Compare commits

..

94 Commits
v1.3.1 ... 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
Vantz Stockwell
ff9fc798c3 fix: resolve 8 Vue 3 lifecycle and component issues
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m5s
VUE-1: store workspace save interval ID, clear in onUnmounted
VUE-2: extract beforeunload handler to named function, remove in onUnmounted
VUE-3: move useTerminal() to <script setup> top level in DetachedSession
VUE-4: call useTerminal() before nextTick await in CopilotPanel launch()
VUE-5: remove duplicate ResizeObserver from LocalTerminalView (useTerminal already creates one)
VUE-6: store terminal.onResize() IDisposable, dispose in onBeforeUnmount
VUE-7: extract connectSsh(), connectRdp(), resolveCredentials() from 220-line connect()
VUE-8: check session protocol before ssh_resize vs pty_resize in TerminalView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:38:00 -04:00
Vantz Stockwell
1b7b1a0051 fix: rdp_type now actually types — clipboard + Ctrl+V keystroke sim
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m44s
Was only setting the remote clipboard without pasting. Now sends
clipboard content then simulates Ctrl+V (scancode 0x001D + 0x002F)
with 50ms delay for clipboard propagation. Works for any text
including special characters and multi-line content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:06:47 -04:00
Vantz Stockwell
f578c434df feat: 31 MCP tools — ssh_connect for autonomous AI operation
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m12s
The AI can now open its own SSH sessions without the Commander
pre-opening them:

- ssh_connect(hostname, username, password?, private_key_path?, port?)
  Returns session_id for use with all other tools
  Supports password auth and SSH key file auth

Also added app_handle and error_watcher to MCP server state so
new sessions get full scrollback, monitoring, and CWD tracking.

This completes the autonomy loop: the AI discovers what's available
(list_sessions), connects to what it needs (ssh_connect), operates
(terminal_execute, docker_ps, sftp_read), and disconnects when done.

Total MCP tools: 31.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:04:53 -04:00
Vantz Stockwell
5aaedbe4a5 feat: 30 MCP tools — RDP click, type, clipboard interaction
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m5s
3 new MCP tools completing the RDP interaction loop:

- rdp_click — click at x,y coordinates (left/right/middle button)
  Use terminal_screenshot first to identify coordinates
- rdp_type — type text into RDP session via clipboard paste
- rdp_clipboard — set clipboard content on remote desktop

The AI can now screenshot an RDP session, analyze it visually,
click buttons, type text, and read clipboard content. Full GUI
automation through the MCP bridge.

Total MCP tools: 30.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:01:22 -04:00
Vantz Stockwell
3c2dc435ff feat: 27 MCP tools — Docker, Git, service, process management
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m3s
8 new MCP tools exposed through the bridge:

Docker:
- docker_ps — list all containers with status/image/ports
- docker_action — start/stop/restart/remove/logs/builder-prune/system-prune
- docker_exec — execute command inside a running container

System:
- service_status — check systemd service status
- process_list — ps aux with optional name filter

Git (remote repos):
- git_status — branch, dirty files, ahead/behind
- git_pull — pull latest changes
- git_log — recent 20 commits

Total MCP tools: 27. All accessible through the wraith-mcp-bridge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:56:10 -04:00
Vantz Stockwell
2307fbe65f fix: terminal resize on tab switch + flickering from activity marking
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m8s
Resize fix:
- ResizeObserver now checks contentRect dimensions before fitting
- Ignores resize events when container width/height < 50px (hidden tab)
- Prevents xterm.js from calculating 8-char columns on zero-width containers

Flickering fix:
- markActivity throttled to once per second per session
- Was firing on every single data event (hundreds per second during
  active output), triggering Vue reactivity updates on the sessions
  array, causing tab bar and session container re-renders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:48:43 -04:00
Vantz Stockwell
661490e925 perf: RDP event-driven frames + MCP terminal \r fix
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m28s
RDP performance overhaul:
- Switched from polling (rAF loop calling rdp_get_frame every tick)
  to event-driven rendering (backend emits rdp:frame:{id} when
  frame buffer updates, frontend fetches on demand)
- Eliminates thousands of empty IPC round-trips per second when
  the screen is idle
- Backend passes AppHandle into run_active_session for event emission
- Frontend uses listen() instead of requestAnimationFrame polling

MCP terminal fix:
- terminal_type and terminal_execute now send \r (carriage return)
  instead of \n (newline) — PTY terminals expect CR to submit
- Fixes commands not auto-sending, requiring manual Enter press

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:44:53 -04:00
Vantz Stockwell
d78cafba93 feat: terminal_type MCP tool + tab resize fix + close confirmation
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m54s
terminal_type MCP tool (19 tools total):
- Fire-and-forget text input to any session
- Optional press_enter flag (default: true)
- No marker wrapping, no output capture
- Use case: AI sends messages/commands without needing output back

Tab resize fix:
- Double requestAnimationFrame before fitAddon.fit() on tab switch
- Container has real dimensions after browser layout pass
- Also sends ssh_resize/pty_resize to backend with correct cols/rows
- Fixes 6-8 char wide terminals after switching tabs

Close confirmation:
- beforeunload event shows browser "Leave page?" dialog
- Only triggers if sessions are active
- Synchronous — cannot hang the close like onCloseRequested did

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:21:53 -04:00
Vantz Stockwell
037c76384b feat: migrate all artifacts to SeaweedFS — single source of truth
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m59s
All build artifacts now upload to files.command.vigilcyber.com/wraith/:
- Installer: /wraith/{ver}/Wraith_{ver}_x64-setup.exe + /wraith/latest/
- MCP bridge: /wraith/{ver}/wraith-mcp-bridge.exe + /wraith/latest/
- Update bundle: /wraith/{ver}/*.nsis.zip
- Update manifest: /wraith/update.json (Tauri updater endpoint)
- Version metadata: /wraith/{ver}/version.json + /wraith/latest/

Removed: Gitea package uploads, Gitea release creation/attachment.
Updated: tauri.conf.json updater endpoint, bridge auto-download URL,
manual update checker download URL.

CI is now: build -> sign -> upload to SeaweedFS. Done.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:52:10 -04:00
Vantz Stockwell
5d472a6e53 fix: window close hanging — replace onCloseRequested with auto-save
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m50s
onCloseRequested async handler was permanently blocking window close
on Windows, even with the 2s timeout. The confirm() dialog and async
invoke chain prevented the close event from completing.

Fix: removed onCloseRequested entirely. Workspace now auto-saves
every 30 seconds via setInterval. Close button works immediately
with no handler blocking it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:46:23 -04:00
Vantz Stockwell
be76a61119 fix: update checker uses Gitea releases API instead of Tauri updater
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m53s
The Tauri native updater needs update.json hosted at a static URL.
Gitea packages don't support a 'latest' alias, so the endpoint
returned 'package does not exist'. Reverted Settings and startup
check to use check_for_updates command which queries the Gitea
releases API directly and works reliably.

The native auto-updater will work once we have proper static hosting
for update.json (or a redirect endpoint). For now, the manual check
+ download page approach is functional.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:16:42 -04:00
Vantz Stockwell
9f6085d251 perf: RDP optimizations — binary IPC, frame throttle, fast PNG
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m8s
1. Binary IPC: get_frame returns Vec<u8> directly instead of base64
   string. Eliminates 33% encoding overhead + string allocation +
   atob() decode on frontend. Frontend receives number[] from Tauri.

2. Frame throttle: reduced from ~30fps to ~20fps (every 3rd rAF tick).
   20% fewer frames with negligible visual difference for remote desktop.

3. Fast PNG compression: screenshot_png_base64 uses Compression::Fast
   for MCP screenshots, reducing encode time.

4. Dirty flag: already existed but documented — empty Vec returned when
   frame hasn't changed, frontend skips rendering.

Net effect: ~45% reduction in IPC bandwidth (no base64 overhead) +
20% fewer frame fetches + faster screenshot encoding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:54:25 -04:00
Vantz Stockwell
58df4ac5c8 fix: MCP sees live sessions — wrap DashMap in Arc for shared state
DashMap::clone() deep-copies all entries into a new map. The MCP
server's cloned SshService/SftpService/RdpService/ScrollbackRegistry
were snapshots from startup that never saw new sessions.

Fix: wrap all DashMap fields in Arc<DashMap<...>> so clones share
the same underlying map. Sessions added after MCP startup are now
visible to MCP tools.

Affected: SshService, SftpService, RdpService, ScrollbackRegistry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:28:13 -04:00
Vantz Stockwell
9c3afa39bd feat: Help menu + fix tab detach rendering
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m48s
Help menu (File → Help):
- Getting Started guide (connections, SFTP, copilot, tabs)
- Keyboard Shortcuts reference table
- MCP Integration page (setup command, all 18 tools documented,
  bridge path auto-populated, architecture explanation)
- About page with version and tech stack
- Opens as a tabbed popup window

Tab detach fixes:
- Added detached-*, editor-*, help-* to capabilities window list
  (detached windows had no event permissions — silent failure)
- SessionContainer filters out detached sessions (active=false)
  so the main window stops rendering the terminal when detached
- Terminal now only renders in the detached popup window

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:07:45 -04:00
Vantz Stockwell
f22f85ac00 feat: MCP bridge auto-download — Wraith manages its own companion binary
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m53s
On startup, Wraith checks if wraith-mcp-bridge exists in the data
directory. If missing or version mismatch, downloads the correct
version from Gitea packages automatically. No installer changes needed.

Flow:
1. Check data_dir/wraith-mcp-bridge.exe exists
2. Check data_dir/mcp-bridge-version matches app version
3. If not, download from packages/vstockwell/generic/wraith/{ver}/
4. Set execute permissions on Unix
5. Write version marker

Also exposes mcp_bridge_path command so the frontend can show the
path in settings for users to add to PATH or configure Claude Code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:58:34 -04:00
Vantz Stockwell
beac33614a fix: window close hanging + add confirmation prompt
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m53s
The onCloseRequested async handler was blocking window close when
save_workspace invoke hung or threw. Fixed with:
1. Confirmation dialog: "Are you sure you want to close Wraith?"
   (only shown if sessions are active, cancel prevents close)
2. Workspace save wrapped in Promise.race with 2s timeout so a
   stuck invoke can never block the close indefinitely

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:45:11 -04:00
Vantz Stockwell
d39c0d38ed fix: local PowerShell garbled output + resize not propagating
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m10s
Two issues:
1. convertEol was false for PTY sessions, but Windows ConPTY sends
   bare \n without \r. Now enabled on Windows PTY sessions (checked
   via navigator.platform). Unix PTY still false (driver handles it).

2. LocalTerminalView had no ResizeObserver, so the terminal never
   reflowed when the container size changed. Added ResizeObserver
   matching the SSH TerminalView pattern. Also added proper cleanup
   on unmount.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:38:24 -04:00
Vantz Stockwell
3638745436 feat: tab detach/reattach — pop sessions into separate windows
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m3s
Right-click any tab → "Detach to Window" opens the session in its
own Tauri window. The tab dims (opacity + italic) while detached.
Close the detached window → session reattaches to the main tab bar.

Architecture:
- DetachedSession.vue: standalone terminal that connects to the same
  backend session (SSH/PTY events keep flowing)
- App.vue detects #/detached-session?sessionId=X hash
- Tab context menu: Detach to Window, Close
- session:reattach event emitted on window close, main window listens
- Monitor bar included in detached SSH windows
- Session.active flag tracks detached state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:37:49 -04:00
Vantz Stockwell
ddce484eb9 fix: remove Unicode em dash from CI PowerShell strings
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m3s
PowerShell parser choked on the em dash character in Write-Host
string literals. Replaced with ASCII-safe alternatives and single
quotes to avoid any encoding issues in the CI runner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:14:30 -04:00
Vantz Stockwell
543bf6b56d fix: PowerShell parse error — colon in string literal
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 3m46s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:01:56 -04:00
Vantz Stockwell
d98600a319 fix: MCP bridge built, signed, and shipped in CI releases
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 3m39s
- Removed useless CLAUDE_MCP_SERVERS env var injection (doesn't work)
- CI builds wraith-mcp-bridge.exe as a separate cargo --bin step
- Bridge binary signed with EV cert alongside the installer
- Uploaded to Gitea packages per version
- Attached to Gitea release as a downloadable asset
- Users add to PATH then: claude mcp add wraith -- wraith-mcp-bridge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:55:17 -04:00
Vantz Stockwell
0c6a4b8109 feat: Tauri auto-updater + RDP vault credentials + sidebar persist
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 2m55s
Tauri auto-updater:
- Signing pubkey in tauri.conf.json
- tauri-plugin-updater initialized in lib.rs
- CI workflow passes TAURI_SIGNING_PRIVATE_KEY env vars to cargo tauri build
- CI generates update.json manifest with signature and uploads to
  packages/latest/update.json endpoint
- Frontend checks for updates on startup via @tauri-apps/plugin-updater
- Downloads, installs, and relaunches seamlessly
- Settings → About button uses native updater too

RDP vault credentials:
- RDP connections now resolve credentials from vault via credentialId
- Same path as SSH: list_credentials → find by ID → decrypt_password
- Falls back to conn.options JSON if no vault credential linked
- Fixes blank username in RDP connect

Sidebar drag persist:
- reorder_connections and reorder_groups Tauri commands
- Batch-update sort_order in database on drop
- Order survives app restart

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:42:01 -04:00
Vantz Stockwell
7c2ab2aa60 fix: error watcher crash — tokio::spawn without runtime context
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m4s
Same root cause as the PTY crash (v1.2.6): tokio::spawn called from
Tauri setup hook without a tokio runtime guard. Switched error watcher
to std:🧵:spawn. Also wrapped both error watcher and MCP server
spawn in individual catch_unwind blocks so neither can crash the app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:16:50 -04:00
Vantz Stockwell
10e0a6b196 fix: workspace restore crash — defer to setTimeout, wrap all imports
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m17s
Workspace restore was running synchronously in onMounted which could
crash if saved connection IDs were stale. The import of
@tauri-apps/api/window for onCloseRequested could also fail in
certain contexts.

Fix: defer restore to setTimeout(500ms) so the app renders first,
wrap each reconnect in individual try/catch, wrap the window close
listener setup in try/catch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:06:34 -04:00
Vantz Stockwell
1b74527a62 feat: tab notifications, session persistence, Docker panel, drag reorder sidebar
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m53s
Tab activity notifications:
- Background tabs pulse blue when new output arrives
- Clears when you switch to the tab
- useTerminal marks activity on every data event

Session persistence:
- Workspace saved to DB on window close (connection IDs + positions)
- Restored on launch — auto-reconnects saved sessions in order
- workspace_commands: save_workspace, load_workspace

Docker Manager (Tools → Docker Manager):
- Containers tab: list all, start/stop/restart/remove/logs
- Images tab: list all, remove
- Volumes tab: list all, remove
- One-click Builder Prune and System Prune buttons
- All operations via SSH exec channels — no Docker socket exposure

Sidebar drag-and-drop:
- Drag groups to reorder
- Drag connections between groups
- Drag connections within a group to reorder
- Blue border indicator on drop targets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:50:49 -04:00
Vantz Stockwell
e6766062b1 fix: MCP startup panic + RDP crypto provider panic
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m57s
MCP: RdpService had a manual Clone impl that called unreachable!().
Replaced with a real clone that shares the DashMap. MCP server can
now clone all services and start successfully.

RDP: rustls needs CryptoProvider::install_default() before any TLS
operations. ironrdp-tls uses rustls for the RDP TLS handshake.
Added aws_lc_rs provider installation at app startup.

Both panics found via wraith.log debug logging from v1.6.3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:28:46 -04:00
Vantz Stockwell
357491b4e8 feat: debug logging macro + MCP tools inject button in copilot
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m58s
Debug logging:
- wraith_log!() macro available in all modules, writes to wraith.log
- SSH connect/auth, PTY spawn, RDP connect all log with session IDs
- MCP startup panic now shows the actual error message

Copilot "Tools" button:
- Shows when a PTY session is active in the copilot panel
- Injects a formatted list of all 18 MCP tools into the chat
- Groups tools by category: session, terminal, SFTP, network, utilities
- Includes parameter signatures so the AI knows how to call them

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:12:23 -04:00
Vantz Stockwell
5d1aeb5fe3 fix: log actual panic message when MCP startup fails
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m54s
Was logging "panicked — continuing" without the WHY. Now captures
the panic payload (String, &str, or type_id) so the log shows
exactly what went wrong in clone_services().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:06:06 -04:00
Vantz Stockwell
03bb6f3ccf fix: RDP panic logging + CWD starts at home directory
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m16s
RDP: wrapped connection thread in catch_unwind so panics are logged
to wraith.log instead of silently killing the channel. Error message
now directs user to check the log.

CWD: changed cd . to cd ~ after OSC 7 hook injection so SFTP starts
at the user's home directory on macOS (where / requires explicit nav).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:02:30 -04:00
Vantz Stockwell
83b746df0e fix: OSC 7 hook quoting — remove extra escaped quotes around pwd
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m59s
The printf argument had escaped quotes that passed through literally,
producing paths like /"/Users/foo". Removed the outer escaped quotes
— printf %s handles the command substitution directly. Also simplified
PROMPT_COMMAND assignment to avoid quote nesting issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:42:56 -04:00
Vantz Stockwell
c1f8d2a14d fix: read version from tauri.conf.json, not CARGO_PKG_VERSION
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Has been cancelled
CARGO_PKG_VERSION is always 0.1.0 (hardcoded in Cargo.toml). CI
patches tauri.conf.json from the git tag. Now reads app_handle.config()
for the real version so the update checker compares correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:42:20 -04:00
Vantz Stockwell
4b26d9190b feat: update checker — startup prompt + Settings → About button
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m2s
Checks Gitea releases API for latest version on startup. If newer
version available, shows confirm dialog to open download page.

Also adds "Check for Updates" button in Settings → About with
version comparison, release notes display, and download button.

Backend: check_for_updates command with semver comparison (6 tests).
96 total tests, zero warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:35:54 -04:00
Vantz Stockwell
e9b504c733 fix: SFTP browser — default to / instead of /home, strip quotes from CWD
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m0s
/home doesn't exist on macOS (home dirs are /Users/). Changed default
SFTP path to / so it always loads. OSC 7 parser now strips stray
quotes from shell printf output that produced paths like /"/path".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:29:38 -04:00
Vantz Stockwell
99f46a2163 fix: remove unused variable in SshKeyGen
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m1s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:21:04 -04:00
Vantz Stockwell
8565f11c11 fix: more portable OSC 7 hook — BEL terminator, %20 space encoding
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 7s
Switched to printf '\e]7;file://localhost/%s\a' with sed space encoding.
BEL (\a) terminator is more universally supported than ST (\e\\).
Shared __wraith_osc7 function avoids duplicating the printf across
bash/zsh branches.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:20:06 -04:00
Vantz Stockwell
0251614732 fix: trigger initial CWD emission with cd . after OSC 7 hook inject
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 6s
precmd/PROMPT_COMMAND only fire after a command runs. Without cd .
the first OSC 7 never emits and SFTP doesn't know the initial directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:18:35 -04:00
Vantz Stockwell
0bcf59865d fix: hide OSC 7 hook injection from terminal output
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 6s
The CWD hook command was visible to the user. Now wrapped in
stty -echo/echo to suppress echo, followed by clear to wipe the
screen. Space prefix prevents history recording in most shells.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:16:20 -04:00
Vantz Stockwell
6b5fad2289 feat: SSH key generator save-to-file buttons
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 6s
Save Private Key and Save Public Key buttons download the generated
keys as id_ed25519/id_rsa (private) and .pub (public) files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:15:15 -04:00
Vantz Stockwell
248381e4ce fix: remove dead EditorWindow import and editorFile ref
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m2s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:59:48 -04:00
Vantz Stockwell
3e548ed10e feat: SFTP editor opens as popup window instead of inline overlay
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 6s
Right-click → Edit now opens a separate Tauri window with the file
content in a monospace editor. Ctrl+S saves back to remote via SFTP.
Tab inserts 4 spaces. Modified indicator in toolbar.

Removed the inline EditorWindow overlay that covered the terminal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:58:09 -04:00
Vantz Stockwell
016906fc9d fix: add window/webview creation permissions for tool popup windows
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Has been cancelled
Tauri v2 ACL requires explicit permissions to create windows from the
frontend. Added core🪟allow-create and
core:webview:allow-create-webview-window so the Tools menu can open
popup windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:56:34 -04:00
Vantz Stockwell
0e88f9f07c feat: file-based logging to wraith.log for MCP startup diagnostics
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Has been cancelled
env_logger was never initialized so log::info/error went nowhere.
Added write_log() that appends to data_dir/wraith.log with timestamps.
Logs MCP server startup success/failure and any panics.

Check: %APPDATA%\Wraith\wraith.log (Windows)
       ~/Library/Application Support/Wraith/wraith.log (macOS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:55:31 -04:00
Vantz Stockwell
44c79decf3 fix: SFTP preserves position on tab switch + CWD following on macOS
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m58s
SFTP tab switch fix:
- Removed :key on FileTree that destroyed component on every switch
- useSftp now accepts a reactive Ref<string> sessionId
- Watches sessionId changes and reinitializes without destroying state
- Per-session path memory via sessionPaths map — switching back to a
  tab restores exactly where you were browsing

CWD following fix (macOS + all platforms):
- Injects OSC 7 prompt hook into the shell after SSH connect
- zsh: precmd() emits \e]7;file://host/path\e\\
- bash: PROMPT_COMMAND emits the same sequence
- Sent via the PTY channel so it configures the interactive shell
- The passive OSC 7 parser in the output loop picks it up
- SFTP sidebar auto-navigates to the current working directory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:41:50 -04:00
Vantz Stockwell
f9c4e2af35 fix: launch presets wait for shell prompt before sending command
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m3s
Was sending the command after a blind 300ms delay with \n — too early
for PowerShell startup banner, and \n caused a blank line before the
command.

Fix: poll mcp_terminal_read every 200ms until a prompt is detected
($, #, %, >, PS>), then send the command with \r (carriage return,
not newline). Falls back to sending after 5s timeout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:37:35 -04:00
Vantz Stockwell
c507c515ef fix: wrap MCP/error watcher startup in catch_unwind — never crash app
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m1s
The MCP server and error watcher are nice-to-have services that were
crashing the app on startup. Wrapped in catch_unwind + error handling
so a failure in these subsystems logs an error instead of taking down
the entire application.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:30:49 -04:00
Vantz Stockwell
6f26822b85 fix: populate version in Settings → About from Tauri app config
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m58s
Used getVersion() from @tauri-apps/api/app which reads the version
from tauri.conf.json (patched by CI from the git tag).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:28:38 -04:00
Vantz Stockwell
ef377e8fe8 fix: PasswordGen TS error — navigator not available in Vue template scope
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m3s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:23:51 -04:00
Vantz Stockwell
15055aeb01 feat: all 18 tools exposed as MCP tools for AI copilot
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 6s
Every tool in Wraith is now callable by the AI through MCP:

| MCP Tool          | AI Use Case                              |
|-------------------|------------------------------------------|
| network_scan      | "What devices are on this subnet?"       |
| port_scan         | "Which servers have SSH open?"           |
| ping              | "Is this host responding?"               |
| traceroute        | "Show me the route to this server"       |
| dns_lookup        | "What's the MX record for this domain?"  |
| whois             | "Who owns this IP?"                      |
| wake_on_lan       | "Wake up the backup server"              |
| bandwidth_test    | "How fast is this server's internet?"    |
| subnet_calc       | "How many hosts in a /22?"               |
| generate_ssh_key  | "Generate an ed25519 key pair"           |
| generate_password | "Generate a 32-char password"            |
| terminal_read     | "What's on screen right now?"            |
| terminal_execute  | "Run df -h on this server"               |
| terminal_screenshot| "What's that RDP error?"                |
| sftp_list/read/write| "Read the nginx config"               |
| list_sessions     | "What sessions are active?"              |

11 new HTTP endpoints on the MCP server. 11 new tool definitions
in the bridge binary. The AI doesn't just chat — it scans, discovers,
analyzes, and connects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:15:08 -04:00
Vantz Stockwell
b3f56a2729 feat: Tools R2 — DNS, Whois, Bandwidth, Subnet Calculator
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 7s
4 new tools with full backend + popup UIs:

DNS Lookup:
- dig/nslookup/host fallback chain on remote host
- Record type selector (A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR)

Whois:
- Remote whois query, first 80 lines
- Works for domains and IP addresses

Bandwidth Test (2 modes):
- iperf3: LAN speed test between remote host and iperf server
- Internet: speedtest-cli / curl-based Cloudflare test fallback

Subnet Calculator:
- Pure Rust, no SSH needed
- CIDR input with quick-select buttons (/8 through /32)
- Displays: network, broadcast, netmask, wildcard, host range,
  total/usable hosts, class, private/public

Tools menu now has 11 items across 3 sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:12:11 -04:00
Vantz Stockwell
875dd1a28f feat: complete Tools suite — 7 tool UIs in popup windows
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 6s
All 7 tool windows with full UIs:
- Network Scanner: subnet scan, ARP+DNS discovery, results table with
  Quick Scan per host, SSH/RDP connect buttons, CSV export
- Port Scanner: custom range or quick scan (24 common ports), open/closed
  results table with service names
- Ping: remote ping with count, raw output display
- Traceroute: remote traceroute, raw output display
- Wake on LAN: MAC address input, broadcasts via python3 on remote host
- SSH Key Generator: ed25519/RSA, copy public/private key, fingerprint
- Password Generator: configurable length/charset, copy button, history

Architecture:
- App.vue detects tool mode via URL hash (#/tool/name?sessionId=...)
- ToolWindow.vue routes to correct tool component
- Tools menu in toolbar opens Tauri popup windows (WebviewWindow)
- Capabilities grant tool-* windows the same permissions as main
- SFTP context menu: right-click Edit/Download/Rename/Delete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:07:15 -04:00
Vantz Stockwell
5cc412a251 feat: Tools menu + backend commands for all tools
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m2s
Tools menu in toolbar (next to File) with 7 tools:
- Network Scanner (existing scan_network command)
- Port Scanner (existing scan_ports/quick_scan commands)
- Ping — via SSH exec channel, cross-platform
- Traceroute — via SSH exec channel
- Wake on LAN — broadcasts WoL magic packet via python3 on remote host
- SSH Key Generator — pure Rust ed25519/RSA keygen via ssh-key crate
- Password Generator — cryptographic random with configurable charset

Backend: all 5 new Tauri commands (tool_ping, tool_traceroute,
tool_wake_on_lan, tool_generate_ssh_key, tool_generate_password)

Frontend: Tools dropdown menu wired, popup window launcher ready.
Tool window UIs (the actual panels inside each popup) to follow.

SFTP context menu: right-click Edit/Download/Rename/Delete working.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:03:34 -04:00
Vantz Stockwell
2d0964f6b2 feat: network scanner + SFTP context menu + CI fix
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m7s
Network scanner (through SSH exec channels):
- scan_network: ping sweep + ARP table + reverse DNS on remote network
- scan_ports: TCP connect scan via bash /dev/tcp (parallel batches of 20)
- quick_scan: 24 common ports (SSH, HTTP, RDP, SMB, DB, etc.)
- Cross-platform: Linux + macOS
- No agent/nmap required — uses standard POSIX commands
- All scans run on the remote host through existing SSH tunnel

SFTP context menu:
- Right-click on files/folders shows Edit, Download, Rename, Delete
- Right-click on folders shows Open Folder
- Teleport menu to body for proper z-index layering
- Click-away handler to close menu
- Rename uses sftp_rename invoke

CI fix:
- Added default-run = "wraith" to Cargo.toml
- The [[bin]] entry for wraith-mcp-bridge confused Cargo about which
  binary is the Tauri app main binary

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:56:42 -04:00
Vantz Stockwell
4532f3beb6 feat: local terminal tabs — + button spawns WSL/Git Bash/PowerShell/CMD
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 16s
The + button in the tab bar now shows a dropdown of detected local
shells. Clicking one opens a full-size PTY terminal in the main
content area as a proper tab — not the copilot sidebar.

- New "local" protocol type in Session interface
- LocalTerminalView component uses useTerminal(id, 'pty')
- SessionContainer renders local sessions alongside SSH/RDP
- TabBadge shows purple dot for local sessions
- Shell detection includes WSL (wsl.exe) on Windows
- closeSession handles PTY disconnect for local tabs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:46:09 -04:00
Vantz Stockwell
2ad6da43eb feat: remote monitoring bar + SFTP tab follow + CWD macOS fix
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 15s
Remote monitoring bar:
- Slim 24px bar at bottom of every SSH terminal
- CPU, RAM, disk, network stats polled every 5s via exec channel
- Cross-platform: Linux (/proc), macOS (vm_stat/sysctl), FreeBSD
- Color-coded thresholds: green/amber/red
- No agent installation — standard POSIX commands only

SFTP follows active tab:
- Added :key="activeSessionId" to FileTree component
- Vue recreates FileTree when session changes, reinitializing SFTP

CWD tracking fix (macOS + all platforms):
- Old approach: exec channel pwd — returns HOME, not actual CWD
- New approach: passive OSC 7 parsing in the output stream
- Scans for \e]7;file://host/path\a without modifying data
- Works with bash, zsh, fish on both Linux and macOS
- Zero corruption risk — data passes through unmodified
- Includes URL percent-decoding for paths with spaces

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:38:01 -04:00
Vantz Stockwell
216cd0cf34 feat: copilot QoL batch — resizable, SFTP, context, errors, presets
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 15s
Resizable panel:
- Drag handle on left border, pointer events, 320px–1200px range

SFTP MCP tools:
- sftp_list, sftp_read, sftp_write — full HTTP endpoints + bridge tools
- SftpService now Clone for MCP server sharing

Active session context:
- mcp_get_session_context — last 20 lines of any session's scrollback

Error watcher:
- Background scanner every 2s across all sessions
- 20+ patterns: permission denied, OOM, segfault, disk full, etc.
- mcp:error events emitted to frontend
- Sessions auto-registered on SSH connect

Configurable launch presets:
- Settings → AI Copilot section with preset editor
- Name + command pairs, stored in settings table as JSON
- One-click preset buttons in copilot panel empty state
- Defaults: Claude Code, Gemini CLI, Codex CLI
- User can set custom commands (e.g. claude --dangerously-skip-permissions)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:32:49 -04:00
Vantz Stockwell
bc608b0683 feat: copilot QoL — resizable panel, SFTP tools, context, error watcher
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 15s
Resizable panel:
- Drag handle on left border of copilot panel
- Pointer events for smooth resize (320px–1200px range)

SFTP MCP tools:
- sftp_list: list remote directories
- sftp_read: read remote files
- sftp_write: write remote files
- Full HTTP endpoints + bridge tool definitions

Active session context:
- mcp_get_session_context command returns last 20 lines of scrollback
- Frontend can call on tab switch to keep AI informed

Error watcher:
- Background scanner runs every 2 seconds across all sessions
- 20+ error patterns (permission denied, OOM, segfault, disk full, etc.)
- Emits mcp:error events to frontend with session ID and matched line
- Sessions auto-registered with watcher on connect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:30:12 -04:00
Vantz Stockwell
add0f0628f feat: MCP auto-inject + RDP screenshot tool
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 16s
- Auto-inject CLAUDE_MCP_SERVERS env var when copilot PTY spawns,
  so Claude Code auto-discovers wraith-mcp-bridge without manual config
- RDP screenshot_png_base64() encodes frame buffer as PNG via png crate
- Bridge binary exposes terminal_screenshot tool returning MCP image
  content (base64 PNG with mimeType) for multimodal AI analysis
- MCP session list now includes RDP sessions with dimensions
- /mcp/screenshot HTTP endpoint on the internal server

"Screenshot that RDP session, what's the error?" now works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:17:36 -04:00
Vantz Stockwell
8276b0cc59 feat: MCP bridge binary + HTTP server + auto-config injection
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 16s
Complete MCP communication pipeline:

Backend HTTP server (axum on localhost:0):
- POST /mcp/sessions — list active sessions
- POST /mcp/terminal/read — read scrollback (ANSI stripped)
- POST /mcp/terminal/execute — send command + marker, capture output
- Port written to data_dir/mcp-port at startup
- Shares SshService and ScrollbackRegistry with AppState via Clone

Bridge binary (wraith-mcp-bridge):
- Speaks JSON-RPC 2.0 over stdio (MCP protocol)
- Translates tool calls to HTTP requests against running Wraith
- Implements initialize, tools/list, tools/call
- Exposes: terminal_read, terminal_execute, list_sessions
- Reads MCP port from data_dir/mcp-port

Auto-config:
- PTY spawn injects WRAITH_MCP_BRIDGE env var
- SshService and ScrollbackRegistry derive Clone for sharing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:10:09 -04:00
91 changed files with 6706 additions and 792 deletions

View File

@ -61,13 +61,24 @@ jobs:
$env:Path = "$env:EXTRA_PATH;$env:Path" $env:Path = "$env:EXTRA_PATH;$env:Path"
cargo install tauri-cli --version "^2" cargo install tauri-cli --version "^2"
- name: Build Tauri app - name: Build Tauri app (with update signing)
shell: powershell shell: powershell
run: | run: |
$env:Path = "$env:EXTRA_PATH;$env:Path" $env:Path = "$env:EXTRA_PATH;$env:Path"
$env:TAURI_SIGNING_PRIVATE_KEY = "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}"
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}"
cargo tauri build cargo tauri build
Write-Host "=== Build output ===" Write-Host "=== Build output ==="
Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*
- name: Build and package MCP bridge binary
shell: powershell
run: |
$env:Path = "$env:EXTRA_PATH;$env:Path"
cd src-tauri
cargo build --release --bin wraith-mcp-bridge
Write-Host "Bridge binary built:"
Get-ChildItem target\release\wraith-mcp-bridge.exe
- name: Download jsign - name: Download jsign
shell: powershell shell: powershell
@ -93,7 +104,10 @@ jobs:
run: | run: |
$env:Path = "$env:EXTRA_PATH;$env:Path" $env:Path = "$env:EXTRA_PATH;$env:Path"
$token = [System.IO.File]::ReadAllText("$env:TEMP\aztoken.txt") $token = [System.IO.File]::ReadAllText("$env:TEMP\aztoken.txt")
$binaries = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe # Sign NSIS installers + MCP bridge binary
$binaries = @()
$binaries += Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
$binaries += Get-Item src-tauri\target\release\wraith-mcp-bridge.exe -ErrorAction SilentlyContinue
foreach ($binary in $binaries) { foreach ($binary in $binaries) {
Write-Host "Signing: $($binary.FullName)" Write-Host "Signing: $($binary.FullName)"
java -jar jsign.jar --storetype AZUREKEYVAULT --keystore "${{ secrets.AZURE_KEY_VAULT_URL }}" --storepass $token --alias "${{ secrets.AZURE_CERT_NAME }}" --tsaurl http://timestamp.digicert.com --tsmode RFC3161 $binary.FullName java -jar jsign.jar --storetype AZUREKEYVAULT --keystore "${{ secrets.AZURE_KEY_VAULT_URL }}" --storepass $token --alias "${{ secrets.AZURE_CERT_NAME }}" --tsaurl http://timestamp.digicert.com --tsmode RFC3161 $binary.FullName
@ -101,42 +115,83 @@ jobs:
} }
Remove-Item "$env:TEMP\aztoken.txt" -ErrorAction SilentlyContinue Remove-Item "$env:TEMP\aztoken.txt" -ErrorAction SilentlyContinue
- name: Upload to Gitea - name: Upload all artifacts to SeaweedFS
shell: powershell shell: powershell
run: | run: |
$ver = ("${{ github.ref_name }}" -replace '^v','') $ver = ("${{ github.ref_name }}" -replace '^v','')
$giteaUrl = "https://git.command.vigilcyber.com" $s3 = "https://files.command.vigilcyber.com/wraith"
$headers = @{ Authorization = "token ${{ secrets.GIT_TOKEN }}" }
# Upload installer
$installers = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe $installers = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
foreach ($file in $installers) { foreach ($file in $installers) {
$hash = (Get-FileHash $file.FullName -Algorithm SHA256).Hash.ToLower()
@{ version = $ver; filename = $file.Name; sha256 = $hash; platform = "windows"; architecture = "amd64"; released = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"); signed = $true } | ConvertTo-Json | Out-File version.json -Encoding utf8
Write-Host "Uploading: $($file.Name)" Write-Host "Uploading: $($file.Name)"
Invoke-RestMethod -Uri "$giteaUrl/api/packages/vstockwell/generic/wraith/$ver/$($file.Name)" -Method PUT -Headers $headers -ContentType "application/octet-stream" -InFile $file.FullName Invoke-RestMethod -Uri "$s3/$ver/$($file.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $file.FullName
# Also upload as 'latest' for direct download links
Write-Host "Uploading: version.json" Invoke-RestMethod -Uri "$s3/latest/$($file.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $file.FullName
Invoke-RestMethod -Uri "$giteaUrl/api/packages/vstockwell/generic/wraith/$ver/version.json" -Method PUT -Headers $headers -ContentType "application/octet-stream" -InFile version.json
} }
Write-Host "=== Upload complete ===" # Upload MCP bridge binary
$bridge = "src-tauri\target\release\wraith-mcp-bridge.exe"
if (Test-Path $bridge) {
Write-Host "Uploading: wraith-mcp-bridge.exe"
Invoke-RestMethod -Uri "$s3/$ver/wraith-mcp-bridge.exe" -Method PUT -ContentType "application/octet-stream" -InFile $bridge
Invoke-RestMethod -Uri "$s3/latest/wraith-mcp-bridge.exe" -Method PUT -ContentType "application/octet-stream" -InFile $bridge
}
- name: Create Release and attach installers # Upload .nsis.zip for Tauri auto-updater
$zipFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip | Select-Object -First 1
if ($zipFile) {
Write-Host "Uploading: $($zipFile.Name)"
Invoke-RestMethod -Uri "$s3/$ver/$($zipFile.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $zipFile.FullName
}
# Upload version.json metadata
$installer = $installers | Select-Object -First 1
if ($installer) {
$hash = (Get-FileHash $installer.FullName -Algorithm SHA256).Hash.ToLower()
@{ version = $ver; filename = $installer.Name; sha256 = $hash; platform = "windows"; architecture = "amd64"; released = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"); signed = $true } | ConvertTo-Json | Out-File version.json -Encoding utf8
Invoke-RestMethod -Uri "$s3/$ver/version.json" -Method PUT -ContentType "application/json" -InFile version.json
Invoke-RestMethod -Uri "$s3/latest/version.json" -Method PUT -ContentType "application/json" -InFile version.json
}
Write-Host "=== SeaweedFS upload complete ==="
- name: Generate and upload update.json for Tauri updater
shell: powershell shell: powershell
run: | run: |
$ver = ("${{ github.ref_name }}" -replace '^v','') $ver = ("${{ github.ref_name }}" -replace '^v','')
$giteaUrl = "https://git.command.vigilcyber.com" $s3 = "https://files.command.vigilcyber.com/wraith"
$headers = @{ Authorization = "token ${{ secrets.GIT_TOKEN }}"; "Content-Type" = "application/json" }
$body = @{ tag_name = "v$ver"; name = "Wraith v$ver"; body = "Wraith Desktop v$ver - Tauri v2 / Rust build." } | ConvertTo-Json
$release = Invoke-RestMethod -Uri "$giteaUrl/api/v1/repos/vstockwell/wraith/releases" -Method POST -Headers $headers -Body $body
$releaseId = $release.id
Write-Host "Release v$ver created (id: $releaseId)"
$installers = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe $sigFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip.sig | Select-Object -First 1
$uploadHeaders = @{ Authorization = "token ${{ secrets.GIT_TOKEN }}" } $zipFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip | Select-Object -First 1
foreach ($file in $installers) {
Write-Host "Attaching $($file.Name) to release..." if ($sigFile -and $zipFile) {
Invoke-RestMethod -Uri "$giteaUrl/api/v1/repos/vstockwell/wraith/releases/$releaseId/assets?name=$($file.Name)" -Method POST -Headers $uploadHeaders -ContentType "application/octet-stream" -InFile $file.FullName $signature = Get-Content $sigFile.FullName -Raw
Write-Host "Attached: $($file.Name)" $downloadUrl = "$s3/$ver/$($zipFile.Name)"
$updateJson = @{
version = "v$ver"
notes = "Wraith Desktop v$ver"
pub_date = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
platforms = @{
"windows-x86_64" = @{
signature = $signature.Trim()
url = $downloadUrl
} }
}
} | ConvertTo-Json -Depth 4
$updateJson | Out-File update.json -Encoding utf8
Write-Host "update.json content:"
Get-Content update.json
# Upload to root (Tauri updater endpoint)
Invoke-RestMethod -Uri "$s3/update.json" -Method PUT -ContentType "application/json" -InFile update.json
# Also versioned copy
Invoke-RestMethod -Uri "$s3/$ver/update.json" -Method PUT -ContentType "application/json" -InFile update.json
Write-Host "=== Update manifest uploaded ==="
} else {
Write-Host 'WARNING - No .sig file found, update signing may have failed'
}

2
.gitignore vendored
View File

@ -1,5 +1,7 @@
node_modules/ node_modules/
dist/ dist/
src-tauri/target/ src-tauri/target/
src-tauri/binaries/
*.log *.log
.DS_Store .DS_Store
.claude/worktrees/

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

20
package-lock.json generated
View File

@ -19,7 +19,9 @@
"@codemirror/theme-one-dark": "^6.0.0", "@codemirror/theme-one-dark": "^6.0.0",
"@codemirror/view": "^6.0.0", "@codemirror/view": "^6.0.0",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.10.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0", "@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
@ -1516,6 +1518,15 @@
"url": "https://opencollective.com/tauri" "url": "https://opencollective.com/tauri"
} }
}, },
"node_modules/@tauri-apps/plugin-process": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-shell": { "node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.5", "version": "2.3.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
@ -1525,6 +1536,15 @@
"@tauri-apps/api": "^2.10.1" "@tauri-apps/api": "^2.10.1"
} }
}, },
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz",
"integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",

View File

@ -10,31 +10,33 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.0", "@codemirror/autocomplete": "^6.0.0",
"pinia": "^3.0.0", "@codemirror/commands": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/lang-python": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/theme-one-dark": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0",
"@xterm/xterm": "^6.0.0", "@tauri-apps/plugin-updater": "^2.10.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0", "@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@codemirror/view": "^6.0.0", "@xterm/xterm": "^6.0.0",
"@codemirror/state": "^6.0.0", "pinia": "^3.0.0",
"@codemirror/commands": "^6.0.0", "vue": "^3.5.0"
"@codemirror/language": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-python": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/theme-one-dark": "^6.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"@vitejs/plugin-vue": "^5.0.0", "vue-tsc": "^2.0.0"
"vue-tsc": "^2.0.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/vite": "^4.0.0"
} }
} }

118
src-tauri/Cargo.lock generated
View File

@ -356,6 +356,58 @@ dependencies = [
"fs_extra", "fs_extra",
] ]
[[package]]
name = "axum"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "base16ct" name = "base16ct"
version = "0.2.0" version = "0.2.0"
@ -2596,6 +2648,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "hybrid-array" name = "hybrid-array"
version = "0.4.8" version = "0.4.8"
@ -2621,6 +2679,7 @@ dependencies = [
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
"httpdate",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
@ -2932,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",
@ -3495,6 +3555,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]] [[package]]
name = "md-5" name = "md-5"
version = "0.10.6" version = "0.10.6"
@ -5974,6 +6040,17 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_repr" name = "serde_repr"
version = "0.1.20" version = "0.1.20"
@ -7402,6 +7479,7 @@ dependencies = [
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@ -7591,6 +7669,35 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64 0.22.1",
"http",
"httparse",
"log",
]
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@ -7622,6 +7729,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@ -8768,6 +8881,7 @@ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
"async-trait", "async-trait",
"axum",
"base64 0.22.1", "base64 0.22.1",
"block-padding 0.3.3", "block-padding 0.3.3",
"cbc 0.1.2", "cbc 0.1.2",
@ -8781,6 +8895,7 @@ dependencies = [
"md5", "md5",
"pem", "pem",
"pkcs8 0.10.2", "pkcs8 0.10.2",
"png",
"portable-pty", "portable-pty",
"rand 0.9.2", "rand 0.9.2",
"reqwest 0.12.28", "reqwest 0.12.28",
@ -8799,8 +8914,11 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-util",
"ureq",
"uuid", "uuid",
"x509-cert", "x509-cert",
"zeroize",
] ]
[[package]] [[package]]

View File

@ -2,16 +2,25 @@
name = "wraith" name = "wraith"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
default-run = "wraith"
[lib] [lib]
name = "wraith_lib" name = "wraith_lib"
crate-type = ["lib", "cdylib", "staticlib"] crate-type = ["lib", "cdylib", "staticlib"]
[[bin]]
name = "wraith-mcp-bridge"
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"
@ -28,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"
@ -48,8 +59,13 @@ sec1 = { version = "0.7", features = ["pem"] }
# Local PTY for AI copilot panel # Local PTY for AI copilot panel
portable-pty = "0.8" portable-pty = "0.8"
# MCP HTTP server (for bridge binary communication)
axum = "0.8"
ureq = "3"
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

@ -1,11 +1,15 @@
{ {
"identifier": "default", "identifier": "default",
"description": "Default capabilities for the main Wraith window", "description": "Default capabilities for the main Wraith window",
"windows": ["main"], "windows": ["main", "tool-*", "detached-*", "editor-*", "help-*"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:event:default", "core:event:default",
"core:window:default", "core:window:default",
"shell:allow-open" "core:window:allow-create",
"core:webview:default",
"core:webview:allow-create-webview-window",
"shell:allow-open",
"updater:default"
] ]
} }

View File

@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for the main Wraith window","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","shell:allow-open"]}} {"default":{"identifier":"default","description":"Default capabilities for the main Wraith window","local":true,"windows":["main","tool-*","detached-*","editor-*","help-*"],"permissions":["core:default","core:event:default","core:window:default","core:window:allow-create","core:webview:default","core:webview:allow-create-webview-window","shell:allow-open","updater:default"]}}

View File

@ -0,0 +1,499 @@
//! Wraith MCP Bridge — stdio JSON-RPC proxy to Wraith's HTTP API.
//!
//! This binary is spawned by AI CLIs (Claude Code, Gemini CLI) as an MCP
//! server. It reads JSON-RPC requests from stdin, translates them to HTTP
//! calls against the running Wraith instance, and writes responses to stdout.
//!
//! The Wraith instance's MCP HTTP port is read from the data directory's
//! `mcp-port` file.
use std::io::{self, BufRead, Write};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Deserialize)]
#[allow(dead_code)]
struct JsonRpcRequest {
jsonrpc: String,
id: Value,
method: String,
#[serde(default)]
params: Value,
}
#[derive(Serialize)]
struct JsonRpcResponse {
jsonrpc: String,
id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<JsonRpcError>,
}
#[derive(Serialize)]
struct JsonRpcError {
code: i32,
message: String,
}
fn get_data_dir() -> Result<std::path::PathBuf, String> {
if let Ok(appdata) = std::env::var("APPDATA") {
Ok(std::path::PathBuf::from(appdata).join("Wraith"))
} else if let Ok(home) = std::env::var("HOME") {
if cfg!(target_os = "macos") {
Ok(std::path::PathBuf::from(home).join("Library").join("Application Support").join("Wraith"))
} else {
Ok(std::path::PathBuf::from(home).join(".local").join("share").join("wraith"))
}
} else {
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)
.map_err(|e| format!("Cannot read MCP port file at {}: {} — is Wraith running?", port_file.display(), e))?;
port_str.trim().parse::<u16>()
.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 {
JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "wraith-terminal",
"version": "1.0.0"
}
})),
error: None,
}
}
fn handle_tools_list(id: Value) -> JsonRpcResponse {
JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(serde_json::json!({
"tools": [
{
"name": "terminal_type",
"description": "Type text into a terminal session (like a human typing). Optionally presses Enter after. Use this to send messages or commands without output capture.",
"inputSchema": {
"type": "object",
"properties": {
"session_id": { "type": "string", "description": "The session ID to type into" },
"text": { "type": "string", "description": "The text to type" },
"press_enter": { "type": "boolean", "description": "Whether to press Enter after typing (default: true)" }
},
"required": ["session_id", "text"]
}
},
{
"name": "terminal_read",
"description": "Read recent terminal output from an active SSH or PTY session (ANSI codes stripped)",
"inputSchema": {
"type": "object",
"properties": {
"session_id": { "type": "string", "description": "The session ID to read from. Use list_sessions to find IDs." },
"lines": { "type": "number", "description": "Number of recent lines to return (default: 50)" }
},
"required": ["session_id"]
}
},
{
"name": "terminal_execute",
"description": "Execute a command in an active SSH session and return the output",
"inputSchema": {
"type": "object",
"properties": {
"session_id": { "type": "string", "description": "The SSH session ID to execute in" },
"command": { "type": "string", "description": "The command to run" },
"timeout_ms": { "type": "number", "description": "Max wait time in ms (default: 5000)" }
},
"required": ["session_id", "command"]
}
},
{
"name": "terminal_screenshot",
"description": "Capture a screenshot of an active RDP session as a base64-encoded PNG image for visual analysis",
"inputSchema": {
"type": "object",
"properties": {
"session_id": { "type": "string", "description": "The RDP session ID to screenshot" }
},
"required": ["session_id"]
}
},
{
"name": "sftp_list",
"description": "List files in a directory on a remote host via SFTP",
"inputSchema": {
"type": "object",
"properties": {
"session_id": { "type": "string", "description": "The SSH session ID" },
"path": { "type": "string", "description": "Remote directory path" }
},
"required": ["session_id", "path"]
}
},
{
"name": "sftp_read",
"description": "Read a file from a remote host via SFTP",
"inputSchema": {
"type": "object",
"properties": {
"session_id": { "type": "string", "description": "The SSH session ID" },
"path": { "type": "string", "description": "Remote file path" }
},
"required": ["session_id", "path"]
}
},
{
"name": "sftp_write",
"description": "Write content to a file on a remote host via SFTP",
"inputSchema": {
"type": "object",
"properties": {
"session_id": { "type": "string", "description": "The SSH session ID" },
"path": { "type": "string", "description": "Remote file path" },
"content": { "type": "string", "description": "File content to write" }
},
"required": ["session_id", "path", "content"]
}
},
{
"name": "network_scan",
"description": "Discover all devices on a remote network subnet via ARP + ping sweep",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "subnet": { "type": "string", "description": "First 3 octets, e.g. 192.168.1" } }, "required": ["session_id", "subnet"] }
},
{
"name": "port_scan",
"description": "Scan TCP ports on a target host through an SSH session",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "target": { "type": "string" }, "ports": { "type": "array", "items": { "type": "number" }, "description": "Specific ports. Omit for quick scan of 24 common ports." } }, "required": ["session_id", "target"] }
},
{
"name": "ping",
"description": "Ping a host through an SSH session",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "target": { "type": "string" } }, "required": ["session_id", "target"] }
},
{
"name": "traceroute",
"description": "Traceroute to a host through an SSH session",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "target": { "type": "string" } }, "required": ["session_id", "target"] }
},
{
"name": "dns_lookup",
"description": "DNS lookup for a domain through an SSH session",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "domain": { "type": "string" }, "record_type": { "type": "string", "description": "A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR" } }, "required": ["session_id", "domain"] }
},
{
"name": "whois",
"description": "Whois lookup for a domain or IP through an SSH session",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "target": { "type": "string" } }, "required": ["session_id", "target"] }
},
{
"name": "wake_on_lan",
"description": "Send Wake-on-LAN magic packet through an SSH session to wake a device",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "mac_address": { "type": "string", "description": "MAC address (AA:BB:CC:DD:EE:FF)" } }, "required": ["session_id", "mac_address"] }
},
{
"name": "bandwidth_test",
"description": "Run an internet speed test on a remote host through SSH",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" } }, "required": ["session_id"] }
},
{
"name": "subnet_calc",
"description": "Calculate subnet details from CIDR notation (no SSH needed)",
"inputSchema": { "type": "object", "properties": { "cidr": { "type": "string", "description": "e.g. 192.168.1.0/24" } }, "required": ["cidr"] }
},
{
"name": "generate_ssh_key",
"description": "Generate an SSH key pair (ed25519 or RSA)",
"inputSchema": { "type": "object", "properties": { "key_type": { "type": "string", "description": "ed25519 or rsa" }, "comment": { "type": "string" } }, "required": ["key_type"] }
},
{
"name": "generate_password",
"description": "Generate a cryptographically secure random password",
"inputSchema": { "type": "object", "properties": { "length": { "type": "number" }, "uppercase": { "type": "boolean" }, "lowercase": { "type": "boolean" }, "digits": { "type": "boolean" }, "symbols": { "type": "boolean" } } }
},
{
"name": "docker_ps",
"description": "List all Docker containers with status, image, and ports",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" } }, "required": ["session_id"] }
},
{
"name": "docker_action",
"description": "Perform a Docker action: start, stop, restart, remove, logs, builder-prune, system-prune",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "action": { "type": "string", "description": "start|stop|restart|remove|logs|builder-prune|system-prune" }, "target": { "type": "string", "description": "Container name (not needed for prune actions)" } }, "required": ["session_id", "action", "target"] }
},
{
"name": "docker_exec",
"description": "Execute a command inside a running Docker container",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "container": { "type": "string" }, "command": { "type": "string" } }, "required": ["session_id", "container", "command"] }
},
{
"name": "service_status",
"description": "Check systemd service status on a remote host",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "target": { "type": "string", "description": "Service name" } }, "required": ["session_id", "target"] }
},
{
"name": "process_list",
"description": "List processes on a remote host (top CPU by default, or filter by name)",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "target": { "type": "string", "description": "Process name filter (empty for top 30 by CPU)" } }, "required": ["session_id", "target"] }
},
{
"name": "git_status",
"description": "Get git status of a remote repository",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "path": { "type": "string", "description": "Path to the git repo on the remote host" } }, "required": ["session_id", "path"] }
},
{
"name": "git_pull",
"description": "Pull latest changes on a remote repository",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "path": { "type": "string" } }, "required": ["session_id", "path"] }
},
{
"name": "git_log",
"description": "Show recent commits on a remote repository",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "path": { "type": "string" } }, "required": ["session_id", "path"] }
},
{
"name": "rdp_click",
"description": "Click at a position in an RDP session (use terminal_screenshot first to see coordinates)",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "x": { "type": "number" }, "y": { "type": "number" }, "button": { "type": "string", "description": "left (default), right, or middle" } }, "required": ["session_id", "x", "y"] }
},
{
"name": "rdp_type",
"description": "Type text into an RDP session via clipboard paste",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "text": { "type": "string" } }, "required": ["session_id", "text"] }
},
{
"name": "rdp_clipboard",
"description": "Set the clipboard content on a remote RDP session",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "text": { "type": "string" } }, "required": ["session_id", "text"] }
},
{
"name": "ssh_connect",
"description": "Open a new SSH connection through Wraith. Returns the session ID for use with other tools.",
"inputSchema": { "type": "object", "properties": {
"hostname": { "type": "string" },
"port": { "type": "number", "description": "Default: 22" },
"username": { "type": "string" },
"password": { "type": "string", "description": "Password (for password auth)" },
"private_key_path": { "type": "string", "description": "Path to SSH private key file on the local machine" }
}, "required": ["hostname", "username"] }
},
{
"name": "list_sessions",
"description": "List all active Wraith sessions (SSH, RDP, PTY) with connection details",
"inputSchema": {
"type": "object",
"properties": {}
}
}
]
})),
error: None,
}
}
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 body_str = serde_json::to_string(&body).unwrap_or_default();
let mut resp = ureq::post(url)
.header("Content-Type", "application/json")
.header("Authorization", &format!("Bearer {}", token))
.send(body_str.as_bytes())
.map_err(|e| format!("HTTP request to Wraith failed: {}", e))?;
let resp_str = resp.body_mut().read_to_string()
.map_err(|e| format!("Failed to read Wraith response: {}", e))?;
let json: Value = serde_json::from_str(&resp_str)
.map_err(|e| format!("Failed to parse Wraith response: {}", e))?;
if json.get("ok").and_then(|v| v.as_bool()) == Some(true) {
Ok(json.get("data").cloned().unwrap_or(Value::Null))
} else {
let err_msg = json.get("error").and_then(|e| e.as_str()).unwrap_or("Unknown error");
Err(err_msg.to_string())
}
}
fn handle_tool_call(id: Value, port: u16, token: &str, tool_name: &str, args: &Value) -> JsonRpcResponse {
let result = match tool_name {
"list_sessions" => call_wraith(port, token, "/mcp/sessions", serde_json::json!({})),
"terminal_type" => call_wraith(port, token, "/mcp/terminal/type", args.clone()),
"terminal_read" => call_wraith(port, token, "/mcp/terminal/read", args.clone()),
"terminal_execute" => call_wraith(port, token, "/mcp/terminal/execute", args.clone()),
"sftp_list" => call_wraith(port, token, "/mcp/sftp/list", args.clone()),
"sftp_read" => call_wraith(port, token, "/mcp/sftp/read", args.clone()),
"sftp_write" => call_wraith(port, token, "/mcp/sftp/write", args.clone()),
"network_scan" => call_wraith(port, token, "/mcp/tool/scan-network", args.clone()),
"port_scan" => call_wraith(port, token, "/mcp/tool/scan-ports", args.clone()),
"ping" => call_wraith(port, token, "/mcp/tool/ping", args.clone()),
"traceroute" => call_wraith(port, token, "/mcp/tool/traceroute", args.clone()),
"dns_lookup" => call_wraith(port, token, "/mcp/tool/dns", args.clone()),
"whois" => call_wraith(port, token, "/mcp/tool/whois", args.clone()),
"wake_on_lan" => call_wraith(port, token, "/mcp/tool/wol", args.clone()),
"bandwidth_test" => call_wraith(port, token, "/mcp/tool/bandwidth", args.clone()),
"subnet_calc" => call_wraith(port, token, "/mcp/tool/subnet", args.clone()),
"generate_ssh_key" => call_wraith(port, token, "/mcp/tool/keygen", args.clone()),
"generate_password" => call_wraith(port, token, "/mcp/tool/passgen", args.clone()),
"docker_ps" => call_wraith(port, token, "/mcp/docker/ps", args.clone()),
"docker_action" => call_wraith(port, token, "/mcp/docker/action", args.clone()),
"docker_exec" => call_wraith(port, token, "/mcp/docker/exec", args.clone()),
"service_status" => call_wraith(port, token, "/mcp/service/status", args.clone()),
"process_list" => call_wraith(port, token, "/mcp/process/list", args.clone()),
"git_status" => call_wraith(port, token, "/mcp/git/status", args.clone()),
"git_pull" => call_wraith(port, token, "/mcp/git/pull", args.clone()),
"git_log" => call_wraith(port, token, "/mcp/git/log", args.clone()),
"rdp_click" => call_wraith(port, token, "/mcp/rdp/click", args.clone()),
"rdp_type" => call_wraith(port, token, "/mcp/rdp/type", args.clone()),
"rdp_clipboard" => call_wraith(port, token, "/mcp/rdp/clipboard", args.clone()),
"ssh_connect" => call_wraith(port, token, "/mcp/ssh/connect", args.clone()),
"terminal_screenshot" => {
let result = call_wraith(port, token, "/mcp/screenshot", args.clone());
// Screenshot returns base64 PNG — wrap as image content for multimodal AI
return match result {
Ok(b64) => JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(serde_json::json!({
"content": [{
"type": "image",
"data": b64,
"mimeType": "image/png"
}]
})),
error: None,
},
Err(e) => JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: None,
error: Some(JsonRpcError { code: -32000, message: e }),
},
};
}
_ => Err(format!("Unknown tool: {}", tool_name)),
};
match result {
Ok(data) => JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(serde_json::json!({
"content": [{
"type": "text",
"text": if data.is_string() {
data.as_str().unwrap().to_string()
} else {
serde_json::to_string_pretty(&data).unwrap_or_default()
}
}]
})),
error: None,
},
Err(e) => JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: None,
error: Some(JsonRpcError { code: -32000, message: e }),
},
}
}
fn main() {
let port = match get_mcp_port() {
Ok(p) => p,
Err(e) => {
eprintln!("wraith-mcp-bridge: {}", e);
std::process::exit(1);
}
};
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 mut stdout = io::stdout();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.trim().is_empty() {
continue;
}
let request: JsonRpcRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
let err_resp = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: Value::Null,
result: None,
error: Some(JsonRpcError { code: -32700, message: format!("Parse error: {}", e) }),
};
let _ = writeln!(stdout, "{}", serde_json::to_string(&err_resp).unwrap());
let _ = stdout.flush();
continue;
}
};
let response = match request.method.as_str() {
"initialize" => handle_initialize(request.id),
"tools/list" => handle_tools_list(request.id),
"tools/call" => {
let tool_name = request.params.get("name")
.and_then(|v| v.as_str())
.unwrap_or("");
let args = request.params.get("arguments")
.cloned()
.unwrap_or(Value::Object(serde_json::Map::new()));
handle_tool_call(request.id, port, &token, tool_name, &args)
}
"notifications/initialized" | "notifications/cancelled" => {
// Notifications don't get responses
continue;
}
_ => JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: request.id,
result: None,
error: Some(JsonRpcError { code: -32601, message: format!("Method not found: {}", request.method) }),
},
};
let _ = writeln!(stdout, "{}", serde_json::to_string(&response).unwrap());
let _ = stdout.flush();
}
}

View File

@ -92,3 +92,19 @@ pub fn search_connections(
) -> Result<Vec<ConnectionRecord>, String> { ) -> Result<Vec<ConnectionRecord>, String> {
state.connections.search(&query) state.connections.search(&query)
} }
#[tauri::command]
pub fn reorder_connections(
ids: Vec<i64>,
state: State<'_, AppState>,
) -> Result<(), String> {
state.connections.reorder_connections(&ids)
}
#[tauri::command]
pub fn reorder_groups(
ids: Vec<i64>,
state: State<'_, AppState>,
) -> Result<(), String> {
state.connections.reorder_groups(&ids)
}

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

@ -0,0 +1,105 @@
//! Tauri commands for Docker management via SSH exec channels.
use tauri::State;
use serde::Serialize;
use crate::AppState;
use crate::ssh::exec::exec_on_session;
use crate::utils::shell_escape;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DockerContainer {
pub id: String,
pub name: String,
pub image: String,
pub status: String,
pub ports: String,
pub created: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DockerImage {
pub id: String,
pub repository: String,
pub tag: String,
pub size: String,
pub created: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DockerVolume {
pub name: String,
pub driver: String,
pub mountpoint: String,
}
#[tauri::command]
pub async fn docker_list_containers(session_id: String, all: Option<bool>, state: State<'_, AppState>) -> Result<Vec<DockerContainer>, String> {
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
let flag = if all.unwrap_or(true) { "-a" } else { "" };
let output = exec_on_session(&session.handle, &format!("docker ps {} --format '{{{{.ID}}}}|{{{{.Names}}}}|{{{{.Image}}}}|{{{{.Status}}}}|{{{{.Ports}}}}|{{{{.CreatedAt}}}}' 2>&1", flag)).await?;
Ok(output.lines().filter(|l| !l.is_empty() && !l.starts_with("CONTAINER")).map(|line| {
let p: Vec<&str> = line.splitn(6, '|').collect();
DockerContainer {
id: p.first().unwrap_or(&"").to_string(),
name: p.get(1).unwrap_or(&"").to_string(),
image: p.get(2).unwrap_or(&"").to_string(),
status: p.get(3).unwrap_or(&"").to_string(),
ports: p.get(4).unwrap_or(&"").to_string(),
created: p.get(5).unwrap_or(&"").to_string(),
}
}).collect())
}
#[tauri::command]
pub async fn docker_list_images(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerImage>, String> {
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
let output = exec_on_session(&session.handle, "docker images --format '{{.ID}}|{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}' 2>&1").await?;
Ok(output.lines().filter(|l| !l.is_empty()).map(|line| {
let p: Vec<&str> = line.splitn(5, '|').collect();
DockerImage {
id: p.first().unwrap_or(&"").to_string(),
repository: p.get(1).unwrap_or(&"").to_string(),
tag: p.get(2).unwrap_or(&"").to_string(),
size: p.get(3).unwrap_or(&"").to_string(),
created: p.get(4).unwrap_or(&"").to_string(),
}
}).collect())
}
#[tauri::command]
pub async fn docker_list_volumes(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerVolume>, String> {
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
let output = exec_on_session(&session.handle, "docker volume ls --format '{{.Name}}|{{.Driver}}|{{.Mountpoint}}' 2>&1").await?;
Ok(output.lines().filter(|l| !l.is_empty()).map(|line| {
let p: Vec<&str> = line.splitn(3, '|').collect();
DockerVolume {
name: p.first().unwrap_or(&"").to_string(),
driver: p.get(1).unwrap_or(&"").to_string(),
mountpoint: p.get(2).unwrap_or(&"").to_string(),
}
}).collect())
}
#[tauri::command]
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 t = shell_escape(&target);
let cmd = match action.as_str() {
"start" => format!("docker start {} 2>&1", t),
"stop" => format!("docker stop {} 2>&1", t),
"restart" => format!("docker restart {} 2>&1", t),
"remove" => format!("docker rm -f {} 2>&1", t),
"logs" => format!("docker logs --tail 100 {} 2>&1", t),
"remove-image" => format!("docker rmi {} 2>&1", t),
"remove-volume" => format!("docker volume rm {} 2>&1", t),
"builder-prune" => "docker builder 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(),
_ => return Err(format!("Unknown docker action: {}", action)),
};
exec_on_session(&session.handle, &cmd).await
}

View File

@ -70,7 +70,7 @@ pub async fn mcp_terminal_execute(
let before = buf.total_written(); let before = buf.total_written();
// Send command + marker echo // Send command + marker echo
let full_cmd = format!("{}\necho {}\n", command, marker); let full_cmd = format!("{}\recho {}\r", command, marker);
state.ssh.write(&session_id, full_cmd.as_bytes()).await?; state.ssh.write(&session_id, full_cmd.as_bytes()).await?;
// Poll scrollback until marker appears or timeout // Poll scrollback until marker appears or timeout
@ -116,6 +116,27 @@ 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;
} }
} }
/// Get the path where the MCP bridge binary is installed.
#[tauri::command]
pub fn mcp_bridge_path() -> String {
crate::mcp::bridge_manager::bridge_path().to_string_lossy().to_string()
}
/// Get the active session context — last 20 lines of scrollback for a session.
/// Called by the frontend when the user switches tabs, emitted to the copilot.
#[tauri::command]
pub fn mcp_get_session_context(
session_id: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let buf = state.scrollback.get(&session_id)
.ok_or_else(|| format!("No scrollback buffer for session {}", session_id))?;
Ok(buf.read_lines(20))
}

View File

@ -8,3 +8,10 @@ pub mod rdp_commands;
pub mod theme_commands; pub mod theme_commands;
pub mod pty_commands; pub mod pty_commands;
pub mod mcp_commands; pub mod mcp_commands;
pub mod scanner_commands;
pub mod tools_commands;
pub mod updater;
pub mod tools_commands_r2;
pub mod workspace_commands;
pub mod docker_commands;
pub mod window_commands;

View File

@ -3,35 +3,53 @@
//! Mirrors the pattern used by `ssh_commands.rs` — thin command wrappers that //! Mirrors the pattern used by `ssh_commands.rs` — thin command wrappers that
//! delegate to the `RdpService` via `State<AppState>`. //! delegate to the `RdpService` via `State<AppState>`.
use tauri::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;
/// Connect to an RDP server. /// Connect to an RDP server.
///
/// Performs the full connection handshake (TCP -> TLS -> CredSSP -> RDP) and
/// starts streaming frame updates in the background.
///
/// Returns the session UUID.
#[tauri::command] #[tauri::command]
pub fn connect_rdp( pub fn connect_rdp(
config: RdpConfig, config: RdpConfig,
app_handle: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<String, String> { ) -> Result<String, String> {
state.rdp.connect(config) state.rdp.connect(config, app_handle)
} }
/// Get the current frame buffer as a base64-encoded RGBA string. /// Get the dirty region since last call as raw RGBA bytes via binary IPC.
/// ///
/// The frontend decodes this and draws it onto a `<canvas>` element. /// Binary format: 8-byte header + pixel data
/// Pixel format: RGBA, 4 bytes per pixel, row-major, top-left origin. /// Header: [x: u16, y: u16, width: u16, height: u16] (little-endian)
/// 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<String, 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.
@ -46,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,
@ -64,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,
@ -75,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>,
@ -83,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> {
@ -96,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

@ -0,0 +1,44 @@
//! Tauri commands for network scanning through SSH sessions.
use tauri::State;
use crate::scanner::{self, DiscoveredHost, PortResult};
use crate::AppState;
/// Discover hosts on the remote network via ARP + ping sweep.
/// `subnet` should be the first 3 octets, e.g. "192.168.1"
#[tauri::command]
pub async fn scan_network(
session_id: String,
subnet: String,
state: State<'_, AppState>,
) -> Result<Vec<DiscoveredHost>, String> {
let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
scanner::scan_network(&session.handle, &subnet).await
}
/// Scan specific ports on a target host through an SSH session.
#[tauri::command]
pub async fn scan_ports(
session_id: String,
target: String,
ports: Vec<u16>,
state: State<'_, AppState>,
) -> Result<Vec<PortResult>, String> {
let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
scanner::scan_ports(&session.handle, &target, &ports).await
}
/// Quick scan of common ports (22, 80, 443, 3389, etc.) on a target.
#[tauri::command]
pub async fn quick_scan(
session_id: String,
target: String,
state: State<'_, AppState>,
) -> Result<Vec<PortResult>, String> {
let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
scanner::quick_port_scan(&session.handle, &target).await
}

View File

@ -33,6 +33,7 @@ pub async fn connect_ssh(
rows, rows,
&state.sftp, &state.sftp,
&state.scrollback, &state.scrollback,
&state.error_watcher,
) )
.await .await
} }
@ -65,6 +66,7 @@ pub async fn connect_ssh_with_key(
rows, rows,
&state.sftp, &state.sftp,
&state.scrollback, &state.scrollback,
&state.error_watcher,
) )
.await .await
} }

View File

@ -0,0 +1,188 @@
//! Tauri commands for built-in tools: ping, traceroute, WoL, keygen, passgen.
use tauri::State;
use serde::Serialize;
use crate::AppState;
use crate::ssh::exec::exec_on_session;
use crate::utils::shell_escape;
// ── Ping ─────────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PingResult {
pub target: String,
pub output: String,
}
/// Ping a host through an SSH session's exec channel.
#[tauri::command]
pub async fn tool_ping(
session_id: String,
target: String,
count: Option<u32>,
state: State<'_, AppState>,
) -> Result<PingResult, String> {
let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
let n = count.unwrap_or(4);
let cmd = format!("ping -c {} {} 2>&1", n, shell_escape(&target));
let output = exec_on_session(&session.handle, &cmd).await?;
Ok(PingResult { target, output })
}
/// Traceroute through an SSH session's exec channel.
#[tauri::command]
pub async fn tool_traceroute(
session_id: String,
target: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
let t = shell_escape(&target);
let cmd = format!("traceroute {} 2>&1 || tracert {} 2>&1", t, t);
exec_on_session(&session.handle, &cmd).await
}
// ── Wake on LAN ──────────────────────────────────────────────────────────────
/// Send a Wake-on-LAN magic packet through an SSH session.
/// The remote host broadcasts the WoL packet on its local network.
#[tauri::command]
pub async fn tool_wake_on_lan(
session_id: String,
mac_address: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
// Build WoL magic packet as a shell one-liner using python or perl (widely available)
let mac_clean = mac_address.replace([':', '-'], "");
if mac_clean.len() != 12 || !mac_clean.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("Invalid MAC address: {}", mac_address));
}
let cmd = format!(
r#"python3 -c "
import socket, struct
mac = bytes.fromhex({mac_clean_escaped})
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 packet sent to {mac_display_escaped}')
" 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
}
// ── SSH Key Generator ────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GeneratedKey {
pub private_key: String,
pub public_key: String,
pub fingerprint: String,
pub key_type: String,
}
/// Generate an SSH key pair locally (no SSH session needed).
#[tauri::command]
pub fn tool_generate_ssh_key(
key_type: String,
comment: Option<String>,
) -> Result<GeneratedKey, String> {
tool_generate_ssh_key_inner(&key_type, comment)
}
pub fn tool_generate_ssh_key_inner(
key_type: &str,
comment: Option<String>,
) -> Result<GeneratedKey, String> {
use ssh_key::{Algorithm, HashAlg, LineEnding};
let comment_str = comment.unwrap_or_else(|| "wraith-generated".to_string());
let algorithm = match key_type.to_lowercase().as_str() {
"ed25519" => Algorithm::Ed25519,
"rsa" | "rsa-2048" => Algorithm::Rsa { hash: Some(ssh_key::HashAlg::Sha256) },
"rsa-4096" => Algorithm::Rsa { hash: Some(ssh_key::HashAlg::Sha256) },
_ => return Err(format!("Unsupported key type: {}. Use ed25519 or rsa", key_type)),
};
let private_key = ssh_key::PrivateKey::random(&mut ssh_key::rand_core::OsRng, algorithm)
.map_err(|e| format!("Key generation failed: {}", e))?;
let private_pem = private_key.to_openssh(LineEnding::LF)
.map_err(|e| format!("Failed to encode private key: {}", e))?;
let public_key = private_key.public_key();
let public_openssh = public_key.to_openssh()
.map_err(|e| format!("Failed to encode public key: {}", e))?;
let fingerprint = public_key.fingerprint(HashAlg::Sha256).to_string();
Ok(GeneratedKey {
private_key: private_pem.to_string(),
public_key: format!("{} {}", public_openssh, comment_str),
fingerprint,
key_type: key_type.to_lowercase(),
})
}
// ── Password Generator ───────────────────────────────────────────────────────
/// Generate a cryptographically secure random password.
#[tauri::command]
pub fn tool_generate_password(
length: Option<usize>,
uppercase: Option<bool>,
lowercase: Option<bool>,
digits: Option<bool>,
symbols: Option<bool>,
) -> Result<String, String> {
tool_generate_password_inner(length, uppercase, lowercase, digits, symbols)
}
pub fn tool_generate_password_inner(
length: Option<usize>,
uppercase: Option<bool>,
lowercase: Option<bool>,
digits: Option<bool>,
symbols: Option<bool>,
) -> Result<String, String> {
use rand::Rng;
let len = length.unwrap_or(20).max(4).min(128);
let use_upper = uppercase.unwrap_or(true);
let use_lower = lowercase.unwrap_or(true);
let use_digits = digits.unwrap_or(true);
let use_symbols = symbols.unwrap_or(true);
let mut charset = String::new();
if use_upper { charset.push_str("ABCDEFGHIJKLMNOPQRSTUVWXYZ"); }
if use_lower { charset.push_str("abcdefghijklmnopqrstuvwxyz"); }
if use_digits { charset.push_str("0123456789"); }
if use_symbols { charset.push_str("!@#$%^&*()-_=+[]{}|;:,.<>?"); }
if charset.is_empty() {
return Err("At least one character class must be enabled".to_string());
}
let chars: Vec<char> = charset.chars().collect();
let mut rng = rand::rng();
let password: String = (0..len)
.map(|_| chars[rng.random_range(0..chars.len())])
.collect();
Ok(password)
}

View File

@ -0,0 +1,184 @@
//! Tauri commands for Tools Round 2: DNS, Whois, Bandwidth, Subnet Calculator.
use tauri::State;
use serde::Serialize;
use crate::AppState;
use crate::ssh::exec::exec_on_session;
use crate::utils::shell_escape;
// ── DNS Lookup ───────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn tool_dns_lookup(
session_id: String,
domain: String,
record_type: Option<String>,
state: State<'_, AppState>,
) -> Result<String, String> {
let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
let d = shell_escape(&domain);
let rt = shell_escape(&record_type.unwrap_or_else(|| "A".to_string()));
let cmd = format!(
r#"dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null"#,
d, rt, rt, d, rt, d
);
exec_on_session(&session.handle, &cmd).await
}
// ── Whois ────────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn tool_whois(
session_id: String,
target: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
let cmd = format!("whois {} 2>&1 | head -80", shell_escape(&target));
exec_on_session(&session.handle, &cmd).await
}
// ── Bandwidth Test ───────────────────────────────────────────────────────────
#[tauri::command]
pub async fn tool_bandwidth_iperf(
session_id: String,
server: String,
duration: Option<u32>,
state: State<'_, AppState>,
) -> Result<String, String> {
let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
let dur = duration.unwrap_or(5);
let s = shell_escape(&server);
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'",
s, dur, s, dur
);
exec_on_session(&session.handle, &cmd).await
}
#[tauri::command]
pub async fn tool_bandwidth_speedtest(
session_id: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let session = state.ssh.get_session(&session_id)
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
// Try multiple speedtest tools in order of preference
let cmd = r#"
if command -v speedtest-cli >/dev/null 2>&1; then
speedtest-cli --simple 2>&1
elif command -v speedtest >/dev/null 2>&1; then
speedtest --simple 2>&1
elif command -v curl >/dev/null 2>&1; then
echo "=== Download speed (curl) ==="
curl -o /dev/null -w "Download: %{speed_download} bytes/sec (%{size_download} bytes in %{time_total}s)\n" https://speed.cloudflare.com/__down?bytes=25000000 2>/dev/null
echo "=== Upload speed (curl) ==="
dd if=/dev/zero bs=1M count=10 2>/dev/null | curl -X POST -o /dev/null -w "Upload: %{speed_upload} bytes/sec (%{size_upload} bytes in %{time_total}s)\n" -d @- https://speed.cloudflare.com/__up 2>/dev/null
else
echo "No speedtest tool found. Install: pip install speedtest-cli"
fi
"#;
exec_on_session(&session.handle, cmd).await
}
// ── Subnet Calculator ────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubnetInfo {
pub cidr: String,
pub network: String,
pub broadcast: String,
pub netmask: String,
pub wildcard: String,
pub first_host: String,
pub last_host: String,
pub total_hosts: u64,
pub usable_hosts: u64,
pub prefix_length: u8,
pub class: String,
pub is_private: bool,
}
/// Pure Rust subnet calculator — no SSH session needed.
#[tauri::command]
pub fn tool_subnet_calc(cidr: String) -> Result<SubnetInfo, String> {
tool_subnet_calc_inner(&cidr)
}
pub fn tool_subnet_calc_inner(cidr: &str) -> Result<SubnetInfo, String> {
let cidr = cidr.to_string();
let parts: Vec<&str> = cidr.split('/').collect();
if parts.len() != 2 {
return Err("Expected CIDR notation: e.g. 192.168.1.0/24".to_string());
}
let ip_str = parts[0];
let prefix: u8 = parts[1].parse()
.map_err(|_| format!("Invalid prefix length: {}", parts[1]))?;
if prefix > 32 {
return Err(format!("Prefix length must be 0-32, got {}", prefix));
}
let octets: Vec<u8> = ip_str.split('.')
.map(|o| o.parse::<u8>())
.collect::<Result<Vec<_>, _>>()
.map_err(|_| format!("Invalid IP address: {}", ip_str))?;
if octets.len() != 4 {
return Err(format!("Invalid IP address: {}", ip_str));
}
let ip: u32 = (octets[0] as u32) << 24
| (octets[1] as u32) << 16
| (octets[2] as u32) << 8
| (octets[3] as u32);
let mask: u32 = if prefix == 0 { 0 } else { !0u32 << (32 - prefix) };
let wildcard = !mask;
let network = ip & mask;
let broadcast = network | wildcard;
let first_host = if prefix >= 31 { network } else { network + 1 };
let last_host = if prefix >= 31 { broadcast } else { broadcast - 1 };
let total: u64 = 1u64 << (32 - prefix as u64);
let usable = if prefix >= 31 { total } else { total - 2 };
let class = match octets[0] {
0..=127 => "A",
128..=191 => "B",
192..=223 => "C",
224..=239 => "D (Multicast)",
_ => "E (Reserved)",
};
let is_private = matches!(
(octets[0], octets[1]),
(10, _) | (172, 16..=31) | (192, 168)
);
Ok(SubnetInfo {
cidr: format!("{}/{}", to_ip(network), prefix),
network: to_ip(network),
broadcast: to_ip(broadcast),
netmask: to_ip(mask),
wildcard: to_ip(wildcard),
first_host: to_ip(first_host),
last_host: to_ip(last_host),
total_hosts: total,
usable_hosts: usable,
prefix_length: prefix,
class: class.to_string(),
is_private,
})
}
fn to_ip(val: u32) -> String {
format!("{}.{}.{}.{}", val >> 24, (val >> 16) & 0xFF, (val >> 8) & 0xFF, val & 0xFF)
}

View File

@ -0,0 +1,94 @@
//! Version check against Gitea releases API.
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateInfo {
pub current_version: String,
pub latest_version: String,
pub update_available: bool,
pub download_url: String,
pub release_notes: String,
}
/// Check Gitea for the latest release and compare with current version.
#[tauri::command]
pub async fn check_for_updates(app_handle: tauri::AppHandle) -> Result<UpdateInfo, String> {
// Read version from tauri.conf.json (patched by CI from git tag)
// rather than CARGO_PKG_VERSION which is always 0.1.0
let current = app_handle.config().version.clone().unwrap_or_else(|| "0.0.0".to_string());
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("HTTP client error: {}", e))?;
let resp = client
.get("https://git.command.vigilcyber.com/api/v1/repos/vstockwell/wraith/releases?limit=1")
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Failed to check for updates: {}", e))?;
let releases: Vec<serde_json::Value> = resp.json().await
.map_err(|e| format!("Failed to parse releases: {}", e))?;
let latest = releases.first()
.ok_or_else(|| "No releases found".to_string())?;
let tag = latest.get("tag_name")
.and_then(|v| v.as_str())
.unwrap_or("v0.0.0")
.trim_start_matches('v')
.to_string();
let notes = latest.get("body")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// Direct download from SeaweedFS
let html_url = format!("https://files.command.vigilcyber.com/wraith/{}/", tag);
let update_available = version_is_newer(&tag, &current);
Ok(UpdateInfo {
current_version: current,
latest_version: tag,
update_available,
download_url: html_url,
release_notes: notes,
})
}
/// Compare semver strings. Returns true if `latest` is newer than `current`.
fn version_is_newer(latest: &str, current: &str) -> bool {
let parse = |v: &str| -> Vec<u32> {
v.split('.').filter_map(|s| s.parse().ok()).collect()
};
let l = parse(latest);
let c = parse(current);
for i in 0..3 {
let lv = l.get(i).copied().unwrap_or(0);
let cv = c.get(i).copied().unwrap_or(0);
if lv > cv { return true; }
if lv < cv { return false; }
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_comparison() {
assert!(version_is_newer("1.5.7", "1.5.6"));
assert!(version_is_newer("1.6.0", "1.5.9"));
assert!(version_is_newer("2.0.0", "1.9.9"));
assert!(!version_is_newer("1.5.6", "1.5.6"));
assert!(!version_is_newer("1.5.5", "1.5.6"));
assert!(!version_is_newer("1.4.0", "1.5.0"));
}
}

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,14 +22,15 @@ 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> {
let result = async {
if !state.is_first_run() { if !state.is_first_run() {
return Err("Vault already exists — use unlock instead of create".into()); 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))?;
@ -39,10 +41,14 @@ pub fn create_vault(password: String, state: State<'_, AppState>) -> Result<(),
// 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,7 +58,8 @@ 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 result = async {
let salt_hex = state let salt_hex = state
.settings .settings
.get("vault_salt") .get("vault_salt")
@ -62,7 +69,7 @@ pub fn unlock(password: String, state: State<'_, AppState>) -> Result<(), String
.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
@ -80,14 +87,18 @@ pub fn unlock(password: String, state: State<'_, AppState>) -> Result<(), String
// 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

@ -0,0 +1,16 @@
//! Tauri commands for workspace persistence.
use tauri::State;
use crate::AppState;
use crate::workspace::{WorkspaceSnapshot, WorkspaceTab};
#[tauri::command]
pub fn save_workspace(tabs: Vec<WorkspaceTab>, state: State<'_, AppState>) -> Result<(), String> {
let snapshot = WorkspaceSnapshot { tabs };
state.workspace.save(&snapshot)
}
#[tauri::command]
pub fn load_workspace(state: State<'_, AppState>) -> Result<Option<WorkspaceSnapshot>, String> {
Ok(state.workspace.load())
}

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,
@ -429,6 +430,54 @@ impl ConnectionService {
Ok(records) Ok(records)
} }
/// Batch-update sort_order for a list of connection IDs.
pub fn reorder_connections(&self, ids: &[i64]) -> Result<(), String> {
let conn = self.db.conn();
conn.execute_batch("BEGIN")
.map_err(|e| format!("Failed to begin reorder transaction: {e}"))?;
let result = (|| {
for (i, id) in ids.iter().enumerate() {
conn.execute(
"UPDATE connections SET sort_order = ?1 WHERE id = ?2",
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}"))?;
}
result
}
/// Batch-update sort_order for a list of group IDs.
pub fn reorder_groups(&self, ids: &[i64]) -> Result<(), String> {
let conn = self.db.conn();
conn.execute_batch("BEGIN")
.map_err(|e| format!("Failed to begin reorder transaction: {e}"))?;
let result = (|| {
for (i, id) in ids.iter().enumerate() {
conn.execute(
"UPDATE groups SET sort_order = ?1 WHERE id = ?2",
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}"))?;
}
result
}
} }
// ── private helpers ─────────────────────────────────────────────────────────── // ── private helpers ───────────────────────────────────────────────────────────

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

@ -1,3 +1,12 @@
// Global debug log macro — must be declared before modules that use it
#[macro_export]
macro_rules! wraith_log {
($($arg:tt)*) => {{
let msg = format!($($arg)*);
let _ = $crate::write_log(&$crate::data_directory().join("wraith.log"), &msg);
}};
}
pub mod db; pub mod db;
pub mod vault; pub mod vault;
pub mod settings; pub mod settings;
@ -10,10 +19,11 @@ pub mod theme;
pub mod workspace; pub mod workspace;
pub mod pty; pub mod pty;
pub mod mcp; pub mod mcp;
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;
@ -27,13 +37,14 @@ use theme::ThemeService;
use workspace::WorkspaceService; use workspace::WorkspaceService;
use pty::PtyService; use pty::PtyService;
use mcp::ScrollbackRegistry; use mcp::ScrollbackRegistry;
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,
@ -41,6 +52,7 @@ pub struct AppState {
pub workspace: WorkspaceService, pub workspace: WorkspaceService,
pub pty: PtyService, pub pty: PtyService,
pub scrollback: ScrollbackRegistry, pub scrollback: ScrollbackRegistry,
pub error_watcher: std::sync::Arc<ErrorWatcher>,
} }
impl AppState { impl AppState {
@ -48,28 +60,34 @@ 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()),
}) })
} }
pub fn clone_services(&self) -> (SshService, rdp::RdpService, SftpService, ScrollbackRegistry, std::sync::Arc<ErrorWatcher>) {
(self.ssh.clone(), self.rdp.clone(), self.sftp.clone(), self.scrollback.clone(), self.error_watcher.clone())
}
pub fn is_first_run(&self) -> bool { pub fn is_first_run(&self) -> bool {
self.settings.get("vault_salt").unwrap_or_default().is_empty() self.settings.get("vault_salt").unwrap_or_default().is_empty()
} }
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()
} }
} }
@ -83,13 +101,52 @@ 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<()> {
use std::io::Write;
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()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
writeln!(f, "[{}] {}", elapsed, msg)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let app_state = AppState::new(data_directory()).expect("Failed to init AppState"); // Install rustls crypto provider before any TLS operations (RDP needs this)
let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
// Initialize file-based logging to data_dir/wraith.log
let log_path = data_directory().join("wraith.log");
let _ = write_log(&log_path, "=== Wraith starting ===");
let app_state = match AppState::new(data_directory()) {
Ok(s) => s,
Err(e) => {
let _ = write_log(&log_path, &format!("FATAL: AppState init failed: {}", e));
panic!("Failed to init AppState: {}", e);
}
};
app_state.theme.seed_builtins(); app_state.theme.seed_builtins();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(app_state) .manage(app_state)
.setup(|app| { .setup(|app| {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@ -99,21 +156,85 @@ pub fn run() {
window.open_devtools(); window.open_devtools();
} }
} }
let _ = app;
// Start MCP and error watcher — completely non-fatal.
{
use tauri::Manager;
let log_file = data_directory().join("wraith.log");
let _ = write_log(&log_file, "Setup: starting MCP and error watcher");
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
app.state::<AppState>().inner().clone_services()
})) {
Ok(state) => {
let (ssh, rdp, sftp, scrollback, watcher) = state;
let _ = write_log(&log_file, "Setup: cloned services OK");
// Error watcher — std::thread, no tokio needed
let watcher_for_mcp = watcher.clone();
let app_handle = app.handle().clone();
let app_handle_for_mcp = app.handle().clone();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mcp::error_watcher::start_error_watcher(watcher, scrollback.clone(), app_handle);
}));
let _ = write_log(&log_file, "Setup: error watcher started");
// MCP HTTP server — needs async runtime
let log_file2 = log_file.clone();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
tauri::async_runtime::spawn(async move {
match mcp::server::start_mcp_server(ssh, rdp, sftp, scrollback, app_handle_for_mcp, watcher_for_mcp).await {
Ok(port) => { let _ = write_log(&log_file2, &format!("MCP server started on localhost:{}", port)); }
Err(e) => { let _ = write_log(&log_file2, &format!("MCP server FAILED: {}", e)); }
}
});
}));
let _ = write_log(&log_file, "Setup: MCP spawn dispatched");
// Download/update MCP bridge binary if needed
let app_ver = app.config().version.clone().unwrap_or_else(|| "0.0.0".to_string());
let log_file3 = log_file.clone();
tauri::async_runtime::spawn(async move {
match mcp::bridge_manager::ensure_bridge(&app_ver).await {
Ok(()) => { let _ = write_log(&log_file3, "Setup: MCP bridge binary OK"); }
Err(e) => { let _ = write_log(&log_file3, &format!("Setup: MCP bridge download failed: {}", e)); }
}
});
}
Err(panic) => {
let msg = if let Some(s) = panic.downcast_ref::<String>() {
s.clone()
} else if let Some(s) = panic.downcast_ref::<&str>() {
s.to_string()
} else {
format!("{:?}", panic.type_id())
};
let _ = write_log(&log_file, &format!("MCP startup panicked: {}", msg));
}
}
}
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::vault::is_first_run, commands::vault::create_vault, commands::vault::unlock, commands::vault::is_unlocked, commands::vault::is_first_run, commands::vault::create_vault, commands::vault::unlock, commands::vault::is_unlocked,
commands::settings::get_setting, commands::settings::set_setting, commands::settings::get_setting, commands::settings::set_setting,
commands::connections::list_connections, commands::connections::create_connection, commands::connections::get_connection, commands::connections::update_connection, commands::connections::delete_connection, commands::connections::list_connections, commands::connections::create_connection, commands::connections::get_connection, commands::connections::update_connection, commands::connections::delete_connection,
commands::connections::list_groups, commands::connections::create_group, commands::connections::delete_group, commands::connections::rename_group, commands::connections::search_connections, commands::connections::list_groups, commands::connections::create_group, commands::connections::delete_group, commands::connections::rename_group, commands::connections::search_connections, commands::connections::reorder_connections, commands::connections::reorder_groups,
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_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::scanner_commands::scan_network, commands::scanner_commands::scan_ports, commands::scanner_commands::quick_scan,
commands::tools_commands::tool_ping, commands::tools_commands::tool_traceroute, commands::tools_commands::tool_wake_on_lan, commands::tools_commands::tool_generate_ssh_key, commands::tools_commands::tool_generate_password,
commands::tools_commands_r2::tool_dns_lookup, commands::tools_commands_r2::tool_whois, commands::tools_commands_r2::tool_bandwidth_iperf, commands::tools_commands_r2::tool_bandwidth_speedtest, commands::tools_commands_r2::tool_subnet_calc,
commands::updater::check_for_updates,
commands::workspace_commands::save_workspace, commands::workspace_commands::load_workspace,
commands::docker_commands::docker_list_containers, commands::docker_commands::docker_list_images, commands::docker_commands::docker_list_volumes, commands::docker_commands::docker_action,
commands::window_commands::open_child_window,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -0,0 +1,85 @@
//! MCP bridge binary self-management.
//!
//! On startup, checks if wraith-mcp-bridge exists in the data directory.
//! If missing or outdated, downloads the correct version from Gitea packages.
use std::path::PathBuf;
/// Get the expected path for the bridge binary.
pub fn bridge_path() -> PathBuf {
let dir = crate::data_directory();
if cfg!(windows) {
dir.join("wraith-mcp-bridge.exe")
} else {
dir.join("wraith-mcp-bridge")
}
}
/// Check if the bridge binary exists and is the correct version.
/// If not, download it from Gitea packages.
pub async fn ensure_bridge(app_version: &str) -> Result<(), String> {
let path = bridge_path();
let version_file = crate::data_directory().join("mcp-bridge-version");
// Check if bridge exists and version matches
if path.exists() {
if let Ok(installed_ver) = std::fs::read_to_string(&version_file) {
if installed_ver.trim() == app_version {
wraith_log!("[MCP Bridge] v{} already installed at {}", app_version, path.display());
return Ok(());
}
}
}
wraith_log!("[MCP Bridge] Downloading v{} to {}", app_version, path.display());
let binary_name = if cfg!(windows) {
"wraith-mcp-bridge.exe"
} else {
"wraith-mcp-bridge"
};
let url = format!(
"https://files.command.vigilcyber.com/wraith/{}/{}",
app_version, binary_name
);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| format!("HTTP client error: {}", e))?;
let resp = client.get(&url).send().await
.map_err(|e| format!("Failed to download MCP bridge: {}", e))?;
if !resp.status().is_success() {
return Err(format!("MCP bridge download failed: HTTP {}", resp.status()));
}
let bytes = resp.bytes().await
.map_err(|e| format!("Failed to read MCP bridge response: {}", e))?;
// Write the binary
std::fs::write(&path, &bytes)
.map_err(|e| format!("Failed to write MCP bridge to {}: {}", path.display(), e))?;
// Make executable on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&path)
.map_err(|e| format!("Failed to read permissions: {}", e))?
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms)
.map_err(|e| format!("Failed to set execute permission: {}", e))?;
}
// Write version marker
std::fs::write(&version_file, app_version)
.map_err(|e| format!("Failed to write version file: {}", e))?;
wraith_log!("[MCP Bridge] v{} installed successfully ({} bytes)", app_version, bytes.len());
Ok(())
}

View File

@ -0,0 +1,115 @@
//! Background error pattern scanner for terminal sessions.
//!
//! Watches scrollback buffers for common error patterns and emits
//! `mcp:error:{session_id}` events to the frontend when detected.
use std::sync::Arc;
use dashmap::DashMap;
use tauri::{AppHandle, Emitter};
use crate::mcp::ScrollbackRegistry;
/// Common error patterns to watch for across all sessions.
const ERROR_PATTERNS: &[&str] = &[
"Permission denied",
"permission denied",
"Connection refused",
"connection refused",
"No space left on device",
"Disk quota exceeded",
"Out of memory",
"OOM",
"Killed",
"Segmentation fault",
"segfault",
"FATAL",
"CRITICAL",
"panic:",
"stack overflow",
"Too many open files",
"Connection timed out",
"Connection reset by peer",
"Host key verification failed",
"command not found",
"No such file or directory",
];
/// Tracks the last scanned position per session to avoid re-emitting.
pub struct ErrorWatcher {
last_scanned: DashMap<String, usize>,
}
impl ErrorWatcher {
pub fn new() -> Self {
Self { last_scanned: DashMap::new() }
}
/// Scan all registered sessions for new error patterns.
/// Returns a list of (session_id, matched_line) pairs.
pub fn scan(&self, scrollback: &ScrollbackRegistry) -> Vec<(String, String)> {
let mut alerts = Vec::new();
// Collect session IDs and positions first to avoid holding the iter
let sessions: Vec<(String, usize)> = self.last_scanned.iter()
.map(|entry| (entry.key().clone(), *entry.value()))
.collect();
for (session_id, last_pos) in sessions {
if let Some(buf) = scrollback.get(&session_id) {
let total = buf.total_written();
if total <= last_pos {
continue;
}
// Only scan bytes written since the last check — avoids
// reading the entire 64 KB ring buffer on every 2-second tick.
let new_content = buf.read_since(last_pos);
for line in new_content.lines() {
for pattern in ERROR_PATTERNS {
if line.contains(pattern) {
alerts.push((session_id.clone(), line.to_string()));
break;
}
}
}
self.last_scanned.insert(session_id, total);
}
}
alerts
}
/// Register a session for watching.
pub fn watch(&self, session_id: &str) {
self.last_scanned.insert(session_id.to_string(), 0);
}
/// Stop watching a session.
pub fn unwatch(&self, session_id: &str) {
self.last_scanned.remove(session_id);
}
}
/// Spawn a background task that scans for errors every 2 seconds.
pub fn start_error_watcher(
watcher: Arc<ErrorWatcher>,
scrollback: ScrollbackRegistry,
app_handle: AppHandle,
) {
std::thread::spawn(move || {
loop {
std::thread::sleep(std::time::Duration::from_secs(2));
let alerts = watcher.scan(&scrollback);
for (session_id, line) in alerts {
let _ = app_handle.emit("mcp:error", serde_json::json!({
"sessionId": session_id,
"message": line,
}));
}
}
});
}

View File

@ -5,6 +5,9 @@
//! sessions. //! sessions.
pub mod scrollback; pub mod scrollback;
pub mod server;
pub mod error_watcher;
pub mod bridge_manager;
use std::sync::Arc; use std::sync::Arc;
@ -14,13 +17,14 @@ use crate::mcp::scrollback::ScrollbackBuffer;
/// Registry of scrollback buffers keyed by session ID. /// Registry of scrollback buffers keyed by session ID.
/// Shared between SSH/PTY output loops (writers) and MCP tools (readers). /// Shared between SSH/PTY output loops (writers) and MCP tools (readers).
#[derive(Clone)]
pub struct ScrollbackRegistry { pub struct ScrollbackRegistry {
buffers: DashMap<String, Arc<ScrollbackBuffer>>, buffers: Arc<DashMap<String, Arc<ScrollbackBuffer>>>,
} }
impl ScrollbackRegistry { impl ScrollbackRegistry {
pub fn new() -> Self { pub fn new() -> Self {
Self { buffers: DashMap::new() } Self { buffers: Arc::new(DashMap::new()) }
} }
/// Create and register a new scrollback buffer for a session. /// Create and register a new scrollback buffer for a session.
@ -32,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");
}
} }

631
src-tauri/src/mcp/server.rs Normal file
View File

@ -0,0 +1,631 @@
//! Tiny HTTP server for MCP bridge communication.
//!
//! Runs on localhost:0 (random port) at Tauri startup. The port is written
//! to ~/.wraith/mcp-port so the bridge binary can find it.
use std::sync::Arc;
use axum::{
extract::State as AxumState,
http::{Request, StatusCode},
middleware::{self, Next},
response::Response,
routing::post,
Json, Router,
};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use crate::mcp::ScrollbackRegistry;
use crate::rdp::RdpService;
use crate::sftp::SftpService;
use crate::ssh::exec::exec_on_session;
use crate::ssh::session::SshService;
use crate::utils::shell_escape;
/// Shared state passed to axum handlers.
pub struct McpServerState {
pub ssh: SshService,
pub rdp: RdpService,
pub sftp: SftpService,
pub scrollback: ScrollbackRegistry,
pub app_handle: tauri::AppHandle,
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)]
struct TerminalReadRequest {
session_id: String,
lines: Option<usize>,
}
#[derive(Deserialize)]
struct ScreenshotRequest {
session_id: String,
}
#[derive(Deserialize)]
struct SftpListRequest {
session_id: String,
path: String,
}
#[derive(Deserialize)]
struct SftpReadRequest {
session_id: String,
path: String,
}
#[derive(Deserialize)]
struct SftpWriteRequest {
session_id: String,
path: String,
content: String,
}
#[derive(Deserialize)]
struct TerminalTypeRequest {
session_id: String,
text: String,
press_enter: Option<bool>,
}
#[derive(Deserialize)]
struct TerminalExecuteRequest {
session_id: String,
command: String,
timeout_ms: Option<u64>,
}
#[derive(Serialize)]
struct McpResponse<T: Serialize> {
ok: bool,
data: Option<T>,
error: Option<String>,
}
fn ok_response<T: Serialize>(data: T) -> Json<McpResponse<T>> {
Json(McpResponse { ok: true, data: Some(data), error: None })
}
fn err_response<T: Serialize>(msg: String) -> Json<McpResponse<T>> {
Json(McpResponse { ok: false, data: None, error: Some(msg) })
}
async fn handle_list_sessions(
AxumState(state): AxumState<Arc<McpServerState>>,
) -> Json<McpResponse<Vec<serde_json::Value>>> {
let mut sessions: Vec<serde_json::Value> = state.ssh.list_sessions()
.into_iter()
.map(|s| serde_json::json!({
"id": s.id,
"type": "ssh",
"name": format!("{}@{}:{}", s.username, s.hostname, s.port),
"host": s.hostname,
"username": s.username,
}))
.collect();
// Include RDP sessions
for s in state.rdp.list_sessions() {
sessions.push(serde_json::json!({
"id": s.id,
"type": "rdp",
"name": s.hostname.clone(),
"host": s.hostname,
"width": s.width,
"height": s.height,
}));
}
ok_response(sessions)
}
async fn handle_sftp_list(
AxumState(state): AxumState<Arc<McpServerState>>,
Json(req): Json<SftpListRequest>,
) -> Json<McpResponse<Vec<serde_json::Value>>> {
match state.sftp.list(&req.session_id, &req.path).await {
Ok(entries) => {
let items: Vec<serde_json::Value> = entries.into_iter().map(|e| {
serde_json::json!({
"name": e.name,
"path": e.path,
"size": e.size,
"is_dir": e.is_dir,
"modified": e.mod_time,
})
}).collect();
ok_response(items)
}
Err(e) => err_response(e),
}
}
async fn handle_sftp_read(
AxumState(state): AxumState<Arc<McpServerState>>,
Json(req): Json<SftpReadRequest>,
) -> Json<McpResponse<String>> {
match state.sftp.read_file(&req.session_id, &req.path).await {
Ok(content) => ok_response(content),
Err(e) => err_response(e),
}
}
async fn handle_sftp_write(
AxumState(state): AxumState<Arc<McpServerState>>,
Json(req): Json<SftpWriteRequest>,
) -> Json<McpResponse<String>> {
match state.sftp.write_file(&req.session_id, &req.path, &req.content).await {
Ok(()) => ok_response("OK".to_string()),
Err(e) => err_response(e),
}
}
async fn handle_screenshot(
AxumState(state): AxumState<Arc<McpServerState>>,
Json(req): Json<ScreenshotRequest>,
) -> Json<McpResponse<String>> {
match state.rdp.screenshot_png_base64(&req.session_id) {
Ok(b64) => ok_response(b64),
Err(e) => err_response(e),
}
}
async fn handle_terminal_type(
AxumState(state): AxumState<Arc<McpServerState>>,
Json(req): Json<TerminalTypeRequest>,
) -> Json<McpResponse<String>> {
let text = if req.press_enter.unwrap_or(true) {
format!("{}\r", req.text)
} else {
req.text.clone()
};
match state.ssh.write(&req.session_id, text.as_bytes()).await {
Ok(()) => ok_response("sent".to_string()),
Err(e) => err_response(e),
}
}
async fn handle_terminal_read(
AxumState(state): AxumState<Arc<McpServerState>>,
Json(req): Json<TerminalReadRequest>,
) -> Json<McpResponse<String>> {
let n = req.lines.unwrap_or(50);
match state.scrollback.get(&req.session_id) {
Some(buf) => ok_response(buf.read_lines(n)),
None => err_response(format!("No scrollback buffer for session {}", req.session_id)),
}
}
async fn handle_terminal_execute(
AxumState(state): AxumState<Arc<McpServerState>>,
Json(req): Json<TerminalExecuteRequest>,
) -> Json<McpResponse<String>> {
let timeout = req.timeout_ms.unwrap_or(5000);
let marker = "__WRAITH_MCP_DONE__";
let buf = match state.scrollback.get(&req.session_id) {
Some(b) => b,
None => return err_response(format!("No scrollback buffer for session {}", req.session_id)),
};
let before = buf.total_written();
let full_cmd = format!("{}\recho {}\r", req.command, marker);
if let Err(e) = state.ssh.write(&req.session_id, full_cmd.as_bytes()).await {
return err_response(e);
}
let start = std::time::Instant::now();
let timeout_dur = std::time::Duration::from_millis(timeout);
loop {
if start.elapsed() > timeout_dur {
let raw = buf.read_raw();
let total = buf.total_written();
let new_bytes = total.saturating_sub(before);
let output = if new_bytes > 0 && raw.len() >= new_bytes {
&raw[raw.len() - new_bytes.min(raw.len())..]
} else {
""
};
return ok_response(format!("[timeout after {}ms]\n{}", timeout, output));
}
let raw = buf.read_raw();
if raw.contains(marker) {
let total = buf.total_written();
let new_bytes = total.saturating_sub(before);
let output = if new_bytes > 0 && raw.len() >= new_bytes {
raw[raw.len() - new_bytes.min(raw.len())..].to_string()
} else {
String::new()
};
let clean = output
.lines()
.filter(|line| !line.contains(marker))
.collect::<Vec<_>>()
.join("\n");
return ok_response(clean.trim().to_string());
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
// ── Tool handlers (all tools exposed to AI via MCP) ──────────────────────────
#[derive(Deserialize)]
struct ToolSessionTarget { session_id: String, target: String }
#[derive(Deserialize)]
struct ToolSessionOnly { session_id: String }
#[derive(Deserialize)]
struct ToolDnsRequest { session_id: String, domain: String, record_type: Option<String> }
#[derive(Deserialize)]
struct ToolWolRequest { session_id: String, mac_address: String }
#[derive(Deserialize)]
struct ToolScanNetworkRequest { session_id: String, subnet: String }
#[derive(Deserialize)]
struct ToolScanPortsRequest { session_id: String, target: String, ports: Option<Vec<u16>> }
#[derive(Deserialize)]
struct ToolSubnetRequest { cidr: String }
#[derive(Deserialize)]
struct ToolKeygenRequest { key_type: String, comment: Option<String> }
#[derive(Deserialize)]
struct ToolPassgenRequest { length: Option<usize>, uppercase: Option<bool>, lowercase: Option<bool>, digits: Option<bool>, symbols: Option<bool> }
async fn handle_tool_ping(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
match exec_on_session(&session.handle, &format!("ping -c 4 {} 2>&1", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
async fn handle_tool_traceroute(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
let t = shell_escape(&req.target);
match exec_on_session(&session.handle, &format!("traceroute {} 2>&1 || tracert {} 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
async fn handle_tool_dns(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolDnsRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
let rt = shell_escape(&req.record_type.unwrap_or_else(|| "A".to_string()));
let d = shell_escape(&req.domain);
match exec_on_session(&session.handle, &format!("dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null", d, rt, rt, d, rt, d)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
async fn handle_tool_whois(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
match exec_on_session(&session.handle, &format!("whois {} 2>&1 | head -80", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
async fn handle_tool_wol(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolWolRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
let mac_clean = req.mac_address.replace([':', '-'], "");
let cmd = format!(r#"python3 -c "import socket;mac=bytes.fromhex({});pkt=b'\xff'*6+mac*16;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.setsockopt(socket.SOL_SOCKET,socket.SO_BROADCAST,1);s.sendto(pkt,('255.255.255.255',9));s.close();print('WoL sent to {}')" 2>&1"#, shell_escape(&mac_clean), shell_escape(&req.mac_address));
match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
async fn handle_tool_scan_network(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolScanNetworkRequest>) -> Json<McpResponse<serde_json::Value>> {
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 crate::scanner::scan_network(&session.handle, &req.subnet).await {
Ok(hosts) => ok_response(serde_json::to_value(hosts).unwrap_or_default()),
Err(e) => err_response(e),
}
}
async fn handle_tool_scan_ports(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolScanPortsRequest>) -> Json<McpResponse<serde_json::Value>> {
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 result = if let Some(ports) = req.ports {
crate::scanner::scan_ports(&session.handle, &req.target, &ports).await
} else {
crate::scanner::quick_port_scan(&session.handle, &req.target).await
};
match result { Ok(r) => ok_response(serde_json::to_value(r).unwrap_or_default()), Err(e) => err_response(e) }
}
async fn handle_tool_subnet(_state: AxumState<Arc<McpServerState>>, Json(req): Json<ToolSubnetRequest>) -> Json<McpResponse<serde_json::Value>> {
match crate::commands::tools_commands_r2::tool_subnet_calc_inner(&req.cidr) {
Ok(info) => ok_response(serde_json::to_value(info).unwrap_or_default()),
Err(e) => err_response(e),
}
}
async fn handle_tool_bandwidth(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionOnly>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
let cmd = r#"if command -v speedtest-cli >/dev/null 2>&1; then speedtest-cli --simple 2>&1; elif command -v curl >/dev/null 2>&1; then curl -o /dev/null -w "Download: %{speed_download} bytes/sec\n" https://speed.cloudflare.com/__down?bytes=25000000 2>/dev/null; else echo "No speedtest tool found"; fi"#;
match exec_on_session(&session.handle, cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
async fn handle_tool_keygen(_state: AxumState<Arc<McpServerState>>, Json(req): Json<ToolKeygenRequest>) -> Json<McpResponse<serde_json::Value>> {
match crate::commands::tools_commands::tool_generate_ssh_key_inner(&req.key_type, req.comment) {
Ok(key) => ok_response(serde_json::to_value(key).unwrap_or_default()),
Err(e) => err_response(e),
}
}
async fn handle_tool_passgen(_state: AxumState<Arc<McpServerState>>, Json(req): Json<ToolPassgenRequest>) -> Json<McpResponse<String>> {
match crate::commands::tools_commands::tool_generate_password_inner(req.length, req.uppercase, req.lowercase, req.digits, req.symbols) {
Ok(pw) => ok_response(pw),
Err(e) => err_response(e),
}
}
// ── Docker handlers ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct DockerActionRequest { session_id: String, action: String, target: String }
#[derive(Deserialize)]
struct DockerListRequest { session_id: String }
#[derive(Deserialize)]
struct DockerExecRequest { session_id: String, container: String, command: 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)) };
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>> {
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() {
"start" => format!("docker start {} 2>&1", t),
"stop" => format!("docker stop {} 2>&1", t),
"restart" => format!("docker restart {} 2>&1", t),
"remove" => format!("docker rm -f {} 2>&1", t),
"logs" => format!("docker logs --tail 100 {} 2>&1", t),
"builder-prune" => "docker builder prune -f 2>&1".to_string(),
"system-prune" => "docker system prune -f 2>&1".to_string(),
_ => return err_response(format!("Unknown action: {}", req.action)),
};
match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
async fn handle_docker_exec(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerExecRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
let cmd = format!("docker exec {} {} 2>&1", shell_escape(&req.container), shell_escape(&req.command));
match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
// ── Service/process handlers ─────────────────────────────────────────────────
async fn handle_service_status(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
let t = shell_escape(&req.target);
match exec_on_session(&session.handle, &format!("systemctl status {} --no-pager 2>&1 || service {} status 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
async fn handle_process_list(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
let filter = if req.target.is_empty() { "aux --sort=-%cpu | head -30".to_string() } else { format!("aux | grep -i {} | grep -v grep", shell_escape(&req.target)) };
match exec_on_session(&session.handle, &format!("ps {}", filter)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
// ── Git handlers ─────────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct GitRequest { session_id: String, path: String }
async fn handle_git_status(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
match exec_on_session(&session.handle, &format!("cd {} && git status --short --branch 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
async fn handle_git_pull(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
match exec_on_session(&session.handle, &format!("cd {} && git pull 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
async fn handle_git_log(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
match exec_on_session(&session.handle, &format!("cd {} && git log --oneline -20 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
// ── Session creation handlers ────────────────────────────────────────────────
#[derive(Deserialize)]
struct SshConnectRequest {
hostname: String,
port: Option<u16>,
username: String,
password: Option<String>,
private_key_path: Option<String>,
}
async fn handle_ssh_connect(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<SshConnectRequest>) -> Json<McpResponse<String>> {
use crate::ssh::session::AuthMethod;
let port = req.port.unwrap_or(22);
let auth = if let Some(key_path) = req.private_key_path {
// Read key file
let pem = match std::fs::read_to_string(&key_path) {
Ok(p) => p,
Err(e) => return err_response(format!("Failed to read key file {}: {}", key_path, e)),
};
AuthMethod::Key { private_key_pem: pem, passphrase: req.password }
} else {
AuthMethod::Password(req.password.unwrap_or_default())
};
match state.ssh.connect(
state.app_handle.clone(),
&req.hostname,
port,
&req.username,
auth,
120, 40,
&state.sftp,
&state.scrollback,
&state.error_watcher,
).await {
Ok(session_id) => ok_response(session_id),
Err(e) => err_response(e),
}
}
// ── RDP interaction handlers ─────────────────────────────────────────────────
#[derive(Deserialize)]
struct RdpClickRequest { session_id: String, x: u16, y: u16, button: Option<String> }
#[derive(Deserialize)]
struct RdpTypeRequest { session_id: String, text: String }
#[derive(Deserialize)]
struct RdpClipboardRequest { session_id: String, text: String }
async fn handle_rdp_click(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<RdpClickRequest>) -> Json<McpResponse<String>> {
use crate::rdp::input::mouse_flags;
let button_flag = match req.button.as_deref().unwrap_or("left") {
"right" => mouse_flags::BUTTON2,
"middle" => mouse_flags::BUTTON3,
_ => mouse_flags::BUTTON1,
};
// Move to position
if let Err(e) = state.rdp.send_mouse(&req.session_id, req.x, req.y, mouse_flags::MOVE) { return err_response(e); }
// Click down
if let Err(e) = state.rdp.send_mouse(&req.session_id, req.x, req.y, button_flag | mouse_flags::DOWN) { return err_response(e); }
// Click up
if let Err(e) = state.rdp.send_mouse(&req.session_id, req.x, req.y, button_flag) { return err_response(e); }
ok_response(format!("clicked ({}, {})", req.x, req.y))
}
async fn handle_rdp_type(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<RdpTypeRequest>) -> Json<McpResponse<String>> {
// Set clipboard then simulate Ctrl+V to paste (most reliable for arbitrary text)
if let Err(e) = state.rdp.send_clipboard(&req.session_id, &req.text) { return err_response(e); }
// Small delay for clipboard to propagate, then Ctrl+V
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Ctrl down
let _ = state.rdp.send_key(&req.session_id, 0x001D, true);
// V down
let _ = state.rdp.send_key(&req.session_id, 0x002F, true);
// V up
let _ = state.rdp.send_key(&req.session_id, 0x002F, false);
// Ctrl up
let _ = state.rdp.send_key(&req.session_id, 0x001D, false);
ok_response(format!("typed {} chars via clipboard paste", req.text.len()))
}
async fn handle_rdp_clipboard(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<RdpClipboardRequest>) -> Json<McpResponse<String>> {
if let Err(e) = state.rdp.send_clipboard(&req.session_id, &req.text) { return err_response(e); }
ok_response("clipboard set".to_string())
}
/// Start the MCP HTTP server and write the port to disk.
pub async fn start_mcp_server(
ssh: SshService,
rdp: RdpService,
sftp: SftpService,
scrollback: ScrollbackRegistry,
app_handle: tauri::AppHandle,
error_watcher: std::sync::Arc<crate::mcp::error_watcher::ErrorWatcher>,
) -> Result<u16, String> {
// 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()
.route("/mcp/sessions", post(handle_list_sessions))
.route("/mcp/terminal/type", post(handle_terminal_type))
.route("/mcp/terminal/read", post(handle_terminal_read))
.route("/mcp/terminal/execute", post(handle_terminal_execute))
.route("/mcp/screenshot", post(handle_screenshot))
.route("/mcp/sftp/list", post(handle_sftp_list))
.route("/mcp/sftp/read", post(handle_sftp_read))
.route("/mcp/sftp/write", post(handle_sftp_write))
.route("/mcp/tool/ping", post(handle_tool_ping))
.route("/mcp/tool/traceroute", post(handle_tool_traceroute))
.route("/mcp/tool/dns", post(handle_tool_dns))
.route("/mcp/tool/whois", post(handle_tool_whois))
.route("/mcp/tool/wol", post(handle_tool_wol))
.route("/mcp/tool/scan-network", post(handle_tool_scan_network))
.route("/mcp/tool/scan-ports", post(handle_tool_scan_ports))
.route("/mcp/tool/subnet", post(handle_tool_subnet))
.route("/mcp/tool/bandwidth", post(handle_tool_bandwidth))
.route("/mcp/tool/keygen", post(handle_tool_keygen))
.route("/mcp/tool/passgen", post(handle_tool_passgen))
.route("/mcp/docker/ps", post(handle_docker_ps))
.route("/mcp/docker/action", post(handle_docker_action))
.route("/mcp/docker/exec", post(handle_docker_exec))
.route("/mcp/service/status", post(handle_service_status))
.route("/mcp/process/list", post(handle_process_list))
.route("/mcp/git/status", post(handle_git_status))
.route("/mcp/git/pull", post(handle_git_pull))
.route("/mcp/git/log", post(handle_git_log))
.route("/mcp/rdp/click", post(handle_rdp_click))
.route("/mcp/rdp/type", post(handle_rdp_type))
.route("/mcp/rdp/clipboard", post(handle_rdp_clipboard))
.route("/mcp/ssh/connect", post(handle_ssh_connect))
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware))
.with_state(state);
let listener = TcpListener::bind("127.0.0.1:0").await
.map_err(|e| format!("Failed to bind MCP server: {}", e))?;
let port = listener.local_addr()
.map_err(|e| format!("Failed to get MCP server port: {}", e))?
.port();
// Write port to well-known location
let data_dir = crate::data_directory();
let port_file = data_dir.join("mcp-port");
std::fs::write(&port_file, port.to_string())
.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 {
axum::serve(listener, app).await.ok();
});
Ok(port)
}

View File

@ -70,6 +70,10 @@ impl PtyService {
break; break;
} }
} }
// WSL (Windows Subsystem for Linux)
if std::path::Path::new(r"C:\Windows\System32\wsl.exe").exists() {
shells.push(ShellInfo { name: "WSL".to_string(), path: r"C:\Windows\System32\wsl.exe".to_string() });
}
} }
shells shells
@ -85,6 +89,7 @@ impl PtyService {
scrollback: &ScrollbackRegistry, scrollback: &ScrollbackRegistry,
) -> Result<String, String> { ) -> Result<String, String> {
let session_id = uuid::Uuid::new_v4().to_string(); let session_id = uuid::Uuid::new_v4().to_string();
wraith_log!("[PTY] Spawning shell: {} (session {})", shell_path, session_id);
let pty_system = native_pty_system(); let pty_system = native_pty_system();
let pair = pty_system let pair = pty_system

View File

@ -8,11 +8,12 @@ use std::sync::atomic::{AtomicBool, Ordering};
use base64::Engine; use base64::Engine;
use dashmap::DashMap; use dashmap::DashMap;
use log::{error, info, warn}; use log::{error, info, warn};
use tauri::Emitter;
use serde::{Deserialize, Serialize}; 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;
@ -62,32 +63,47 @@ 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>,
} }
pub struct RdpService { pub struct RdpService {
sessions: DashMap<String, Arc<RdpSessionHandle>>, sessions: Arc<DashMap<String, Arc<RdpSessionHandle>>>,
} }
impl RdpService { impl RdpService {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
sessions: DashMap::new(), sessions: Arc::new(DashMap::new()),
} }
} }
pub fn connect(&self, config: RdpConfig) -> Result<String, String> { pub fn connect(&self, config: RdpConfig, app_handle: tauri::AppHandle) -> Result<String, String> {
let session_id = uuid::Uuid::new_v4().to_string(); let session_id = uuid::Uuid::new_v4().to_string();
wraith_log!("[RDP] Connecting to {}:{} as {} (session {})", config.hostname, config.port, config.username, session_id);
let width = config.width; let width = config.width;
let height = config.height; let height = config.height;
let hostname = config.hostname.clone(); let hostname = config.hostname.clone();
@ -97,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();
@ -107,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,
}); });
@ -119,6 +137,7 @@ impl RdpService {
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>(); let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
std::thread::spawn(move || { std::thread::spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
@ -153,11 +172,14 @@ 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,
height as u16, height as u16,
app_handle,
sid.clone(),
) )
.await .await
{ {
@ -166,6 +188,18 @@ impl RdpService {
info!("RDP session {} ended", sid); info!("RDP session {} ended", sid);
sessions_ref.remove(&sid); sessions_ref.remove(&sid);
}); });
}));
if let Err(panic) = result {
let msg = if let Some(s) = panic.downcast_ref::<String>() {
s.clone()
} else if let Some(s) = panic.downcast_ref::<&str>() {
s.to_string()
} else {
"unknown panic".to_string()
};
let _ = crate::write_log(&crate::data_directory().join("wraith.log"), &format!("RDP thread PANIC: {}", msg));
// ready_tx is dropped here, which triggers the "died unexpectedly" error
}
}); });
match ready_rx.recv() { match ready_rx.recv() {
@ -176,29 +210,81 @@ impl RdpService {
} }
Err(_) => { Err(_) => {
self.sessions.remove(&session_id); self.sessions.remove(&session_id);
return Err("RDP connection thread died unexpectedly".into()); return Err("RDP connection thread panicked — check wraith.log for details".into());
} }
} }
Ok(session_id) Ok(session_id)
} }
pub async fn get_frame(&self, session_id: &str) -> Result<String, 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(String::new()); return Ok((None, Vec::new()));
}
let buf = handle.frame_buffer.lock().await;
let encoded = base64::engine::general_purpose::STANDARD.encode(&*buf);
Ok(encoded)
} }
pub async fn get_frame_raw(&self, session_id: &str) -> Result<Vec<u8>, String> { let region = handle.dirty_region.lock().unwrap_or_else(|e| e.into_inner()).take();
let buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner());
let stride = handle.width as usize * 4;
let total_pixels = handle.width as usize * handle.height as usize;
match region {
Some(rect) if (rect.width as usize * rect.height as usize) < total_pixels / 2 => {
// Partial: extract only the dirty rectangle
let rw = rect.width as usize;
let rh = rect.height as usize;
let rx = rect.x as usize;
let ry = rect.y as usize;
let mut out = Vec::with_capacity(rw * rh * 4);
for row in ry..ry + rh {
let start = row * stride + rx * 4;
let end = start + rw * 4;
if end <= buf.len() {
out.extend_from_slice(&buf[start..end]);
}
}
Ok((Some(rect), out))
}
_ => {
// Full frame: dirty region covers most of the screen or is missing
Ok((None, buf.clone()))
}
}
}
pub fn get_frame_raw(&self, session_id: &str) -> Result<Vec<u8>, String> {
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.
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 width = handle.width as u32;
let height = handle.height as u32;
let buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner());
// Encode RGBA raw bytes to PNG (fast compression for speed)
let mut png_data = Vec::new();
{
let mut encoder = png::Encoder::new(&mut png_data, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.set_compression(png::Compression::Fast);
let mut writer = encoder.write_header()
.map_err(|e| format!("PNG header error: {}", e))?;
writer.write_image_data(&buf)
.map_err(|e| format!("PNG encode error: {}", e))?;
}
Ok(base64::engine::general_purpose::STANDARD.encode(&png_data))
}
pub fn send_clipboard(&self, session_id: &str, text: &str) -> Result<(), String> { pub fn send_clipboard(&self, session_id: &str, text: &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))?;
handle.input_tx.send(InputEvent::Clipboard(text.to_string())).map_err(|_| format!("RDP session {} input channel closed", session_id)) handle.input_tx.send(InputEvent::Clipboard(text.to_string())).map_err(|_| format!("RDP session {} input channel closed", session_id))
@ -214,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);
@ -233,7 +332,7 @@ impl RdpService {
impl Clone for RdpService { impl Clone for RdpService {
fn clone(&self) -> Self { fn clone(&self) -> Self {
unreachable!("RdpService should not be cloned — access via State<AppState>"); Self { sessions: self.sessions.clone() }
} }
} }
@ -267,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,
@ -297,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) -> 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);
@ -349,17 +452,68 @@ async fn run_active_session(connection_result: ConnectionResult, framed: Upgrade
} }
all_outputs all_outputs
} }
Some(InputEvent::Resize { width: new_w, height: new_h }) => {
// Ensure dimensions are within RDP spec (200-8192, even width)
let w = (new_w.max(200).min(8192) & !1) as u32;
let h = new_h.max(200).min(8192) as u32;
if let Some(Ok(resize_frame)) = active_stage.encode_resize(w, h, None, None) {
writer.write_all(&resize_frame).await.map_err(|e| format!("Failed to send resize: {}", e))?;
// Reallocate image and front buffer for new dimensions
image = DecodedImage::new(PixelFormat::RgbA32, w as u16, h as u16);
let buf_size = w as usize * h as usize * 4;
let mut new_buf = vec![0u8; buf_size];
for pixel in new_buf.chunks_exact_mut(4) { pixel[3] = 255; }
*front_buffer.write().unwrap_or_else(|e| e.into_inner()) = new_buf;
width = w as u16;
height = h as u16;
info!("RDP session {} resized to {}x{}", session_id, width, height);
}
Vec::new()
}
} }
} }
}; };
for out in outputs { for out in outputs {
match out { match out {
ActiveStageOutput::ResponseFrame(frame) => { writer.write_all(&frame).await.map_err(|e| format!("Failed to write RDP response frame: {}", e))?; } ActiveStageOutput::ResponseFrame(frame) => { writer.write_all(&frame).await.map_err(|e| format!("Failed to write RDP response frame: {}", e))?; }
ActiveStageOutput::GraphicsUpdate(_region) => { ActiveStageOutput::GraphicsUpdate(region) => {
let mut buf = frame_buffer.lock().await; let rx = region.left as usize;
let ry = region.top as usize;
let rr = (region.right as usize).saturating_add(1).min(width as usize);
let rb = (region.bottom as usize).saturating_add(1).min(height as usize);
let stride = width as usize * 4;
// Copy only the dirty rectangle rows from decoded image → front buffer
{
let src = image.data(); let src = image.data();
if src.len() == buf.len() { buf.copy_from_slice(src); } else { *buf = src.to_vec(); } let mut front = front_buffer.write().unwrap_or_else(|e| e.into_inner());
frame_dirty.store(true, Ordering::Relaxed); 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), ());
} }
ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); } ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); }
ActiveStageOutput::DeactivateAll(_) => { warn!("RDP server sent DeactivateAll — reconnection not yet implemented"); return Ok(()); } ActiveStageOutput::DeactivateAll(_) => { warn!("RDP server sent DeactivateAll — reconnection not yet implemented"); return Ok(()); }

View File

@ -0,0 +1,255 @@
//! Network scanner tools — IP discovery, port scanning, and network mapping
//! through SSH exec channels. No agent installation required.
//!
//! All scans run on the REMOTE host through the existing SSH connection,
//! giving visibility into the remote network without direct access.
use std::sync::Arc;
use russh::client::Handle;
use russh::ChannelMsg;
use serde::Serialize;
use tokio::sync::Mutex as TokioMutex;
use crate::ssh::session::SshClient;
use crate::utils::shell_escape;
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveredHost {
pub ip: String,
pub mac: Option<String>,
pub hostname: Option<String>,
pub vendor: Option<String>,
pub open_ports: Vec<u16>,
pub services: Vec<String>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PortResult {
pub port: u16,
pub open: bool,
pub service: String,
}
/// Well-known port → service name mapping for common ports.
fn service_name(port: u16) -> &'static str {
match port {
21 => "FTP",
22 => "SSH",
23 => "Telnet",
25 => "SMTP",
53 => "DNS",
80 => "HTTP",
110 => "POP3",
135 => "RPC",
139 => "NetBIOS",
143 => "IMAP",
443 => "HTTPS",
445 => "SMB",
993 => "IMAPS",
995 => "POP3S",
1433 => "MSSQL",
1521 => "Oracle",
3306 => "MySQL",
3389 => "RDP",
5432 => "PostgreSQL",
5900 => "VNC",
6379 => "Redis",
8080 => "HTTP-Alt",
8443 => "HTTPS-Alt",
27017 => "MongoDB",
_ => "unknown",
}
}
/// 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.
pub async fn scan_network(
handle: &Arc<TokioMutex<Handle<SshClient>>>,
subnet: &str,
) -> Result<Vec<DiscoveredHost>, String> {
// Validate subnet format before using it in remote shell commands.
validate_subnet(subnet)?;
// Script that works on Linux and macOS:
// 1. Ping sweep the subnet to populate ARP cache
// 2. Read ARP table for IP/MAC pairs
// 3. Try reverse DNS for hostnames
let escaped_subnet = shell_escape(subnet);
let script = format!(r#"
OS=$(uname -s 2>/dev/null)
SUBNET={escaped_subnet}
# Ping sweep (background, fast)
if [ "$OS" = "Linux" ]; then
for i in $(seq 1 254); do
ping -c 1 -W 1 "$SUBNET.$i" > /dev/null 2>&1 &
done
wait
elif [ "$OS" = "Darwin" ]; then
for i in $(seq 1 254); do
ping -c 1 -t 1 "$SUBNET.$i" > /dev/null 2>&1 &
done
wait
fi
# Read ARP table
if [ "$OS" = "Linux" ]; then
arp -n 2>/dev/null | grep -v incomplete | awk 'NR>1 {{printf "%s|%s\n", $1, $3}}'
elif [ "$OS" = "Darwin" ]; then
arp -a 2>/dev/null | grep -v incomplete | awk '{{gsub(/[()]/, ""); printf "%s|%s\n", $2, $4}}'
fi
"#);
let output = exec_command(handle, &script).await
.ok_or_else(|| "Failed to execute network scan".to_string())?;
let mut hosts = Vec::new();
for line in output.lines() {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 2 && !parts[0].is_empty() {
let ip = parts[0].trim().to_string();
let mac = if parts[1].trim().is_empty() || parts[1].trim() == "(incomplete)" {
None
} else {
Some(parts[1].trim().to_string())
};
hosts.push(DiscoveredHost {
ip,
mac,
hostname: None,
vendor: None,
open_ports: Vec::new(),
services: Vec::new(),
});
}
}
// Try reverse DNS for each host
if !hosts.is_empty() {
let ips: Vec<String> = hosts.iter().map(|h| h.ip.clone()).collect();
let dns_script = ips.iter()
.map(|ip| format!("echo \"{}|$(host {} 2>/dev/null | awk '/domain name pointer/ {{print $NF}}' | sed 's/\\.$//')\"", ip, ip))
.collect::<Vec<_>>()
.join("\n");
if let Some(dns_output) = exec_command(handle, &dns_script).await {
for line in dns_output.lines() {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 2 && !parts[1].is_empty() {
if let Some(host) = hosts.iter_mut().find(|h| h.ip == parts[0]) {
host.hostname = Some(parts[1].to_string());
}
}
}
}
}
Ok(hosts)
}
/// Scan specific ports on a target host through the SSH session.
pub async fn scan_ports(
handle: &Arc<TokioMutex<Handle<SshClient>>>,
target: &str,
ports: &[u16],
) -> 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
let port_checks: Vec<String> = ports.iter()
.map(|p| format!(
"(echo >/dev/tcp/{target}/{p}) 2>/dev/null && echo \"{p}|open\" || echo \"{p}|closed\""
))
.collect();
// Run in parallel batches of 20 for speed
let mut results = Vec::new();
for chunk in port_checks.chunks(20) {
let script = chunk.join(" &\n") + " &\nwait";
let output = exec_command(handle, &script).await
.ok_or_else(|| "Port scan exec failed".to_string())?;
for line in output.lines() {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 2 {
if let Ok(port) = parts[0].parse::<u16>() {
results.push(PortResult {
port,
open: parts[1] == "open",
service: service_name(port).to_string(),
});
}
}
}
}
results.sort_by_key(|r| r.port);
Ok(results)
}
/// Quick scan of common ports on a target.
pub async fn quick_port_scan(
handle: &Arc<TokioMutex<Handle<SshClient>>>,
target: &str,
) -> Result<Vec<PortResult>, String> {
let common_ports: Vec<u16> = vec![
21, 22, 23, 25, 53, 80, 110, 135, 139, 143,
443, 445, 993, 995, 1433, 1521, 3306, 3389,
5432, 5900, 6379, 8080, 8443, 27017,
];
scan_ports(handle, target, &common_ports).await
}
async fn exec_command(handle: &Arc<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> {
let mut channel = {
let h = handle.lock().await;
h.channel_open_session().await.ok()?
};
channel.exec(true, cmd).await.ok()?;
let mut output = String::new();
loop {
match channel.wait().await {
Some(ChannelMsg::Data { ref data }) => {
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
output.push_str(text);
}
}
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break,
Some(ChannelMsg::ExitStatus { .. }) => {}
_ => {}
}
}
Some(output)
}

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)
} }
@ -94,17 +85,18 @@ fn format_permissions(raw: Option<u32>) -> String {
// ── SFTP service ───────────────────────────────────────────────────────────── // ── SFTP service ─────────────────────────────────────────────────────────────
/// Manages SFTP sessions keyed by SSH session ID. /// Manages SFTP sessions keyed by SSH session ID.
#[derive(Clone)]
pub struct SftpService { pub struct SftpService {
/// One `SftpSession` per SSH session, behind a mutex so async commands can /// One `SftpSession` per SSH session, behind a mutex so async commands can
/// take a shared reference to the `SftpService` and still mutably borrow /// take a shared reference to the `SftpService` and still mutably borrow
/// individual sessions. /// individual sessions.
clients: DashMap<String, Arc<TokioMutex<SftpSession>>>, clients: Arc<DashMap<String, Arc<TokioMutex<SftpSession>>>>,
} }
impl SftpService { impl SftpService {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
clients: DashMap::new(), clients: Arc::new(DashMap::new()),
} }
} }
@ -318,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

@ -1,3 +1,5 @@
pub mod session; pub mod session;
pub mod host_key; pub mod host_key;
pub mod cwd; pub mod cwd;
pub mod monitor;
pub mod exec;

View File

@ -0,0 +1,197 @@
//! Remote system monitoring via SSH exec channels.
//!
//! Periodically runs lightweight system commands over a separate exec channel
//! (same pattern as CWD tracker) and emits stats to the frontend.
//! No agent installation required — uses standard POSIX and platform commands.
use std::sync::Arc;
use log::warn;
use russh::client::Handle;
use russh::ChannelMsg;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use tokio::sync::Mutex as TokioMutex;
use tokio_util::sync::CancellationToken;
use crate::ssh::session::SshClient;
#[derive(Debug, Serialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct SystemStats {
pub cpu_percent: f64,
pub mem_used_mb: u64,
pub mem_total_mb: u64,
pub mem_percent: f64,
pub disk_used_gb: f64,
pub disk_total_gb: f64,
pub disk_percent: f64,
pub net_rx_bytes: u64,
pub net_tx_bytes: u64,
pub os_type: String,
}
/// 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(
handle: Arc<TokioMutex<Handle<SshClient>>>,
app_handle: AppHandle,
session_id: String,
cancel: CancellationToken,
) {
tokio::spawn(async move {
// Brief delay to let the shell start up
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let mut consecutive_timeouts: u32 = 0;
loop {
if cancel.is_cancelled() {
break;
}
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; }
}
}
});
}
async fn collect_stats(handle: &Arc<TokioMutex<Handle<SshClient>>>) -> Option<SystemStats> {
// Single command that works cross-platform: detect OS then gather stats
let script = r#"
OS=$(uname -s 2>/dev/null || echo "Unknown")
if [ "$OS" = "Linux" ]; then
CPU=$(grep 'cpu ' /proc/stat | awk '{usage=($2+$4)*100/($2+$4+$5)} END {printf "%.1f", usage}')
MEM=$(free -m 2>/dev/null | awk '/^Mem:/ {printf "%d %d", $3, $2}')
DISK=$(df -BG / 2>/dev/null | awk 'NR==2 {gsub("G",""); printf "%s %s", $3, $2}')
NET=$(cat /proc/net/dev 2>/dev/null | awk '/eth0:|ens|enp|wlan0:/ {gsub(":",""); printf "%s %s", $2, $10; exit}')
echo "WRAITH_STATS:$OS:$CPU:$MEM:$DISK:$NET"
elif [ "$OS" = "Darwin" ]; then
CPU=$(ps -A -o %cpu | awk '{s+=$1} END {printf "%.1f", s/4}')
MEM_PAGES=$(vm_stat 2>/dev/null | awk '/Pages active/ {gsub(/\./,""); print $3}')
MEM_TOTAL=$(sysctl -n hw.memsize 2>/dev/null | awk '{printf "%d", $1/1048576}')
MEM_USED=$(echo "$MEM_PAGES" | awk -v t="$MEM_TOTAL" '{printf "%d", $1*4096/1048576}')
DISK=$(df -g / 2>/dev/null | awk 'NR==2 {printf "%s %s", $3, $2}')
NET=$(netstat -ib 2>/dev/null | awk '/en0/ && /Link/ {printf "%s %s", $7, $10; exit}')
echo "WRAITH_STATS:$OS:$CPU:$MEM_USED $MEM_TOTAL:$DISK:$NET"
else
echo "WRAITH_STATS:$OS:0:0 0:0 0:0 0"
fi
"#;
let output = exec_command(handle, script).await?;
for line in output.lines() {
if let Some(rest) = line.strip_prefix("WRAITH_STATS:") {
return parse_stats(rest);
}
}
None
}
fn parse_stats(raw: &str) -> Option<SystemStats> {
let parts: Vec<&str> = raw.split(':').collect();
if parts.len() < 5 {
return None;
}
let os_type = parts[0].to_string();
let cpu_percent = parts[1].parse::<f64>().unwrap_or(0.0);
let mem_parts: Vec<&str> = parts[2].split_whitespace().collect();
let mem_used = mem_parts.first().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let mem_total = mem_parts.get(1).and_then(|s| s.parse::<u64>().ok()).unwrap_or(1);
let mem_percent = if mem_total > 0 { (mem_used as f64 / mem_total as f64) * 100.0 } else { 0.0 };
let disk_parts: Vec<&str> = parts[3].split_whitespace().collect();
let disk_used = disk_parts.first().and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0);
let disk_total = disk_parts.get(1).and_then(|s| s.parse::<f64>().ok()).unwrap_or(1.0);
let disk_percent = if disk_total > 0.0 { (disk_used / disk_total) * 100.0 } else { 0.0 };
let net_parts: Vec<&str> = parts.get(4).unwrap_or(&"0 0").split_whitespace().collect();
let net_rx = net_parts.first().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let net_tx = net_parts.get(1).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
Some(SystemStats {
cpu_percent,
mem_used_mb: mem_used,
mem_total_mb: mem_total,
mem_percent,
disk_used_gb: disk_used,
disk_total_gb: disk_total,
disk_percent,
net_rx_bytes: net_rx,
net_tx_bytes: net_tx,
os_type,
})
}
/// 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> {
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 h = handle.lock().await;
h.channel_open_session().await.ok()?
};
channel.exec(true, cmd).await.ok()?;
let mut output = String::new();
loop {
match channel.wait().await {
Some(ChannelMsg::Data { ref data }) => {
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
output.push_str(text);
}
}
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break,
Some(ChannelMsg::ExitStatus { .. }) => {}
_ => {}
}
}
Some(output)
}

View File

@ -13,9 +13,11 @@ use tokio::sync::mpsc;
use crate::db::Database; use crate::db::Database;
use crate::mcp::ScrollbackRegistry; use crate::mcp::ScrollbackRegistry;
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),
@ -46,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 {
@ -73,18 +76,20 @@ impl client::Handler for SshClient {
} }
} }
#[derive(Clone)]
pub struct SshService { pub struct SshService {
sessions: DashMap<String, Arc<SshSession>>, sessions: Arc<DashMap<String, Arc<SshSession>>>,
db: Database, db: Database,
} }
impl SshService { impl SshService {
pub fn new(db: Database) -> Self { pub fn new(db: Database) -> Self {
Self { sessions: DashMap::new(), db } Self { sessions: Arc::new(DashMap::new()), db }
} }
pub async fn connect(&self, app_handle: AppHandle, hostname: &str, port: u16, username: &str, auth: AuthMethod, cols: u32, rows: u32, sftp_service: &SftpService, scrollback: &ScrollbackRegistry) -> Result<String, String> { pub async fn connect(&self, app_handle: AppHandle, hostname: &str, port: u16, username: &str, auth: AuthMethod, cols: u32, rows: u32, sftp_service: &SftpService, scrollback: &ScrollbackRegistry, error_watcher: &ErrorWatcher) -> Result<String, String> {
let session_id = uuid::Uuid::new_v4().to_string(); let session_id = uuid::Uuid::new_v4().to_string();
wraith_log!("[SSH] Connecting to {}:{} as {} (session {})", hostname, port, username, session_id);
let config = Arc::new(russh::client::Config::default()); let config = Arc::new(russh::client::Config::default());
let handler = SshClient { host_key_store: HostKeyStore::new(self.db.clone()), hostname: hostname.to_string(), port }; let handler = SshClient { host_key_store: HostKeyStore::new(self.db.clone()), hostname: hostname.to_string(), port };
@ -132,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;
@ -148,8 +154,31 @@ impl SshService {
} }
} }
wraith_log!("[SSH] Connected and authenticated: {}", session_id);
// Create scrollback buffer for MCP terminal_read // Create scrollback buffer for MCP terminal_read
let scrollback_buf = scrollback.create(&session_id); let scrollback_buf = scrollback.create(&session_id);
error_watcher.watch(&session_id);
// Start remote monitoring if enabled (runs on a separate exec channel)
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.
// This enables SFTP CWD following on all platforms (Linux, macOS, FreeBSD).
// Sent via the PTY channel so it configures the interactive shell.
// Wrapped in stty -echo/echo so the command is invisible to the user,
// then clear the line with \r and overwrite with spaces.
{
let osc7_hook = concat!(
" stty -echo; ",
"__wraith_osc7() { printf '\\e]7;file://localhost/%s\\a' \"$(pwd | sed 's/ /%20/g')\"; }; ",
"if [ -n \"$ZSH_VERSION\" ]; then precmd() { __wraith_osc7; }; ",
"elif [ -n \"$BASH_VERSION\" ]; then PROMPT_COMMAND=__wraith_osc7; fi; ",
"stty echo; clear; cd ~\n"
);
let h = handle.lock().await;
let _ = h.data(channel_id, CryptoVec::from_slice(osc7_hook.as_bytes())).await;
}
// Output reader loop — owns the Channel exclusively. // Output reader loop — owns the Channel exclusively.
// Writes go through Handle::data() so no shared mutex is needed. // Writes go through Handle::data() so no shared mutex is needed.
@ -162,6 +191,10 @@ impl SshService {
match msg { match msg {
Some(ChannelMsg::Data { ref data }) => { Some(ChannelMsg::Data { ref data }) => {
scrollback_buf.push(data.as_ref()); scrollback_buf.push(data.as_ref());
// Passive OSC 7 CWD detection — scan without modifying stream
if let Some(cwd) = extract_osc7_cwd(data.as_ref()) {
let _ = app.emit(&format!("ssh:cwd:{}", sid), &cwd);
}
let encoded = base64::engine::general_purpose::STANDARD.encode(data.as_ref()); let encoded = base64::engine::general_purpose::STANDARD.encode(data.as_ref());
let _ = app.emit(&format!("ssh:data:{}", sid), encoded); let _ = app.emit(&format!("ssh:data:{}", sid), encoded);
} }
@ -216,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);
@ -223,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> {
@ -335,6 +370,60 @@ fn extract_dek_iv(pem_text: &str) -> Result<[u8; 16], String> {
Err("No DEK-Info: AES-128-CBC header found in encrypted PEM".to_string()) Err("No DEK-Info: AES-128-CBC header found in encrypted PEM".to_string())
} }
/// Passively extract CWD from OSC 7 escape sequences in terminal output.
/// Format: \e]7;file://hostname/path\a or \e]7;file://hostname/path\e\\
/// Returns the path portion without modifying the data stream.
fn extract_osc7_cwd(data: &[u8]) -> Option<String> {
let text = std::str::from_utf8(data).ok()?;
// Look for OSC 7 pattern: \x1b]7;file://
let marker = "\x1b]7;file://";
let start = text.find(marker)?;
let after_marker = &text[start + marker.len()..];
// Skip hostname (everything up to the next /)
let path_start = after_marker.find('/')?;
let path_part = &after_marker[path_start..];
// Find the terminator: BEL (\x07) or ST (\x1b\\)
let end = path_part.find('\x07')
.or_else(|| path_part.find("\x1b\\").map(|i| i));
let path = match end {
Some(e) => &path_part[..e],
None => path_part, // Might be split across chunks — take what we have
};
if path.is_empty() {
None
} else {
// URL-decode the path (spaces encoded as %20, etc.)
// Strip any stray quotes from shell printf output
let decoded = percent_decode(path);
let clean = decoded.trim_matches('"').trim_matches('\'').to_string();
if clean.is_empty() { None } else { Some(clean) }
}
}
fn percent_decode(input: &str) -> String {
let mut bytes: Vec<u8> = Vec::with_capacity(input.len());
let mut chars = input.chars();
while let Some(ch) = chars.next() {
if ch == '%' {
let hex: String = chars.by_ref().take(2).collect();
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
bytes.push(byte);
} else {
bytes.extend_from_slice(b"%");
bytes.extend_from_slice(hex.as_bytes());
}
} else {
let mut buf = [0u8; 4];
bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
}
}
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.
/// If it looks like a file path, read the file. Strip BOM and normalize. /// If it looks like a file path, read the file. Strip BOM and normalize.
fn resolve_private_key(input: &str) -> Result<String, String> { fn resolve_private_key(input: &str) -> Result<String, String> {

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,
@ -48,6 +49,12 @@
"plugins": { "plugins": {
"shell": { "shell": {
"open": true "open": true
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNCRkQ2OUY2OEY0Q0ZFQkYKUldTLy9reVA5bW45T3dUQ1R5OFNCenVhL2srTXlLcHR4cFNaeCtJSmJUSTZKSUNHVTRIbWZwanEK",
"endpoints": [
"https://files.command.vigilcyber.com/wraith/update.json"
]
} }
} }
} }

View File

@ -1,27 +1,67 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } 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";
// MainLayout is the full app shell lazy-load it so the unlock screen is const MainLayout = defineAsyncComponent({
// instant and the heavy editor/terminal code only lands after auth. loader: () => import("@/layouts/MainLayout.vue"),
import { defineAsyncComponent } from "vue"; onError(error) { console.error("[App] MainLayout load failed:", error); },
const MainLayout = defineAsyncComponent( });
() => import("@/layouts/MainLayout.vue") const DetachedSession = defineAsyncComponent({
); loader: () => import("@/components/session/DetachedSession.vue"),
onError(error) { console.error("[App] DetachedSession load failed:", error); },
});
const app = useAppStore(); const app = useAppStore();
const appError = ref<string | null>(null);
const isToolMode = ref(false);
const isDetachedMode = ref(false);
const toolName = ref("");
const toolSessionId = ref("");
onErrorCaptured((err) => {
appError.value = err instanceof Error ? err.message : String(err);
console.error("[App] Uncaught error:", err);
return false;
});
/** Parse hash and set mode flags. Called on mount and on hashchange. */
function applyHash(hash: string): void {
if (hash.startsWith("#/tool/")) {
isToolMode.value = true;
const rest = hash.substring(7);
const [name, query] = rest.split("?");
toolName.value = name;
toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || "";
} else if (hash.startsWith("#/detached-session")) {
isDetachedMode.value = true;
}
}
onMounted(async () => { 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>
<div class="app-root"> <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">
<!-- Show the unlock/create-vault screen until the store confirms we're in --> {{ appError }}
</div>
<DetachedSession v-else-if="isDetachedMode" />
<ToolWindow v-else-if="isToolMode" :tool="toolName" :session-id="toolSessionId" />
<div v-else class="app-root">
<UnlockLayout v-if="!app.isUnlocked" /> <UnlockLayout v-if="!app.isUnlocked" />
<!-- Once unlocked, mount the full application shell -->
<MainLayout v-else /> <MainLayout v-else />
</div> </div>
</template> </template>

View File

@ -2,7 +2,7 @@
.terminal-container { .terminal-container {
width: 100%; width: 100%;
height: 100%; min-height: 0;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: var(--wraith-bg-primary); background: var(--wraith-bg-primary);
@ -20,14 +20,16 @@
height: 100%; height: 100%;
} }
/* Selection styling */ /* WKWebView focus fix: xterm.js hides its helper textarea with opacity: 0,
.terminal-container .xterm-selection div { width/height: 0, left: -9999em. macOS WKWebView doesn't reliably focus
background-color: rgba(88, 166, 255, 0.3) !important; elements with zero dimensions positioned off-screen. Override to keep it
} within the viewport with non-zero dimensions so focus events fire. */
.terminal-container .xterm .xterm-helper-textarea {
/* Cursor styling */ left: 0 !important;
.terminal-container .xterm-cursor-layer { top: 0 !important;
z-index: 4; width: 1px !important;
height: 1px !important;
opacity: 0.01 !important;
} }
/* Scrollbar inside terminal */ /* Scrollbar inside terminal */

View File

@ -1,5 +1,14 @@
<template> <template>
<div class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] w-[640px]"> <div class="flex h-full relative">
<!-- Drag handle for resizing -->
<div
class="w-1 cursor-col-resize hover:bg-[var(--wraith-accent-blue)] active:bg-[var(--wraith-accent-blue)] transition-colors shrink-0"
@pointerdown="startResize"
/>
<div
class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] flex-1 min-w-0"
:style="{ width: panelWidth + 'px' }"
>
<!-- Header --> <!-- Header -->
<div class="p-3 border-b border-[var(--wraith-border)] flex items-center justify-between gap-2"> <div class="p-3 border-b border-[var(--wraith-border)] flex items-center justify-between gap-2">
<span class="text-xs font-bold tracking-widest text-[var(--wraith-accent-blue)]">AI COPILOT</span> <span class="text-xs font-bold tracking-widest text-[var(--wraith-accent-blue)]">AI COPILOT</span>
@ -28,6 +37,14 @@
> >
Kill Kill
</button> </button>
<button
v-if="connected"
class="px-2 py-0.5 text-[10px] rounded border border-[var(--wraith-border)] text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] cursor-pointer"
title="Inject available MCP tools into the chat"
@click="injectTools"
>
Tools
</button>
</div> </div>
</div> </div>
@ -45,18 +62,27 @@
</button> </button>
</div> </div>
<!-- Empty state --> <!-- Empty state with quick-launch presets -->
<div v-else class="flex-1 flex flex-col items-center justify-center gap-2 p-4"> <div v-else class="flex-1 flex flex-col items-center justify-center gap-3 p-4">
<p class="text-xs text-[var(--wraith-text-muted)] text-center"> <p class="text-xs text-[var(--wraith-text-muted)] text-center">
Select a shell and click Launch to start a local terminal. Select a shell and click Launch, or use a preset:
</p> </p>
<div v-if="presets.length" class="flex flex-col gap-1.5 w-full max-w-[200px]">
<button
v-for="preset in presets"
:key="preset.name"
class="px-3 py-1.5 text-xs rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] hover:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer text-left"
@click="launchPreset(preset)"
>
{{ preset.name }}
</button>
</div>
<p class="text-[10px] text-[var(--wraith-text-muted)] text-center"> <p class="text-[10px] text-[var(--wraith-text-muted)] text-center">
Run <code class="text-[var(--wraith-accent-blue)]">claude</code>, Configure presets in Settings AI Copilot
<code class="text-[var(--wraith-accent-blue)]">gemini</code>, or
<code class="text-[var(--wraith-accent-blue)]">codex</code> here.
</p> </p>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -67,6 +93,33 @@ import { useTerminal } from "@/composables/useTerminal";
interface ShellInfo { name: string; path: string; } interface ShellInfo { name: string; path: string; }
// Resizable panel
const panelWidth = ref(640);
function startResize(e: PointerEvent): void {
e.preventDefault();
const startX = e.clientX;
const startWidth = panelWidth.value;
function onMove(ev: PointerEvent): void {
// Dragging left increases width (panel is on the right side)
const delta = startX - ev.clientX;
panelWidth.value = Math.max(320, Math.min(1200, startWidth + delta));
}
function onUp(): void {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
}
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
}
interface LaunchPreset { name: string; shell: string; command: string; }
const presets = ref<LaunchPreset[]>([]);
const shells = ref<ShellInfo[]>([]); const shells = ref<ShellInfo[]>([]);
const selectedShell = ref(""); const selectedShell = ref("");
const connected = ref(false); const connected = ref(false);
@ -88,6 +141,56 @@ async function loadShells(): Promise<void> {
} }
} }
async function loadPresets(): Promise<void> {
try {
const raw = await invoke<string | null>("get_setting", { key: "copilot_presets" });
if (raw) {
presets.value = JSON.parse(raw);
} else {
// Seed with sensible defaults
presets.value = [
{ name: "Claude Code", shell: "", command: "claude" },
{ name: "Gemini CLI", shell: "", command: "gemini" },
{ name: "Codex CLI", shell: "", command: "codex" },
];
}
} catch {
presets.value = [];
}
}
async function launchPreset(preset: LaunchPreset): Promise<void> {
const shell = preset.shell || selectedShell.value;
if (!shell) return;
selectedShell.value = shell;
await launch();
// Wait for the shell prompt before sending the command.
// Poll the scrollback for a prompt indicator (PS>, $, #, %, >)
if (sessionId && connected.value) {
const maxWait = 5000;
const start = Date.now();
const poll = setInterval(async () => {
if (Date.now() - start > maxWait) {
clearInterval(poll);
// Send anyway after timeout
invoke("pty_write", { sessionId, data: preset.command + "\r" }).catch(() => {});
return;
}
try {
const lines = await invoke<string>("mcp_terminal_read", { sessionId, lines: 3 });
const lastLine = lines.split("\n").pop()?.trim() || "";
// Detect common shell prompts
if (lastLine.endsWith("$") || lastLine.endsWith("#") || lastLine.endsWith("%") || lastLine.endsWith(">") || lastLine.endsWith("PS>")) {
clearInterval(poll);
invoke("pty_write", { sessionId, data: preset.command + "\r" }).catch(() => {});
}
} catch {
// Scrollback not ready yet, keep polling
}
}, 200);
}
}
async function launch(): Promise<void> { async function launch(): Promise<void> {
if (!selectedShell.value) return; if (!selectedShell.value) return;
sessionEnded.value = false; sessionEnded.value = false;
@ -100,10 +203,12 @@ async function launch(): Promise<void> {
}); });
connected.value = true; connected.value = true;
await nextTick(); // Instantiate terminal synchronously (before any further awaits) now that
// sessionId is known. Cleanup is owned by this component's onBeforeUnmount.
if (containerRef.value) {
terminalInstance = useTerminal(sessionId, "pty"); terminalInstance = useTerminal(sessionId, "pty");
nextTick(() => {
if (containerRef.value && terminalInstance) {
terminalInstance.mount(containerRef.value); terminalInstance.mount(containerRef.value);
// Fit after mount to get real dimensions, then resize the PTY // Fit after mount to get real dimensions, then resize the PTY
@ -119,6 +224,7 @@ async function launch(): Promise<void> {
} }
}, 50); }, 50);
} }
});
// Listen for shell exit // Listen for shell exit
closeUnlisten = await listen(`pty:close:${sessionId}`, () => { closeUnlisten = await listen(`pty:close:${sessionId}`, () => {
@ -131,6 +237,44 @@ async function launch(): Promise<void> {
} }
} }
function injectTools(): void {
if (!sessionId || !connected.value) return;
const toolsPrompt = [
"You have access to these Wraith MCP tools via the wraith-mcp-bridge:",
"",
"SESSION MANAGEMENT:",
" list_sessions — List all active SSH/RDP/PTY sessions",
"",
"TERMINAL:",
" terminal_read(session_id, lines?) — Read recent terminal output (ANSI stripped)",
" terminal_execute(session_id, command, timeout_ms?) — Run a command and capture output",
" terminal_screenshot(session_id) — Capture RDP session as PNG",
"",
"SFTP:",
" sftp_list(session_id, path) — List remote directory",
" sftp_read(session_id, path) — Read remote file",
" sftp_write(session_id, path, content) — Write remote file",
"",
"NETWORK:",
" network_scan(session_id, subnet) — Discover devices on subnet (ARP + ping sweep)",
" port_scan(session_id, target, ports?) — Scan TCP ports",
" ping(session_id, target) — Ping a host",
" traceroute(session_id, target) — Traceroute to host",
" dns_lookup(session_id, domain, record_type?) — DNS lookup",
" whois(session_id, target) — Whois lookup",
" wake_on_lan(session_id, mac_address) — Send WoL magic packet",
" bandwidth_test(session_id) — Internet speed test",
"",
"UTILITIES (no session needed):",
" subnet_calc(cidr) — Calculate subnet details",
" generate_ssh_key(key_type, comment?) — Generate SSH key pair",
" generate_password(length?, uppercase?, lowercase?, digits?, symbols?) — Generate password",
"",
].join("\n");
invoke("pty_write", { sessionId, data: toolsPrompt + "\r" }).catch(() => {});
}
function kill(): void { function kill(): void {
if (sessionId) { if (sessionId) {
invoke("disconnect_pty", { sessionId }).catch(() => {}); invoke("disconnect_pty", { sessionId }).catch(() => {});
@ -151,7 +295,10 @@ function cleanup(): void {
sessionId = ""; sessionId = "";
} }
onMounted(loadShells); onMounted(() => {
loadShells();
loadPresets();
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (connected.value) kill(); if (connected.value) kill();

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

@ -154,6 +154,56 @@
</div> </div>
</template> </template>
<!-- AI Copilot -->
<template v-if="activeSection === 'copilot'">
<h4 class="text-xs font-semibold text-[var(--wraith-text-muted)] uppercase tracking-wider mb-3">Launch Presets</h4>
<p class="text-[10px] text-[var(--wraith-text-muted)] mb-3">
Configure quick-launch buttons for the AI copilot panel. Each preset spawns a shell and runs the command.
</p>
<div class="space-y-2">
<div
v-for="(preset, idx) in copilotPresets"
:key="idx"
class="flex items-center gap-2"
>
<input
v-model="preset.name"
type="text"
placeholder="Name"
class="w-24 px-2 py-1 text-xs rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)]"
/>
<input
v-model="preset.command"
type="text"
placeholder="Command (e.g. claude --dangerously-skip-permissions)"
class="flex-1 px-2 py-1 text-xs rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] font-mono"
/>
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)] transition-colors cursor-pointer text-sm"
@click="copilotPresets.splice(idx, 1)"
>
&times;
</button>
</div>
</div>
<div class="flex gap-2 mt-3">
<button
class="px-3 py-1.5 text-xs rounded border border-[#30363d] text-[var(--wraith-text-secondary)] hover:bg-[#30363d] transition-colors cursor-pointer"
@click="copilotPresets.push({ name: '', shell: '', command: '' })"
>
+ Add Preset
</button>
<button
class="px-3 py-1.5 text-xs rounded bg-[var(--wraith-accent-blue)] text-black font-bold transition-colors cursor-pointer"
@click="saveCopilotPresets"
>
Save
</button>
</div>
</template>
<!-- About --> <!-- About -->
<template v-if="activeSection === 'about'"> <template v-if="activeSection === 'about'">
<h4 class="text-xs font-semibold text-[var(--wraith-text-muted)] uppercase tracking-wider mb-3">About</h4> <h4 class="text-xs font-semibold text-[var(--wraith-text-muted)] uppercase tracking-wider mb-3">About</h4>
@ -183,6 +233,30 @@
</div> </div>
</div> </div>
<!-- Update check -->
<div class="pt-2">
<button
class="w-full px-3 py-2 text-xs font-bold rounded bg-[var(--wraith-accent-blue)] text-black cursor-pointer disabled:opacity-40"
:disabled="updateChecking"
@click="checkUpdates"
>
{{ updateChecking ? "Checking..." : "Check for Updates" }}
</button>
<div v-if="updateInfo" class="mt-2 p-3 rounded bg-[#0d1117] border border-[#30363d]">
<template v-if="updateInfo.updateAvailable">
<p class="text-xs text-[#3fb950] mb-1">Update available: v{{ updateInfo.latestVersion }}</p>
<p v-if="updateInfo.releaseNotes" class="text-[10px] text-[var(--wraith-text-muted)] mb-2 max-h-20 overflow-auto">{{ updateInfo.releaseNotes }}</p>
<button
class="w-full px-3 py-1.5 text-xs font-bold rounded bg-[#238636] text-white cursor-pointer"
@click="downloadUpdate"
>
Download v{{ updateInfo.latestVersion }}
</button>
</template>
<p v-else class="text-xs text-[var(--wraith-text-muted)]">You're on the latest version.</p>
</div>
</div>
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<a <a
href="#" href="#"
@ -221,12 +295,46 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted } from "vue"; import { ref, watch, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { open as shellOpen } from "@tauri-apps/plugin-shell"; import { open as shellOpen } from "@tauri-apps/plugin-shell";
type Section = "general" | "terminal" | "vault" | "about"; type Section = "general" | "terminal" | "vault" | "copilot" | "about";
interface CopilotPreset { name: string; shell: string; command: string; }
const visible = ref(false); const visible = ref(false);
const activeSection = ref<Section>("general"); const activeSection = ref<Section>("general");
const copilotPresets = ref<CopilotPreset[]>([]);
interface UpdateCheckInfo {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
downloadUrl: string;
releaseNotes: string;
}
const updateChecking = ref(false);
const updateInfo = ref<UpdateCheckInfo | null>(null);
async function checkUpdates(): Promise<void> {
updateChecking.value = true;
updateInfo.value = null;
try {
updateInfo.value = await invoke<UpdateCheckInfo>("check_for_updates");
} catch (err) {
alert(`Update check failed: ${err}`);
}
updateChecking.value = false;
}
async function downloadUpdate(): Promise<void> {
if (!updateInfo.value?.downloadUrl) return;
try {
await shellOpen(updateInfo.value.downloadUrl);
} catch {
window.open(updateInfo.value.downloadUrl, "_blank");
}
}
const currentVersion = ref("loading..."); const currentVersion = ref("loading...");
const sections = [ const sections = [
@ -245,6 +353,11 @@ const sections = [
label: "Vault", label: "Vault",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M4 4v2h-.25A1.75 1.75 0 0 0 2 7.75v5.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0 0 14 13.25v-5.5A1.75 1.75 0 0 0 12.25 6H12V4a4 4 0 1 0-8 0Zm6.5 2V4a2.5 2.5 0 0 0-5 0v2ZM8 9.5a1.5 1.5 0 0 1 .5 2.915V13.5a.5.5 0 0 1-1 0v-1.085A1.5 1.5 0 0 1 8 9.5Z"/></svg>`, icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M4 4v2h-.25A1.75 1.75 0 0 0 2 7.75v5.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0 0 14 13.25v-5.5A1.75 1.75 0 0 0 12.25 6H12V4a4 4 0 1 0-8 0Zm6.5 2V4a2.5 2.5 0 0 0-5 0v2ZM8 9.5a1.5 1.5 0 0 1 .5 2.915V13.5a.5.5 0 0 1-1 0v-1.085A1.5 1.5 0 0 1 8 9.5Z"/></svg>`,
}, },
{
id: "copilot" as const,
label: "AI Copilot",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M5.5 8.5 9 5l-2-.5L4 7.5l1.5 1ZM1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25Z"/></svg>`,
},
{ {
id: "about" as const, id: "about" as const,
label: "About", label: "About",
@ -273,6 +386,9 @@ const settings = ref({
/** Load saved settings from Rust backend on mount. */ /** Load saved settings from Rust backend on mount. */
onMounted(async () => { onMounted(async () => {
// Populate version from Tauri app config
try { currentVersion.value = await getVersion(); } catch { currentVersion.value = "unknown"; }
try { try {
const [protocol, sidebarW, theme, fontSize, scrollback] = await Promise.all([ const [protocol, sidebarW, theme, fontSize, scrollback] = await Promise.all([
invoke<string | null>("get_setting", { key: "default_protocol" }), invoke<string | null>("get_setting", { key: "default_protocol" }),
@ -306,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,
@ -326,6 +449,33 @@ watch(
function open(): void { function open(): void {
visible.value = true; visible.value = true;
activeSection.value = "general"; activeSection.value = "general";
loadCopilotPresets();
}
async function loadCopilotPresets(): Promise<void> {
try {
const raw = await invoke<string | null>("get_setting", { key: "copilot_presets" });
if (raw) {
copilotPresets.value = JSON.parse(raw);
} else {
copilotPresets.value = [
{ name: "Claude Code", shell: "", command: "claude" },
{ name: "Gemini CLI", shell: "", command: "gemini" },
{ name: "Codex CLI", shell: "", command: "codex" },
];
}
} catch {
copilotPresets.value = [];
}
}
async function saveCopilotPresets(): Promise<void> {
try {
const json = JSON.stringify(copilotPresets.value.filter(p => p.name && p.command));
await invoke("set_setting", { key: "copilot_presets", value: json });
} catch (err) {
console.error("Failed to save copilot presets:", err);
}
} }
function close(): void { function close(): void {

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;
// Immediate focus so keyboard works right away
if (keyboardGrabbed.value) canvasRef.value.focus();
// Immediate force refresh to show SOMETHING while we check dimensions
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
// Delayed dimension check layout needs time to settle
setTimeout(() => { setTimeout(() => {
canvasRef.value?.focus(); const wrapper = canvasWrapper.value;
}, 0); const canvas = canvasRef.value;
if (!wrapper || !canvas) return;
const { width: cw, height: ch } = wrapper.getBoundingClientRect();
const newW = Math.round(cw) & ~1;
const newH = Math.round(ch);
if (newW >= 200 && newH >= 200 && (newW !== canvas.width || newH !== canvas.height)) {
invoke("rdp_resize", {
sessionId: props.sessionId,
width: newW,
height: newH,
}).then(() => {
if (canvas) {
canvas.width = newW;
canvas.height = newH;
} }
setTimeout(() => {
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
}, 500);
}).catch(() => {});
}
}, 300);
}, },
); );
</script> </script>
@ -196,9 +267,8 @@ watch(
} }
.rdp-canvas { .rdp-canvas {
max-width: 100%; width: 100%;
max-height: 100%; height: 100%;
object-fit: contain;
cursor: default; cursor: default;
outline: none; outline: none;
image-rendering: auto; image-rendering: auto;

View File

@ -0,0 +1,75 @@
<template>
<div class="h-screen w-screen flex flex-col bg-[#0d1117]">
<!-- Minimal title bar -->
<div class="h-8 flex items-center justify-between px-3 bg-[#161b22] border-b border-[#30363d] shrink-0" data-tauri-drag-region>
<span class="text-xs text-[#8b949e]">{{ sessionName }}</span>
<span class="text-[10px] text-[#484f58]">Detached close to reattach</span>
</div>
<!-- Terminal -->
<div ref="containerRef" class="flex-1 min-h-0" />
<!-- Monitor bar for SSH sessions -->
<MonitorBar v-if="protocol === 'ssh'" :session-id="sessionId" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useTerminal } from "@/composables/useTerminal";
import MonitorBar from "@/components/terminal/MonitorBar.vue";
const sessionId = ref("");
const sessionName = ref("Detached Session");
const protocol = ref("ssh");
const containerRef = ref<HTMLElement | null>(null);
// Parse session info from URL hash synchronously so backend type is known at setup time
const hash = window.location.hash;
const params = new URLSearchParams(hash.split("?")[1] || "");
const _initialSessionId = params.get("sessionId") || "";
const _initialProtocol = params.get("protocol") || "ssh";
const _backend = (_initialProtocol === "local" ? "pty" : "ssh") as 'ssh' | 'pty';
const terminalInstance = useTerminal(_initialSessionId, _backend);
onMounted(async () => {
sessionId.value = _initialSessionId;
sessionName.value = decodeURIComponent(params.get("name") || "Detached Session");
protocol.value = _initialProtocol;
if (!sessionId.value || !containerRef.value) return;
terminalInstance.mount(containerRef.value);
setTimeout(() => {
terminalInstance.fit();
terminalInstance.terminal.focus();
const resizeCmd = _backend === "ssh" ? "ssh_resize" : "pty_resize";
invoke(resizeCmd, {
sessionId: sessionId.value,
cols: terminalInstance.terminal.cols,
rows: terminalInstance.terminal.rows,
}).catch(() => {});
}, 50);
// On window close, emit event so main window reattaches the tab
const appWindow = getCurrentWindow();
appWindow.onCloseRequested(async () => {
// Emit a custom event that the main window listens for
const { emit } = await import("@tauri-apps/api/event");
await emit("session:reattach", {
sessionId: sessionId.value,
name: sessionName.value,
protocol: protocol.value,
});
});
});
onBeforeUnmount(() => {
terminalInstance.destroy();
});
</script>

View File

@ -14,6 +14,19 @@
/> />
</div> </div>
<!-- Local PTY views v-show keeps xterm alive across tab switches -->
<div
v-for="session in localSessions"
:key="session.id"
v-show="session.id === sessionStore.activeSessionId"
class="absolute inset-0"
>
<LocalTerminalView
:session-id="session.id"
:is-active="session.id === sessionStore.activeSessionId"
/>
</div>
<!-- RDP views toolbar + canvas, kept alive via v-show --> <!-- RDP views toolbar + canvas, kept alive via v-show -->
<div <div
v-for="session in rdpSessions" v-for="session in rdpSessions"
@ -60,6 +73,7 @@ import { computed, ref } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useSessionStore } from "@/stores/session.store"; import { useSessionStore } from "@/stores/session.store";
import TerminalView from "@/components/terminal/TerminalView.vue"; import TerminalView from "@/components/terminal/TerminalView.vue";
import LocalTerminalView from "@/components/terminal/LocalTerminalView.vue";
import RdpView from "@/components/rdp/RdpView.vue"; import RdpView from "@/components/rdp/RdpView.vue";
import RdpToolbar from "@/components/rdp/RdpToolbar.vue"; import RdpToolbar from "@/components/rdp/RdpToolbar.vue";
import { ScancodeMap } from "@/composables/useRdp"; import { ScancodeMap } from "@/composables/useRdp";
@ -77,12 +91,17 @@ function setTerminalRef(sessionId: string, el: unknown): void {
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
// Only render sessions that are active (not detached to separate windows)
const sshSessions = computed(() => const sshSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "ssh"), sessionStore.sessions.filter((s) => s.protocol === "ssh" && s.active),
);
const localSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "local" && s.active),
); );
const rdpSessions = computed(() => const rdpSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "rdp"), sessionStore.sessions.filter((s) => s.protocol === "rdp" && s.active),
); );
/** /**

View File

@ -31,7 +31,7 @@ import { computed } from "vue";
const props = defineProps<{ const props = defineProps<{
/** Connection protocol — drives the protocol-dot colour. */ /** Connection protocol — drives the protocol-dot colour. */
protocol: "ssh" | "rdp"; protocol: "ssh" | "rdp" | "local";
/** Username from the active session (if known). */ /** Username from the active session (if known). */
username?: string; username?: string;
/** Raw tags from the connection record. */ /** Raw tags from the connection record. */
@ -40,9 +40,10 @@ const props = defineProps<{
status?: "connected" | "disconnected"; status?: "connected" | "disconnected";
}>(); }>();
/** Green=connected SSH, blue=connected RDP, red=disconnected. */ /** Green=connected SSH, blue=connected RDP, purple=local, red=disconnected. */
const protocolDotClass = computed(() => { const protocolDotClass = computed(() => {
if (props.status === "disconnected") return "bg-[#f85149]"; if (props.status === "disconnected") return "bg-[#f85149]";
if (props.protocol === "local") return "bg-[#bc8cff]";
return props.protocol === "ssh" ? "bg-[#3fb950]" : "bg-[#1f6feb]"; return props.protocol === "ssh" ? "bg-[#3fb950]" : "bg-[#1f6feb]";
}); });

View File

@ -14,6 +14,8 @@
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] hover:bg-[var(--wraith-bg-tertiary)]', : 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] hover:bg-[var(--wraith-bg-tertiary)]',
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '', isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
dragOverIndex === index ? 'border-l-2 border-l-[var(--wraith-accent-blue)]' : '', dragOverIndex === index ? 'border-l-2 border-l-[var(--wraith-accent-blue)]' : '',
session.hasActivity && session.id !== sessionStore.activeSessionId ? 'animate-pulse text-[var(--wraith-accent-blue)]' : '',
!session.active ? 'opacity-40 italic' : '',
]" ]"
@click="sessionStore.activateSession(session.id)" @click="sessionStore.activateSession(session.id)"
@dragstart="onDragStart(index, $event)" @dragstart="onDragStart(index, $event)"
@ -21,6 +23,7 @@
@dragleave="dragOverIndex = -1" @dragleave="dragOverIndex = -1"
@drop.prevent="onDrop(index)" @drop.prevent="onDrop(index)"
@dragend="draggedIndex = -1; dragOverIndex = -1" @dragend="draggedIndex = -1; dragOverIndex = -1"
@contextmenu.prevent="showTabMenu($event, session)"
> >
<!-- Badge: protocol dot + root dot + env pills --> <!-- Badge: protocol dot + root dot + env pills -->
<TabBadge <TabBadge
@ -42,18 +45,51 @@
</div> </div>
</div> </div>
<!-- New tab button --> <!-- New tab button with shell dropdown -->
<div class="relative shrink-0">
<button <button
class="flex items-center justify-center w-9 h-9 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer shrink-0" class="flex items-center justify-center w-9 h-9 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer"
title="New session" title="New local terminal"
@click="toggleShellMenu"
@blur="closeShellMenuDeferred"
> >
+ +
</button> </button>
<div
v-if="shellMenuOpen"
class="absolute top-full right-0 mt-0.5 w-48 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden z-50 py-1"
>
<button
v-for="shell in availableShells"
:key="shell.path"
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="spawnShell(shell)"
>
{{ shell.name }}
</button>
<div v-if="availableShells.length === 0" class="px-4 py-2 text-xs text-[var(--wraith-text-muted)]">
No shells found
</div>
</div>
</div>
<!-- Tab context menu -->
<Teleport to="body">
<div v-if="tabMenu.visible" class="fixed z-[100] w-44 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden py-1"
:style="{ top: tabMenu.y + 'px', left: tabMenu.x + 'px' }">
<button class="w-full px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
@click="detachTab">Detach to Window</button>
<div class="border-t border-[#30363d] my-1" />
<button class="w-full px-4 py-2 text-xs text-left text-[var(--wraith-accent-red)] hover:bg-[#30363d] cursor-pointer"
@click="closeMenuTab">Close</button>
</div>
<div v-if="tabMenu.visible" class="fixed inset-0 z-[99]" @click="tabMenu.visible = false" @contextmenu.prevent="tabMenu.visible = false" />
</Teleport>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref, onMounted, onBeforeUnmount } from "vue";
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";
import TabBadge from "@/components/session/TabBadge.vue"; import TabBadge from "@/components/session/TabBadge.vue";
@ -61,6 +97,84 @@ import TabBadge from "@/components/session/TabBadge.vue";
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const connectionStore = useConnectionStore(); const connectionStore = useConnectionStore();
// Shell menu for + button
interface ShellInfo { name: string; path: string; }
const availableShells = ref<ShellInfo[]>([]);
const shellMenuOpen = ref(false);
function toggleShellMenu(): void {
shellMenuOpen.value = !shellMenuOpen.value;
}
function closeShellMenuDeferred(): void {
setTimeout(() => { shellMenuOpen.value = false; }, 150);
}
async function spawnShell(shell: ShellInfo): Promise<void> {
shellMenuOpen.value = false;
await sessionStore.spawnLocalTab(shell.name, shell.path);
}
// Tab right-click context menu
const tabMenu = ref<{ visible: boolean; x: number; y: number; session: Session | null }>({
visible: false, x: 0, y: 0, session: null,
});
function showTabMenu(event: MouseEvent, session: Session): void {
tabMenu.value = { visible: true, x: event.clientX, y: event.clientY, session };
}
async function detachTab(): Promise<void> {
const session = tabMenu.value.session;
tabMenu.value.visible = false;
if (!session) return;
// Mark as detached in the store
session.active = false;
// Open a new Tauri window for this session
try {
await invoke("open_child_window", {
label: `detached-${session.id.substring(0, 8)}-${Date.now()}`,
title: `${session.name} — Wraith`,
url: `index.html#/detached-session?sessionId=${session.id}&name=${encodeURIComponent(session.name)}&protocol=${session.protocol}`,
width: 900, height: 600,
});
} catch (err) { console.error("Detach window error:", err); }
}
function closeMenuTab(): void {
const session = tabMenu.value.session;
tabMenu.value.visible = false;
if (session) sessionStore.closeSession(session.id);
}
import { listen } from "@tauri-apps/api/event";
import type { UnlistenFn } from "@tauri-apps/api/event";
let unlistenReattach: UnlistenFn | null = null;
onMounted(async () => {
try {
availableShells.value = await invoke<ShellInfo[]>("list_available_shells");
} catch {
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
const draggedIndex = ref(-1); const draggedIndex = ref(-1);
const dragOverIndex = ref(-1); const dragOverIndex = ref(-1);

View File

@ -98,6 +98,7 @@
:class="{ 'bg-[var(--wraith-bg-tertiary)] ring-1 ring-inset ring-[var(--wraith-accent-blue)]': selectedEntry?.path === entry.path }" :class="{ 'bg-[var(--wraith-bg-tertiary)] ring-1 ring-inset ring-[var(--wraith-accent-blue)]': selectedEntry?.path === entry.path }"
@click="selectedEntry = entry" @click="selectedEntry = entry"
@dblclick="handleEntryDblClick(entry)" @dblclick="handleEntryDblClick(entry)"
@contextmenu.prevent="openContextMenu($event, entry)"
> >
<!-- Icon --> <!-- Icon -->
<svg <svg
@ -136,6 +137,62 @@
</template> </template>
</div> </div>
<!-- Context menu -->
<Teleport to="body">
<div
v-if="contextMenu.visible"
class="fixed z-[100] w-44 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden py-1"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
@click="contextMenu.visible = false"
@contextmenu.prevent
>
<button
v-if="!contextMenu.entry?.isDir"
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
@click="handleEdit(contextMenu.entry!)"
>
Edit
</button>
<button
v-if="!contextMenu.entry?.isDir"
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
@click="selectedEntry = contextMenu.entry!; handleDownload()"
>
Download
</button>
<button
v-if="contextMenu.entry?.isDir"
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
@click="navigateTo(contextMenu.entry!.path)"
>
Open Folder
</button>
<button
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
@click="handleRename(contextMenu.entry!)"
>
Rename
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-accent-red)] hover:bg-[#30363d] cursor-pointer"
@click="selectedEntry = contextMenu.entry!; handleDelete()"
>
Delete
</button>
</div>
</Teleport>
<!-- Click-away handler to close context menu -->
<Teleport to="body">
<div
v-if="contextMenu.visible"
class="fixed inset-0 z-[99]"
@click="contextMenu.visible = false"
@contextmenu.prevent="contextMenu.visible = false"
/>
</Teleport>
<!-- Follow terminal toggle --> <!-- Follow terminal toggle -->
<div class="flex items-center gap-2 px-3 py-1.5 border-t border-[var(--wraith-border)]"> <div class="flex items-center gap-2 px-3 py-1.5 border-t border-[var(--wraith-border)]">
<label class="flex items-center gap-1.5 cursor-pointer text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] transition-colors"> <label class="flex items-center gap-1.5 cursor-pointer text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] transition-colors">
@ -151,7 +208,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref, toRef } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useSftp, type FileEntry } from "@/composables/useSftp"; import { useSftp, type FileEntry } from "@/composables/useSftp";
import { useTransfers } from "@/composables/useTransfers"; import { useTransfers } from "@/composables/useTransfers";
@ -164,12 +221,41 @@ const emit = defineEmits<{
openFile: [entry: FileEntry]; openFile: [entry: FileEntry];
}>(); }>();
const { currentPath, entries, isLoading, followTerminal, navigateTo, goUp, refresh } = useSftp(props.sessionId); const { currentPath, entries, isLoading, followTerminal, navigateTo, goUp, refresh } = useSftp(toRef(props, 'sessionId'));
const { addTransfer, completeTransfer, failTransfer } = useTransfers(); const { addTransfer, completeTransfer, failTransfer } = useTransfers();
/** Currently selected entry (single-click to select, double-click to open/navigate). */ /** Currently selected entry (single-click to select, double-click to open/navigate). */
const selectedEntry = ref<FileEntry | null>(null); const selectedEntry = ref<FileEntry | null>(null);
/** Right-click context menu state. */
const contextMenu = ref<{ visible: boolean; x: number; y: number; entry: FileEntry | null }>({
visible: false, x: 0, y: 0, entry: null,
});
function openContextMenu(event: MouseEvent, entry: FileEntry): void {
selectedEntry.value = entry;
contextMenu.value = { visible: true, x: event.clientX, y: event.clientY, entry };
}
function handleEdit(entry: FileEntry): void {
emit("openFile", entry);
}
async function handleRename(entry: FileEntry): Promise<void> {
const newName = prompt("Rename to:", entry.name);
if (!newName || !newName.trim() || newName.trim() === entry.name) return;
const parentPath = entry.path.substring(0, entry.path.lastIndexOf("/"));
const newPath = parentPath + "/" + newName.trim();
try {
await invoke("sftp_rename", { sessionId: props.sessionId, oldPath: entry.path, newPath });
await refresh();
} catch (err) {
console.error("SFTP rename error:", err);
}
}
/** Hidden file input element used for the upload flow. */ /** Hidden file input element used for the upload flow. */
const fileInputRef = ref<HTMLInputElement | null>(null); const fileInputRef = ref<HTMLInputElement | null>(null);
@ -285,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

@ -28,10 +28,17 @@
<!-- Only show groups that have matching connections during search --> <!-- Only show groups that have matching connections during search -->
<div v-if="!connectionStore.searchQuery || connectionStore.groupHasResults(group.id)"> <div v-if="!connectionStore.searchQuery || connectionStore.groupHasResults(group.id)">
<!-- Group header --> <!-- Group header -->
<button <div
class="w-full flex items-center gap-1.5 px-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer" class="w-full flex items-center gap-1.5 px-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer select-none"
:class="{ 'border-t-2 border-t-[var(--wraith-accent-blue)]': dragOverGroupId === group.id }"
draggable="true"
@click="toggleGroup(group.id)" @click="toggleGroup(group.id)"
@contextmenu.prevent="showGroupMenu($event, group)" @contextmenu.prevent="showGroupMenu($event, group)"
@dragstart="onGroupDragStart(group, $event)"
@dragover.prevent="onGroupDragOver(group)"
@dragleave="dragOverGroupId = null"
@drop.prevent="onGroupDrop(group)"
@dragend="resetDragState"
> >
<!-- Chevron --> <!-- Chevron -->
<svg <svg
@ -58,16 +65,23 @@
<span class="ml-auto text-[var(--wraith-text-muted)] text-[10px]"> <span class="ml-auto text-[var(--wraith-text-muted)] text-[10px]">
{{ connectionStore.connectionsByGroup(group.id).length }} {{ connectionStore.connectionsByGroup(group.id).length }}
</span> </span>
</button> </div>
<!-- Connections in group --> <!-- Connections in group -->
<div v-if="expandedGroups.has(group.id)"> <div v-if="expandedGroups.has(group.id)">
<button <div
v-for="conn in connectionStore.connectionsByGroup(group.id)" v-for="conn in connectionStore.connectionsByGroup(group.id)"
:key="conn.id" :key="conn.id"
class="w-full flex items-center gap-2 pl-8 pr-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer" draggable="true"
class="w-full flex items-center gap-2 pl-8 pr-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer select-none"
:class="{ 'border-t-2 border-t-[var(--wraith-accent-blue)]': dragOverConnId === conn.id }"
@dblclick="handleConnect(conn)" @dblclick="handleConnect(conn)"
@contextmenu.prevent="showConnectionMenu($event, conn)" @contextmenu.prevent="showConnectionMenu($event, conn)"
@dragstart="onConnDragStart(conn, group.id, $event)"
@dragover.prevent="onConnDragOver(conn)"
@dragleave="dragOverConnId = null"
@drop.prevent="onConnDrop(conn, group.id)"
@dragend="resetDragState"
> >
<!-- Protocol dot --> <!-- Protocol dot -->
<span <span
@ -82,7 +96,7 @@
> >
{{ tag }} {{ tag }}
</span> </span>
</button> </div>
</div> </div>
</div> </div>
</template> </template>
@ -96,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";
@ -118,11 +132,107 @@ const sessionStore = useSessionStore();
const contextMenu = ref<InstanceType<typeof ContextMenu> | null>(null); const contextMenu = ref<InstanceType<typeof ContextMenu> | null>(null);
const editDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null); const editDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
// Drag and drop reordering
const dragOverGroupId = ref<number | null>(null);
const dragOverConnId = ref<number | null>(null);
let draggedGroup: Group | null = null;
let draggedConn: { conn: Connection; fromGroupId: number } | null = null;
function onGroupDragStart(group: Group, event: DragEvent): void {
draggedGroup = group;
draggedConn = null;
event.dataTransfer?.setData("text/plain", `group:${group.id}`);
}
function onGroupDragOver(target: Group): void {
if (draggedGroup && draggedGroup.id !== target.id) {
dragOverGroupId.value = target.id;
}
// Allow dropping connections onto groups to move them
if (draggedConn) {
dragOverGroupId.value = target.id;
}
}
async function onGroupDrop(target: Group): Promise<void> {
if (draggedGroup && draggedGroup.id !== target.id) {
const groups = connectionStore.groups;
const fromIdx = groups.findIndex(g => g.id === draggedGroup!.id);
const toIdx = groups.findIndex(g => g.id === target.id);
if (fromIdx !== -1 && toIdx !== -1) {
const [moved] = groups.splice(fromIdx, 1);
groups.splice(toIdx, 0, moved);
// Persist new order
const ids = groups.map(g => g.id);
invoke("reorder_groups", { ids }).catch(console.error);
}
}
if (draggedConn && draggedConn.fromGroupId !== target.id) {
try {
await invoke("update_connection", { id: draggedConn.conn.id, input: { groupId: target.id } });
await connectionStore.loadAll();
} catch (err) { console.error("Failed to move connection:", err); }
}
resetDragState();
}
function onConnDragStart(conn: Connection, groupId: number, event: DragEvent): void {
draggedConn = { conn, fromGroupId: groupId };
draggedGroup = null;
event.dataTransfer?.setData("text/plain", `conn:${conn.id}`);
}
function onConnDragOver(target: Connection): void {
if (draggedConn && draggedConn.conn.id !== target.id) {
dragOverConnId.value = target.id;
}
}
async function onConnDrop(target: Connection, targetGroupId: number): Promise<void> {
if (draggedConn && draggedConn.conn.id !== target.id) {
if (draggedConn.fromGroupId !== targetGroupId) {
try {
await invoke("update_connection", { id: draggedConn.conn.id, input: { groupId: targetGroupId } });
await connectionStore.loadAll();
} catch (err) { console.error("Failed to move connection:", err); }
} else {
const conns = connectionStore.connectionsByGroup(targetGroupId);
const fromIdx = conns.findIndex(c => c.id === draggedConn!.conn.id);
const toIdx = conns.findIndex(c => c.id === target.id);
if (fromIdx !== -1 && toIdx !== -1) {
const [moved] = conns.splice(fromIdx, 1);
conns.splice(toIdx, 0, moved);
// Persist new order
const ids = conns.map(c => c.id);
invoke("reorder_connections", { ids }).catch(console.error);
}
}
}
resetDragState();
}
function resetDragState(): void {
draggedGroup = null;
draggedConn = null;
dragOverGroupId.value = null;
dragOverConnId.value = null;
}
// All groups expanded by default // All groups expanded by default
const expandedGroups = ref<Set<number>>( 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

@ -0,0 +1,112 @@
<template>
<div class="flex flex-col h-full">
<div
ref="containerRef"
class="terminal-container flex-1"
@click="terminal.focus()"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useTerminal } from "@/composables/useTerminal";
import { useSessionStore } from "@/stores/session.store";
import "@/assets/css/terminal.css";
const props = defineProps<{
sessionId: string;
isActive: boolean;
}>();
const sessionStore = useSessionStore();
const containerRef = ref<HTMLElement | null>(null);
const { terminal, mount, fit, destroy } = useTerminal(props.sessionId, "pty");
/** Apply the session store's active theme to this local terminal instance. */
function applyTheme(): void {
const theme = sessionStore.activeTheme;
if (!theme) return;
terminal.options.theme = {
background: theme.background,
foreground: theme.foreground,
cursor: theme.cursor,
cursorAccent: theme.background,
selectionBackground: theme.selectionBackground ?? "#264f78",
selectionForeground: theme.selectionForeground ?? "#ffffff",
selectionInactiveBackground: theme.selectionBackground ?? "#264f78",
black: theme.black,
red: theme.red,
green: theme.green,
yellow: theme.yellow,
blue: theme.blue,
magenta: theme.magenta,
cyan: theme.cyan,
white: theme.white,
brightBlack: theme.brightBlack,
brightRed: theme.brightRed,
brightGreen: theme.brightGreen,
brightYellow: theme.brightYellow,
brightBlue: theme.brightBlue,
brightMagenta: theme.brightMagenta,
brightCyan: theme.brightCyan,
brightWhite: theme.brightWhite,
};
if (containerRef.value) {
containerRef.value.style.backgroundColor = theme.background;
}
terminal.refresh(0, terminal.rows - 1);
}
onMounted(() => {
if (containerRef.value) {
mount(containerRef.value);
}
// Apply current theme immediately if one is already active
if (sessionStore.activeTheme) {
applyTheme();
}
setTimeout(() => {
fit();
terminal.focus();
invoke("pty_resize", {
sessionId: props.sessionId,
cols: terminal.cols,
rows: terminal.rows,
}).catch(() => {});
}, 50);
});
watch(
() => props.isActive,
(active) => {
if (active) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
fit();
terminal.focus();
invoke("pty_resize", {
sessionId: props.sessionId,
cols: terminal.cols,
rows: terminal.rows,
}).catch(() => {});
});
});
}
},
);
// Watch for theme changes and apply to this local terminal
watch(() => sessionStore.activeTheme, (newTheme) => {
if (newTheme) applyTheme();
}, { deep: true });
onBeforeUnmount(() => {
destroy();
});
</script>

View File

@ -0,0 +1,97 @@
<template>
<div
v-if="stats"
class="flex items-center gap-4 px-6 h-[48px] bg-[var(--wraith-bg-tertiary)] border-t border-[var(--wraith-border)] text-base font-mono shrink-0 select-none"
>
<!-- CPU -->
<span class="flex items-center gap-1">
<span class="text-[var(--wraith-text-muted)]">CPU</span>
<span :class="colorClass(stats.cpuPercent, 50, 80)">{{ stats.cpuPercent.toFixed(0) }}%</span>
</span>
<!-- RAM -->
<span class="flex items-center gap-1">
<span class="text-[var(--wraith-text-muted)]">RAM</span>
<span :class="colorClass(stats.memPercent, 50, 80)">{{ stats.memUsedMb }}M/{{ stats.memTotalMb }}M ({{ stats.memPercent.toFixed(0) }}%)</span>
</span>
<!-- Disk -->
<span class="flex items-center gap-1">
<span class="text-[var(--wraith-text-muted)]">DISK</span>
<span :class="colorClass(stats.diskPercent, 70, 90)">{{ stats.diskUsedGb.toFixed(0) }}G/{{ stats.diskTotalGb.toFixed(0) }}G ({{ stats.diskPercent.toFixed(0) }}%)</span>
</span>
<!-- Network -->
<span class="flex items-center gap-1">
<span class="text-[var(--wraith-text-muted)]">NET</span>
<span class="text-[var(--wraith-text-secondary)]">{{ formatBytes(stats.netRxBytes) }} {{ formatBytes(stats.netTxBytes) }}</span>
</span>
<!-- OS -->
<span class="text-[var(--wraith-text-muted)] ml-auto">{{ stats.osType }}</span>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
const props = defineProps<{
sessionId: string;
}>();
interface SystemStats {
cpuPercent: number;
memUsedMb: number;
memTotalMb: number;
memPercent: number;
diskUsedGb: number;
diskTotalGb: number;
diskPercent: number;
netRxBytes: number;
netTxBytes: number;
osType: string;
}
const stats = ref<SystemStats | null>(null);
let unlistenFn: UnlistenFn | null = null;
let subscribeGeneration = 0;
function colorClass(value: number, warnThreshold: number, critThreshold: number): string {
if (value >= critThreshold) return "text-[#f85149]"; // red
if (value >= warnThreshold) return "text-[#e3b341]"; // amber
return "text-[#3fb950]"; // green
}
function formatBytes(bytes: number): string {
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + "G";
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + "M";
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + "K";
return bytes + "B";
}
async function subscribe(): Promise<void> {
const gen = ++subscribeGeneration;
if (unlistenFn) unlistenFn();
const fn = await listen<SystemStats>(`ssh:monitor:${props.sessionId}`, (event) => {
stats.value = event.payload;
});
if (gen !== subscribeGeneration) {
// A newer subscribe() call has already taken over discard this listener
fn();
return;
}
unlistenFn = fn;
}
onMounted(subscribe);
watch(() => props.sessionId, () => {
stats.value = null;
subscribe();
});
onBeforeUnmount(() => {
if (unlistenFn) unlistenFn();
});
</script>

View File

@ -52,13 +52,19 @@
@click="handleFocus" @click="handleFocus"
@focus="handleFocus" @focus="handleFocus"
/> />
<!-- Remote monitoring bar -->
<MonitorBar :session-id="props.sessionId" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick, onMounted, watch } from "vue"; import { ref, nextTick, onMounted, onBeforeUnmount, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useTerminal } from "@/composables/useTerminal"; import { useTerminal } from "@/composables/useTerminal";
import { useSessionStore } from "@/stores/session.store"; import { useSessionStore } from "@/stores/session.store";
import MonitorBar from "@/components/terminal/MonitorBar.vue";
import type { IDisposable } from "@xterm/xterm";
import "@/assets/css/terminal.css"; import "@/assets/css/terminal.css";
const props = defineProps<{ const props = defineProps<{
@ -69,6 +75,11 @@ const props = defineProps<{
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const containerRef = ref<HTMLElement | null>(null); 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;
function handleFocus(): void {
terminal.focus();
}
// --- Search state --- // --- Search state ---
const searchVisible = ref(false); const searchVisible = ref(false);
@ -134,7 +145,7 @@ onMounted(() => {
} }
// Track terminal dimensions in the session store // Track terminal dimensions in the session store
terminal.onResize(({ cols, rows }) => { resizeDisposable = terminal.onResize(({ cols, rows }) => {
sessionStore.setTerminalDimensions(props.sessionId, cols, rows); sessionStore.setTerminalDimensions(props.sessionId, cols, rows);
}); });
@ -145,15 +156,27 @@ onMounted(() => {
}, 50); }, 50);
}); });
// Re-fit and focus terminal when switching back to this tab // Re-fit and focus terminal when switching back to this tab.
// Must wait for the container to have real dimensions after becoming visible.
watch( watch(
() => props.isActive, () => props.isActive,
(active) => { (active) => {
if (active) { if (active) {
setTimeout(() => { // Double rAF ensures the container has been laid out by the browser
requestAnimationFrame(() => {
requestAnimationFrame(() => {
fit(); fit();
terminal.focus(); terminal.focus();
}, 0); // Also notify the backend of the correct size
const session = sessionStore.sessions.find(s => s.id === props.sessionId);
const resizeCmd = session?.protocol === "local" ? "pty_resize" : "ssh_resize";
invoke(resizeCmd, {
sessionId: props.sessionId,
cols: terminal.cols,
rows: terminal.rows,
}).catch(() => {});
});
});
} }
}, },
); );
@ -166,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,
@ -183,14 +210,27 @@ 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 });
function handleFocus(): void { onBeforeUnmount(() => {
terminal.focus(); if (resizeDisposable) {
} resizeDisposable.dispose();
resizeDisposable = null;
}
});
</script> </script>

View File

@ -0,0 +1,43 @@
<template>
<ToolShell ref="shell" placeholder="Select a mode and click Run Test">
<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">
<option value="speedtest">Internet Speed Test</option>
<option value="iperf">iperf3 (LAN)</option>
</select>
<template v-if="mode === 'iperf'">
<input v-model="server" type="text" placeholder="iperf3 server IP" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-40" />
<input v-model.number="duration" type="number" min="1" max="60" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-16" />
<span class="text-xs text-[#484f58]">sec</span>
</template>
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="run">
{{ running ? "Testing..." : "Run Test" }}
</button>
</template>
</ToolShell>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import ToolShell from "./ToolShell.vue";
const props = defineProps<{ sessionId: string }>();
const mode = ref("speedtest");
const server = ref("");
const duration = ref(5);
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
async function run(): Promise<void> {
if (mode.value === "iperf" && !server.value) {
shell.value?.setOutput("Enter an iperf3 server IP");
return;
}
shell.value?.execute(() => {
if (mode.value === "iperf") {
return invoke<string>("tool_bandwidth_iperf", { sessionId: props.sessionId, server: server.value, duration: duration.value });
}
return invoke<string>("tool_bandwidth_speedtest", { sessionId: props.sessionId });
});
}
</script>

View File

@ -0,0 +1,29 @@
<template>
<ToolShell ref="shell" placeholder="Enter a domain and click Lookup">
<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" />
<select v-model="recordType" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer">
<option v-for="t in ['A','AAAA','MX','NS','TXT','CNAME','SOA','SRV','PTR']" :key="t" :value="t">{{ t }}</option>
</select>
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Lookup</button>
</template>
</ToolShell>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import ToolShell from "./ToolShell.vue";
const props = defineProps<{ sessionId: string }>();
const domain = ref("");
const recordType = ref("A");
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
async function lookup(): Promise<void> {
if (!domain.value) return;
shell.value?.execute(() =>
invoke<string>("tool_dns_lookup", { sessionId: props.sessionId, domain: domain.value, recordType: recordType.value })
);
}
</script>

View File

@ -0,0 +1,117 @@
<template>
<div class="flex flex-col h-full p-4 gap-3">
<!-- Tabs -->
<div class="flex items-center gap-2">
<button v-for="t in ['containers','images','volumes']" :key="t"
class="px-3 py-1 text-xs rounded cursor-pointer transition-colors"
:class="tab === t ? 'bg-[#58a6ff] text-black font-bold' : 'bg-[#21262d] text-[#8b949e] hover:text-white'"
@click="tab = t; refresh()"
>{{ t.charAt(0).toUpperCase() + t.slice(1) }}</button>
<div class="ml-auto flex gap-1">
<button class="px-2 py-1 text-[10px] rounded bg-[#21262d] text-[#8b949e] hover:text-white cursor-pointer" @click="refresh">Refresh</button>
<button class="px-2 py-1 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('builder-prune', '')">Builder Prune</button>
<button class="px-2 py-1 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('system-prune', '')">System Prune</button>
</div>
</div>
<!-- Containers -->
<div v-if="tab === 'containers'" class="flex-1 overflow-auto border border-[#30363d] rounded">
<table class="w-full text-xs"><thead class="bg-[#161b22] sticky top-0"><tr>
<th class="text-left px-3 py-2 text-[#8b949e]">Name</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Image</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Status</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Actions</th>
</tr></thead><tbody>
<tr v-for="c in containers" :key="c.id" class="border-t border-[#21262d] hover:bg-[#161b22]">
<td class="px-3 py-1.5 font-mono">{{ c.name }}</td>
<td class="px-3 py-1.5 text-[#8b949e]">{{ c.image }}</td>
<td class="px-3 py-1.5" :class="c.status.startsWith('Up') ? 'text-[#3fb950]' : 'text-[#8b949e]'">{{ c.status }}</td>
<td class="px-3 py-1.5 flex gap-1">
<button v-if="!c.status.startsWith('Up')" class="px-1.5 py-0.5 text-[10px] rounded bg-[#238636] text-white cursor-pointer" @click="action('start', c.name)">Start</button>
<button v-if="c.status.startsWith('Up')" class="px-1.5 py-0.5 text-[10px] rounded bg-[#e3b341] text-black cursor-pointer" @click="action('stop', c.name)">Stop</button>
<button class="px-1.5 py-0.5 text-[10px] rounded bg-[#1f6feb] text-white cursor-pointer" @click="action('restart', c.name)">Restart</button>
<button class="px-1.5 py-0.5 text-[10px] rounded bg-[#21262d] text-[#8b949e] cursor-pointer" @click="viewLogs(c.name)">Logs</button>
<button class="px-1.5 py-0.5 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('remove', c.name)">Remove</button>
</td>
</tr>
</tbody></table>
</div>
<!-- Images -->
<div v-if="tab === 'images'" class="flex-1 overflow-auto border border-[#30363d] rounded">
<table class="w-full text-xs"><thead class="bg-[#161b22] sticky top-0"><tr>
<th class="text-left px-3 py-2 text-[#8b949e]">Repository</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Tag</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Size</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Actions</th>
</tr></thead><tbody>
<tr v-for="img in images" :key="img.id" class="border-t border-[#21262d] hover:bg-[#161b22]">
<td class="px-3 py-1.5 font-mono">{{ img.repository }}</td>
<td class="px-3 py-1.5">{{ img.tag }}</td>
<td class="px-3 py-1.5 text-[#8b949e]">{{ img.size }}</td>
<td class="px-3 py-1.5"><button class="px-1.5 py-0.5 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('remove-image', img.id)">Remove</button></td>
</tr>
</tbody></table>
</div>
<!-- Volumes -->
<div v-if="tab === 'volumes'" class="flex-1 overflow-auto border border-[#30363d] rounded">
<table class="w-full text-xs"><thead class="bg-[#161b22] sticky top-0"><tr>
<th class="text-left px-3 py-2 text-[#8b949e]">Name</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Driver</th>
<th class="text-left px-3 py-2 text-[#8b949e]">Actions</th>
</tr></thead><tbody>
<tr v-for="v in volumes" :key="v.name" class="border-t border-[#21262d] hover:bg-[#161b22]">
<td class="px-3 py-1.5 font-mono">{{ v.name }}</td>
<td class="px-3 py-1.5 text-[#8b949e]">{{ v.driver }}</td>
<td class="px-3 py-1.5"><button class="px-1.5 py-0.5 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('remove-volume', v.name)">Remove</button></td>
</tr>
</tbody></table>
</div>
<!-- Output -->
<pre v-if="output" class="max-h-32 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-2 text-[10px] font-mono text-[#e0e0e0]">{{ output }}</pre>
<div class="text-[10px] text-[#484f58]">{{ containers.length }} containers · {{ images.length }} images · {{ volumes.length }} volumes</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
interface DockerContainer { id: string; name: string; image: string; status: string; ports: string; }
interface DockerImage { repository: string; tag: string; id: string; size: string; }
interface DockerVolume { name: string; driver: string; mountpoint: string; }
const props = defineProps<{ sessionId: string }>();
const tab = ref("containers");
const containers = ref<DockerContainer[]>([]);
const images = ref<DockerImage[]>([]);
const volumes = ref<DockerVolume[]>([]);
const output = ref("");
async function refresh(): Promise<void> {
try {
if (tab.value === "containers") containers.value = await invoke("docker_list_containers", { sessionId: props.sessionId, all: true });
if (tab.value === "images") images.value = await invoke("docker_list_images", { sessionId: props.sessionId });
if (tab.value === "volumes") volumes.value = await invoke("docker_list_volumes", { sessionId: props.sessionId });
} catch (err) { output.value = String(err); }
}
async function action(act: string, target: string): Promise<void> {
try {
output.value = await invoke<string>("docker_action", { sessionId: props.sessionId, action: act, target });
await refresh();
} catch (err) { output.value = String(err); }
}
async function viewLogs(name: string): Promise<void> {
try { output.value = await invoke<string>("docker_action", { sessionId: props.sessionId, action: "logs", target: name }); }
catch (err) { output.value = String(err); }
}
onMounted(refresh);
</script>

View File

@ -0,0 +1,107 @@
<template>
<div class="flex flex-col h-full bg-[#0d1117]">
<!-- Toolbar -->
<div class="flex items-center gap-2 px-3 py-2 bg-[#161b22] border-b border-[#30363d] shrink-0">
<span class="text-xs text-[#8b949e] font-mono truncate flex-1">{{ filePath }}</span>
<span v-if="modified" class="text-[10px] text-[#e3b341]">modified</span>
<button
class="px-3 py-1 text-xs font-bold rounded bg-[#238636] text-white cursor-pointer disabled:opacity-40"
:disabled="saving || !modified"
@click="save"
>
{{ saving ? "Saving..." : "Save" }}
</button>
</div>
<!-- Editor area -->
<div ref="editorContainer" class="flex-1 min-h-0" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
const props = defineProps<{
sessionId: string;
}>();
const filePath = ref("");
const content = ref("");
const modified = ref(false);
const saving = ref(false);
const editorContainer = ref<HTMLElement | null>(null);
let editorContent = "";
onMounted(async () => {
// Parse path from URL
const params = new URLSearchParams(window.location.hash.split("?")[1] || "");
filePath.value = decodeURIComponent(params.get("path") || "");
if (!filePath.value || !props.sessionId) return;
// Load file content
try {
content.value = await invoke<string>("sftp_read_file", {
sessionId: props.sessionId,
path: filePath.value,
});
editorContent = content.value;
} catch (err) {
content.value = `Error loading file: ${err}`;
}
// Create a simple textarea editor (CodeMirror can be added later)
if (editorContainer.value) {
const textarea = document.createElement("textarea");
textarea.value = content.value;
textarea.spellcheck = false;
textarea.style.cssText = `
width: 100%; height: 100%; resize: none; border: none; outline: none;
background: #0d1117; color: #e0e0e0; padding: 12px; font-size: 13px;
font-family: 'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', monospace;
line-height: 1.5; tab-size: 4;
`;
textarea.addEventListener("input", () => {
editorContent = textarea.value;
modified.value = editorContent !== content.value;
});
textarea.addEventListener("keydown", (e) => {
// Ctrl+S to save
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
save();
}
// Tab inserts spaces
if (e.key === "Tab") {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, start) + " " + textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 4;
editorContent = textarea.value;
modified.value = true;
}
});
editorContainer.value.appendChild(textarea);
textarea.focus();
}
});
async function save(): Promise<void> {
if (!modified.value || saving.value) return;
saving.value = true;
try {
await invoke("sftp_write_file", {
sessionId: props.sessionId,
path: filePath.value,
content: editorContent,
});
content.value = editorContent;
modified.value = false;
} catch (err) {
alert(`Save failed: ${err}`);
}
saving.value = false;
}
</script>

View File

@ -0,0 +1,219 @@
<template>
<div class="flex flex-col h-full">
<!-- Tabs -->
<div class="flex items-center gap-1 px-4 py-2 bg-[#161b22] border-b border-[#30363d] shrink-0">
<button v-for="t in tabs" :key="t.id"
class="px-3 py-1 text-xs rounded cursor-pointer transition-colors"
:class="activeTab === t.id ? 'bg-[#58a6ff] text-black font-bold' : 'text-[#8b949e] hover:text-white'"
@click="activeTab = t.id"
>{{ t.label }}</button>
</div>
<div class="flex-1 overflow-auto p-6">
<!-- Getting Started -->
<div v-if="activeTab === 'guide'" class="prose-wraith">
<h2>Getting Started with Wraith</h2>
<p>Wraith is a native desktop SSH/SFTP/RDP client with an integrated AI copilot.</p>
<h3>Creating a Connection</h3>
<ol>
<li>Click <strong>File &rarr; New Connection</strong> or the <strong>+ Host</strong> button in the sidebar</li>
<li>Enter hostname, port, and protocol (SSH or RDP)</li>
<li>Optionally link a credential from the vault</li>
<li>Double-click the connection to connect</li>
</ol>
<h3>Quick Connect</h3>
<p>Type <code>user@host:port</code> in the Quick Connect bar and press Enter.</p>
<h3>AI Copilot</h3>
<p>Press <strong>Ctrl+Shift+G</strong> to open the AI copilot panel. Select a shell, click Launch, and run your AI CLI (Claude Code, Gemini, Codex).</p>
<p>Configure one-click launch presets in <strong>Settings &rarr; AI Copilot</strong>.</p>
<h3>Local Terminals</h3>
<p>Click the <strong>+</strong> button in the tab bar to open a local shell (PowerShell, CMD, Git Bash, WSL, bash, zsh).</p>
<h3>SFTP Browser</h3>
<p>Switch to the <strong>SFTP</strong> tab in the sidebar. It follows the active SSH session and tracks the current working directory.</p>
<p>Right-click files for Edit, Download, Rename, Delete.</p>
<h3>Tab Management</h3>
<ul>
<li><strong>Drag tabs</strong> to reorder</li>
<li><strong>Right-click tab</strong> &rarr; Detach to Window (pop out to separate window)</li>
<li>Close the detached window to reattach</li>
<li>Tabs pulse blue when there's new activity in the background</li>
</ul>
<h3>Remote Monitoring</h3>
<p>Every SSH session shows a monitoring bar at the bottom with CPU, RAM, disk, and network stats polled every 5 seconds. No agent needed.</p>
</div>
<!-- Keyboard Shortcuts -->
<div v-if="activeTab === 'shortcuts'" class="prose-wraith">
<h2>Keyboard Shortcuts</h2>
<table>
<thead><tr><th>Shortcut</th><th>Action</th></tr></thead>
<tbody>
<tr><td><kbd>Ctrl+K</kbd></td><td>Command Palette</td></tr>
<tr><td><kbd>Ctrl+Shift+G</kbd></td><td>Toggle AI Copilot</td></tr>
<tr><td><kbd>Ctrl+B</kbd></td><td>Toggle Sidebar</td></tr>
<tr><td><kbd>Ctrl+W</kbd></td><td>Close Active Tab</td></tr>
<tr><td><kbd>Ctrl+Tab</kbd></td><td>Next Tab</td></tr>
<tr><td><kbd>Ctrl+Shift+Tab</kbd></td><td>Previous Tab</td></tr>
<tr><td><kbd>Ctrl+1-9</kbd></td><td>Switch to Tab N</td></tr>
<tr><td><kbd>Ctrl+F</kbd></td><td>Find in Terminal</td></tr>
<tr><td><kbd>Ctrl+S</kbd></td><td>Save (in editor windows)</td></tr>
</tbody>
</table>
<h3>Terminal</h3>
<table>
<thead><tr><th>Action</th><th>How</th></tr></thead>
<tbody>
<tr><td>Copy</td><td>Select text (auto-copies)</td></tr>
<tr><td>Paste</td><td>Right-click</td></tr>
</tbody>
</table>
</div>
<!-- MCP Integration -->
<div v-if="activeTab === 'mcp'" class="prose-wraith">
<h2>MCP Integration (AI Tool Access)</h2>
<p>Wraith includes an MCP (Model Context Protocol) server that gives AI CLI tools programmatic access to your active sessions.</p>
<h3>Setup</h3>
<p>The MCP bridge binary is automatically downloaded to:</p>
<pre>{{ bridgePath }}</pre>
<p>Register with Claude Code:</p>
<pre>claude mcp add wraith -- "{{ bridgePath }}"</pre>
<h3>Available MCP Tools (18)</h3>
<h4>Session Management</h4>
<table>
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>list_sessions</code></td><td>List all active SSH/RDP/PTY sessions</td></tr>
</tbody>
</table>
<h4>Terminal</h4>
<table>
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>terminal_read</code></td><td>Read recent terminal output (ANSI stripped)</td></tr>
<tr><td><code>terminal_execute</code></td><td>Run a command and capture output</td></tr>
<tr><td><code>terminal_screenshot</code></td><td>Capture RDP frame as PNG</td></tr>
</tbody>
</table>
<h4>SFTP</h4>
<table>
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>sftp_list</code></td><td>List remote directory</td></tr>
<tr><td><code>sftp_read</code></td><td>Read remote file</td></tr>
<tr><td><code>sftp_write</code></td><td>Write remote file</td></tr>
</tbody>
</table>
<h4>Network</h4>
<table>
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>network_scan</code></td><td>ARP + ping sweep subnet discovery</td></tr>
<tr><td><code>port_scan</code></td><td>TCP port scan</td></tr>
<tr><td><code>ping</code></td><td>Ping a host</td></tr>
<tr><td><code>traceroute</code></td><td>Traceroute to host</td></tr>
<tr><td><code>dns_lookup</code></td><td>DNS query (A, MX, TXT, etc.)</td></tr>
<tr><td><code>whois</code></td><td>Whois lookup</td></tr>
<tr><td><code>wake_on_lan</code></td><td>Send WoL magic packet</td></tr>
<tr><td><code>bandwidth_test</code></td><td>Internet speed test</td></tr>
</tbody>
</table>
<h4>Utilities (no session needed)</h4>
<table>
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>subnet_calc</code></td><td>Subnet calculator</td></tr>
<tr><td><code>generate_ssh_key</code></td><td>Generate SSH key pair</td></tr>
<tr><td><code>generate_password</code></td><td>Generate secure password</td></tr>
</tbody>
</table>
<h3>How It Works</h3>
<ol>
<li>Wraith starts an HTTP server on <code>localhost</code> (random port)</li>
<li>Port written to <code>mcp-port</code> in data directory</li>
<li>Bridge binary reads the port and proxies JSON-RPC over stdio</li>
<li>AI CLI spawns the bridge as an MCP server</li>
</ol>
</div>
<!-- About -->
<div v-if="activeTab === 'about'" class="prose-wraith">
<h2>About Wraith</h2>
<p class="text-2xl font-bold tracking-widest text-[#58a6ff]">WRAITH</p>
<p>Exists everywhere, all at once.</p>
<table>
<tbody>
<tr><td>Version</td><td>{{ version }}</td></tr>
<tr><td>Runtime</td><td>Tauri v2 + Rust</td></tr>
<tr><td>Frontend</td><td>Vue 3 + TypeScript</td></tr>
<tr><td>Terminal</td><td>xterm.js 6</td></tr>
<tr><td>SSH</td><td>russh 0.48</td></tr>
<tr><td>RDP</td><td>ironrdp 0.14</td></tr>
<tr><td>License</td><td>Proprietary</td></tr>
<tr><td>Publisher</td><td>Vigilance Cyber / Vigilsynth</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
const tabs = [
{ id: "guide", label: "Getting Started" },
{ id: "shortcuts", label: "Shortcuts" },
{ id: "mcp", label: "MCP Integration" },
{ id: "about", label: "About" },
];
const activeTab = ref("guide");
const bridgePath = ref("loading...");
const version = ref("loading...");
onMounted(async () => {
// Read initial tab from URL
const params = new URLSearchParams(window.location.hash.split("?")[1] || "");
const page = params.get("page");
if (page && tabs.some(t => t.id === page)) activeTab.value = page;
try { version.value = await getVersion(); } catch { version.value = "unknown"; }
try { bridgePath.value = await invoke<string>("mcp_bridge_path"); } catch { bridgePath.value = "unknown"; }
});
</script>
<style scoped>
.prose-wraith h2 { font-size: 16px; font-weight: 700; color: #e0e0e0; margin-bottom: 12px; }
.prose-wraith h3 { font-size: 13px; font-weight: 600; color: #8b949e; margin-top: 20px; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
.prose-wraith h4 { font-size: 12px; font-weight: 600; color: #58a6ff; margin-top: 16px; margin-bottom: 6px; }
.prose-wraith p { font-size: 12px; color: #8b949e; margin-bottom: 8px; line-height: 1.6; }
.prose-wraith ol, .prose-wraith ul { font-size: 12px; color: #8b949e; margin-bottom: 8px; padding-left: 20px; }
.prose-wraith li { margin-bottom: 4px; line-height: 1.5; }
.prose-wraith code { background: #161b22; border: 1px solid #30363d; border-radius: 4px; padding: 1px 5px; font-size: 11px; color: #e0e0e0; }
.prose-wraith pre { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 10px 14px; font-size: 11px; color: #e0e0e0; overflow-x: auto; margin-bottom: 8px; font-family: 'Cascadia Mono', monospace; }
.prose-wraith kbd { background: #21262d; border: 1px solid #484f58; border-radius: 3px; padding: 1px 5px; font-size: 10px; color: #e0e0e0; }
.prose-wraith table { width: 100%; font-size: 12px; border-collapse: collapse; margin-bottom: 12px; }
.prose-wraith th { text-align: left; padding: 6px 10px; background: #161b22; color: #8b949e; font-weight: 500; border-bottom: 1px solid #30363d; }
.prose-wraith td { padding: 5px 10px; color: #e0e0e0; border-bottom: 1px solid #21262d; }
.prose-wraith strong { color: #e0e0e0; }
</style>

View File

@ -0,0 +1,90 @@
<template>
<div class="flex flex-col h-full p-4 gap-3">
<div class="flex items-center gap-2">
<label class="text-xs text-[#8b949e]">Subnet (first 3 octets):</label>
<input v-model="subnet" type="text" placeholder="192.168.1" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-40" />
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="scanning" @click="scan">
{{ scanning ? "Scanning..." : "Scan Network" }}
</button>
<button v-if="hosts.length" class="px-3 py-1.5 text-xs rounded border border-[#30363d] text-[#8b949e] hover:text-white cursor-pointer" @click="exportCsv">Export CSV</button>
</div>
<div class="flex-1 overflow-auto border border-[#30363d] rounded">
<table class="w-full text-xs">
<thead class="bg-[#161b22] sticky top-0">
<tr>
<th class="text-left px-3 py-2 text-[#8b949e] font-medium">IP Address</th>
<th class="text-left px-3 py-2 text-[#8b949e] font-medium">Hostname</th>
<th class="text-left px-3 py-2 text-[#8b949e] font-medium">MAC Address</th>
<th class="text-left px-3 py-2 text-[#8b949e] font-medium">Open Ports</th>
<th class="text-left px-3 py-2 text-[#8b949e] font-medium">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="host in hosts" :key="host.ip" class="border-t border-[#21262d] hover:bg-[#161b22]">
<td class="px-3 py-1.5 font-mono">{{ host.ip }}</td>
<td class="px-3 py-1.5">{{ host.hostname || "—" }}</td>
<td class="px-3 py-1.5 font-mono text-[#8b949e]">{{ host.mac || "—" }}</td>
<td class="px-3 py-1.5">
<span v-if="host.openPorts.length" class="text-[#3fb950]">{{ host.openPorts.join(", ") }}</span>
<button v-else class="text-[#58a6ff] hover:underline cursor-pointer" @click="quickScanHost(host)">scan</button>
</td>
<td class="px-3 py-1.5 flex gap-1">
<button class="px-2 py-0.5 text-[10px] rounded bg-[#238636] text-white cursor-pointer" @click="connectSsh(host)">SSH</button>
<button class="px-2 py-0.5 text-[10px] rounded bg-[#1f6feb] text-white cursor-pointer" @click="connectRdp(host)">RDP</button>
</td>
</tr>
<tr v-if="!hosts.length && !scanning">
<td colspan="5" class="px-3 py-8 text-center text-[#484f58]">Enter a subnet and click Scan</td>
</tr>
</tbody>
</table>
</div>
<div class="text-[10px] text-[#484f58]">{{ hosts.length }} hosts found Scanning through session {{ sessionId.substring(0, 8) }}...</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
const props = defineProps<{ sessionId: string }>();
interface Host { ip: string; mac: string | null; hostname: string | null; vendor: string | null; openPorts: number[]; services: string[]; }
const subnet = ref("192.168.1");
const hosts = ref<Host[]>([]);
const scanning = ref(false);
async function scan(): Promise<void> {
scanning.value = true;
try {
hosts.value = await invoke<Host[]>("scan_network", { sessionId: props.sessionId, subnet: subnet.value });
} catch (err) { alert(err); }
scanning.value = false;
}
async function quickScanHost(host: Host): Promise<void> {
try {
const results = await invoke<{ port: number; open: boolean; service: string }[]>("quick_scan", { sessionId: props.sessionId, target: host.ip });
host.openPorts = results.filter(r => r.open).map(r => r.port);
} catch (err) { console.error(err); }
}
function connectSsh(host: Host): void { alert(`TODO: Open SSH tab to ${host.ip}`); }
function connectRdp(host: Host): void { alert(`TODO: Open RDP tab to ${host.ip}`); }
function exportCsv(): void {
const lines = ["IP,Hostname,MAC,OpenPorts"];
for (const h of hosts.value) {
lines.push(`${h.ip},"${h.hostname || ""}","${h.mac || ""}","${h.openPorts.join(";")}"`);
}
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `wraith-scan-${subnet.value}-${Date.now()}.csv`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
</script>

View File

@ -0,0 +1,67 @@
<template>
<div class="flex flex-col h-full p-4 gap-4">
<h2 class="text-sm font-bold text-[#58a6ff]">Password Generator</h2>
<div class="flex items-center gap-3">
<div>
<label class="block text-xs text-[#8b949e] mb-1">Length</label>
<input v-model.number="length" type="number" min="4" max="128" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-20" />
</div>
<div class="flex items-center gap-3 self-end">
<label class="flex items-center gap-1 text-xs text-[#8b949e] cursor-pointer"><input v-model="uppercase" type="checkbox" class="accent-[#58a6ff]" /> A-Z</label>
<label class="flex items-center gap-1 text-xs text-[#8b949e] cursor-pointer"><input v-model="lowercase" type="checkbox" class="accent-[#58a6ff]" /> a-z</label>
<label class="flex items-center gap-1 text-xs text-[#8b949e] cursor-pointer"><input v-model="digits" type="checkbox" class="accent-[#58a6ff]" /> 0-9</label>
<label class="flex items-center gap-1 text-xs text-[#8b949e] cursor-pointer"><input v-model="symbols" type="checkbox" class="accent-[#58a6ff]" /> !@#</label>
</div>
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#238636] text-white cursor-pointer self-end" @click="generate">Generate</button>
</div>
<div v-if="password" class="flex items-center gap-2">
<input readonly :value="password" class="flex-1 px-3 py-2 text-lg font-mono rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] select-all" @click="($event.target as HTMLInputElement).select()" />
<button class="px-3 py-2 text-xs rounded bg-[#58a6ff] text-black font-bold cursor-pointer" @click="copy">Copy</button>
</div>
<div v-if="history.length" class="flex-1 overflow-auto">
<h3 class="text-xs text-[#8b949e] mb-2">History</h3>
<div v-for="(pw, i) in history" :key="i" class="flex items-center gap-2 py-1 border-b border-[#21262d]">
<span class="flex-1 font-mono text-xs text-[#8b949e] truncate">{{ pw }}</span>
<button class="text-[10px] text-[#58a6ff] hover:underline cursor-pointer" @click="copyText(pw)">copy</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
const length = ref(20);
const uppercase = ref(true);
const lowercase = ref(true);
const digits = ref(true);
const symbols = ref(true);
const password = ref("");
const history = ref<string[]>([]);
async function generate(): Promise<void> {
try {
password.value = await invoke<string>("tool_generate_password", {
length: length.value,
uppercase: uppercase.value,
lowercase: lowercase.value,
digits: digits.value,
symbols: symbols.value,
});
history.value.unshift(password.value);
if (history.value.length > 20) history.value.pop();
} catch (err) { alert(err); }
}
function copy(): void {
navigator.clipboard.writeText(password.value).catch(() => {});
}
function copyText(text: string): void {
navigator.clipboard.writeText(text).catch(() => {});
}
</script>

View File

@ -0,0 +1,28 @@
<template>
<ToolShell ref="shell" placeholder="Enter a host and click Ping">
<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.number="count" type="number" min="1" max="100" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-16" />
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="ping">Ping</button>
</template>
</ToolShell>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import ToolShell from "./ToolShell.vue";
const props = defineProps<{ sessionId: string }>();
const target = ref("");
const count = ref(4);
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
async function ping(): Promise<void> {
if (!target.value) return;
shell.value?.execute(async () => {
const result = await invoke<{ target: string; output: string }>("tool_ping", { sessionId: props.sessionId, target: target.value, count: count.value });
return result.output;
});
}
</script>

View File

@ -0,0 +1,81 @@
<template>
<div class="flex flex-col h-full p-4 gap-3">
<div class="flex items-center gap-2">
<input v-model="target" type="text" placeholder="Target IP or hostname" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-44" />
<input v-model="portRange" type="text" placeholder="Ports: 1-1024 or 22,80,443" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-48" />
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="scanning" @click="scan">
{{ scanning ? "Scanning..." : "Scan" }}
</button>
<button class="px-3 py-1.5 text-xs rounded border border-[#30363d] text-[#8b949e] hover:text-white cursor-pointer disabled:opacity-40" :disabled="scanning" @click="quickScan">Quick Scan</button>
</div>
<div class="flex-1 overflow-auto border border-[#30363d] rounded">
<table class="w-full text-xs">
<thead class="bg-[#161b22] sticky top-0">
<tr>
<th class="text-left px-3 py-2 text-[#8b949e] font-medium w-20">Port</th>
<th class="text-left px-3 py-2 text-[#8b949e] font-medium w-20">State</th>
<th class="text-left px-3 py-2 text-[#8b949e] font-medium">Service</th>
</tr>
</thead>
<tbody>
<tr v-for="r in results" :key="r.port" class="border-t border-[#21262d]">
<td class="px-3 py-1.5 font-mono">{{ r.port }}</td>
<td class="px-3 py-1.5" :class="r.open ? 'text-[#3fb950]' : 'text-[#484f58]'">{{ r.open ? "open" : "closed" }}</td>
<td class="px-3 py-1.5">{{ r.service }}</td>
</tr>
</tbody>
</table>
</div>
<div class="text-[10px] text-[#484f58]">{{ results.filter(r => r.open).length }} open / {{ results.length }} scanned</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
const props = defineProps<{ sessionId: string }>();
const target = ref("");
const portRange = ref("1-1024");
const results = ref<{ port: number; open: boolean; service: string }[]>([]);
const scanning = ref(false);
function parsePorts(input: string): number[] {
const ports: number[] = [];
for (const part of input.split(",")) {
const trimmed = part.trim();
if (trimmed.includes("-")) {
const [start, end] = trimmed.split("-").map(Number);
if (!isNaN(start) && !isNaN(end)) {
for (let p = start; p <= Math.min(end, 65535); p++) ports.push(p);
}
} else {
const p = Number(trimmed);
if (!isNaN(p) && p > 0 && p <= 65535) ports.push(p);
}
}
return ports;
}
async function scan(): Promise<void> {
if (!target.value) return;
scanning.value = true;
try {
const ports = parsePorts(portRange.value);
results.value = await invoke("scan_ports", { sessionId: props.sessionId, target: target.value, ports });
} catch (err) { alert(err); }
scanning.value = false;
}
async function quickScan(): Promise<void> {
if (!target.value) return;
scanning.value = true;
try {
results.value = await invoke("quick_scan", { sessionId: props.sessionId, target: target.value });
} catch (err) { alert(err); }
scanning.value = false;
}
</script>

View File

@ -0,0 +1,90 @@
<template>
<div class="flex flex-col h-full p-4 gap-4">
<h2 class="text-sm font-bold text-[#58a6ff]">SSH Key Generator</h2>
<div class="flex items-center gap-3">
<div>
<label class="block text-xs text-[#8b949e] mb-1">Key Type</label>
<select v-model="keyType" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer">
<option value="ed25519">Ed25519 (recommended)</option>
<option value="rsa">RSA 2048</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs text-[#8b949e] mb-1">Comment</label>
<input v-model="comment" type="text" placeholder="user@host" class="w-full px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff]" />
</div>
<div class="self-end">
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#238636] text-white cursor-pointer" @click="generate">Generate</button>
</div>
</div>
<template v-if="key">
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs text-[#8b949e]">Public Key</label>
<button class="text-[10px] text-[#58a6ff] hover:underline cursor-pointer" @click="copy(key.publicKey)">Copy</button>
</div>
<textarea readonly :value="key.publicKey" rows="2" class="w-full px-3 py-2 text-xs font-mono rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] resize-none" />
</div>
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs text-[#8b949e]">Private Key</label>
<button class="text-[10px] text-[#58a6ff] hover:underline cursor-pointer" @click="copy(key.privateKey)">Copy</button>
</div>
<textarea readonly :value="key.privateKey" rows="8" class="w-full px-3 py-2 text-xs font-mono rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] resize-none" />
</div>
<div class="flex items-center gap-3">
<div class="text-xs text-[#8b949e]">
Fingerprint: <span class="font-mono text-[#e0e0e0]">{{ key.fingerprint }}</span>
</div>
<button class="px-3 py-1 text-xs rounded bg-[#58a6ff] text-black font-bold cursor-pointer" @click="savePrivateKey">Save Private Key</button>
<button class="px-3 py-1 text-xs rounded border border-[#30363d] text-[#8b949e] hover:text-white cursor-pointer" @click="savePublicKey">Save Public Key</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
const keyType = ref("ed25519");
const comment = ref("");
interface GeneratedKey { privateKey: string; publicKey: string; fingerprint: string; keyType: string; }
const key = ref<GeneratedKey | null>(null);
async function generate(): Promise<void> {
try {
key.value = await invoke<GeneratedKey>("tool_generate_ssh_key", { keyType: keyType.value, comment: comment.value || null });
} catch (err) { alert(err); }
}
function copy(text: string): void {
navigator.clipboard.writeText(text).catch(() => {});
}
function saveFile(content: string, filename: string): void {
const blob = new Blob([content], { type: "text/plain" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
function savePrivateKey(): void {
if (!key.value) return;
const ext = key.value.keyType === "ed25519" ? "id_ed25519" : "id_rsa";
saveFile(key.value.privateKey, ext);
}
function savePublicKey(): void {
if (!key.value) return;
const ext = key.value.keyType === "ed25519" ? "id_ed25519.pub" : "id_rsa.pub";
saveFile(key.value.publicKey, ext);
}
</script>

View File

@ -0,0 +1,49 @@
<template>
<div class="flex flex-col h-full p-4 gap-4">
<div class="flex items-center gap-2">
<input v-model="cidr" type="text" placeholder="192.168.1.0/24" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-48 font-mono" @keydown.enter="calc" />
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer" @click="calc">Calculate</button>
<div class="flex items-center gap-1 ml-2">
<button v-for="quick in ['/8','/16','/24','/25','/26','/27','/28','/29','/30','/32']" :key="quick"
class="px-1.5 py-0.5 text-[10px] rounded bg-[#21262d] text-[#8b949e] hover:text-white hover:bg-[#30363d] cursor-pointer"
@click="cidr = cidr.replace(/\/\d+$/, '') + quick; calc()"
>{{ quick }}</button>
</div>
</div>
<div v-if="info" class="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
<div><span class="text-[#8b949e]">CIDR:</span> <span class="font-mono">{{ info.cidr }}</span></div>
<div><span class="text-[#8b949e]">Class:</span> {{ info.class }} <span v-if="info.isPrivate" class="text-[#3fb950]">(Private)</span></div>
<div><span class="text-[#8b949e]">Network:</span> <span class="font-mono">{{ info.network }}</span></div>
<div><span class="text-[#8b949e]">Broadcast:</span> <span class="font-mono">{{ info.broadcast }}</span></div>
<div><span class="text-[#8b949e]">Netmask:</span> <span class="font-mono">{{ info.netmask }}</span></div>
<div><span class="text-[#8b949e]">Wildcard:</span> <span class="font-mono">{{ info.wildcard }}</span></div>
<div><span class="text-[#8b949e]">First Host:</span> <span class="font-mono">{{ info.firstHost }}</span></div>
<div><span class="text-[#8b949e]">Last Host:</span> <span class="font-mono">{{ info.lastHost }}</span></div>
<div><span class="text-[#8b949e]">Total Hosts:</span> {{ info.totalHosts.toLocaleString() }}</div>
<div><span class="text-[#8b949e]">Usable Hosts:</span> {{ info.usableHosts.toLocaleString() }}</div>
<div><span class="text-[#8b949e]">Prefix Length:</span> /{{ info.prefixLength }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
const cidr = ref("192.168.1.0/24");
interface SubnetInfo {
cidr: string; network: string; broadcast: string; netmask: string; wildcard: string;
firstHost: string; lastHost: string; totalHosts: number; usableHosts: number;
prefixLength: number; class: string; isPrivate: boolean;
}
const info = ref<SubnetInfo | null>(null);
async function calc(): Promise<void> {
if (!cidr.value) return;
try { info.value = await invoke<SubnetInfo>("tool_subnet_calc", { cidr: cidr.value }); }
catch (err) { alert(err); }
}
</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

@ -0,0 +1,43 @@
<template>
<div class="h-screen w-screen flex flex-col bg-[#0d1117] text-[#e0e0e0]">
<NetworkScanner v-if="tool === 'network-scanner'" :session-id="sessionId" />
<PortScanner v-else-if="tool === 'port-scanner'" :session-id="sessionId" />
<PingTool v-else-if="tool === 'ping'" :session-id="sessionId" />
<TracerouteTool v-else-if="tool === 'traceroute'" :session-id="sessionId" />
<WakeOnLan v-else-if="tool === 'wake-on-lan'" :session-id="sessionId" />
<DnsLookup v-else-if="tool === 'dns-lookup'" :session-id="sessionId" />
<WhoisTool v-else-if="tool === 'whois'" :session-id="sessionId" />
<BandwidthTest v-else-if="tool === 'bandwidth'" :session-id="sessionId" />
<SubnetCalc v-else-if="tool === 'subnet-calc'" />
<DockerPanel v-else-if="tool === 'docker'" :session-id="sessionId" />
<FileEditor v-else-if="tool === 'editor'" :session-id="sessionId" />
<SshKeyGen v-else-if="tool === 'ssh-keygen'" />
<PasswordGen v-else-if="tool === 'password-gen'" />
<HelpWindow v-else-if="tool === 'help'" />
<div v-else class="flex-1 flex items-center justify-center text-sm text-[#484f58]">
Unknown tool: {{ tool }}
</div>
</div>
</template>
<script setup lang="ts">
import NetworkScanner from "./NetworkScanner.vue";
import PortScanner from "./PortScanner.vue";
import PingTool from "./PingTool.vue";
import TracerouteTool from "./TracerouteTool.vue";
import WakeOnLan from "./WakeOnLan.vue";
import DnsLookup from "./DnsLookup.vue";
import WhoisTool from "./WhoisTool.vue";
import BandwidthTest from "./BandwidthTest.vue";
import SubnetCalc from "./SubnetCalc.vue";
import DockerPanel from "./DockerPanel.vue";
import FileEditor from "./FileEditor.vue";
import SshKeyGen from "./SshKeyGen.vue";
import PasswordGen from "./PasswordGen.vue";
import HelpWindow from "./HelpWindow.vue";
defineProps<{
tool: string;
sessionId: string;
}>();
</script>

View File

@ -0,0 +1,25 @@
<template>
<ToolShell ref="shell" placeholder="Enter a host and click Trace">
<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" />
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="trace">Trace</button>
</template>
</ToolShell>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import ToolShell from "./ToolShell.vue";
const props = defineProps<{ sessionId: string }>();
const target = ref("");
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
async function trace(): Promise<void> {
if (!target.value) return;
shell.value?.execute(() =>
invoke<string>("tool_traceroute", { sessionId: props.sessionId, target: target.value })
);
}
</script>

View File

@ -0,0 +1,32 @@
<template>
<div class="flex flex-col h-full p-4 gap-4">
<h2 class="text-sm font-bold text-[#58a6ff]">Wake on LAN</h2>
<p class="text-xs text-[#8b949e]">Send a magic packet through the remote host to wake a machine on the same network.</p>
<div class="flex items-center gap-2">
<input v-model="macAddress" type="text" placeholder="MAC address (AA:BB:CC:DD:EE:FF)" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1 font-mono" @keydown.enter="wake" />
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="sending" @click="wake">Wake</button>
</div>
<pre v-if="result" class="bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono text-[#e0e0e0]">{{ result }}</pre>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
const props = defineProps<{ sessionId: string }>();
const macAddress = ref("");
const result = ref("");
const sending = ref(false);
async function wake(): Promise<void> {
if (!macAddress.value) return;
sending.value = true;
try {
result.value = await invoke<string>("tool_wake_on_lan", { sessionId: props.sessionId, macAddress: macAddress.value });
} catch (err) { result.value = String(err); }
sending.value = false;
}
</script>

View File

@ -0,0 +1,25 @@
<template>
<ToolShell ref="shell" placeholder="Enter a domain or IP and click Whois">
<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" />
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Whois</button>
</template>
</ToolShell>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import ToolShell from "./ToolShell.vue";
const props = defineProps<{ sessionId: string }>();
const target = ref("");
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
async function lookup(): Promise<void> {
if (!target.value) return;
shell.value?.execute(() =>
invoke<string>("tool_whois", { sessionId: props.sessionId, target: target.value })
);
}
</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,47 +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 frameCount = 0; 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: string; ): Promise<boolean> {
let raw: ArrayBuffer;
try { try {
raw = await invoke<string>("rdp_get_frame", { sessionId }); raw = await invoke<ArrayBuffer>("rdp_get_frame", { sessionId });
} catch { } catch {
// Session may not be connected yet or backend returned an error — skip frame return false;
return null;
} }
if (!raw || raw.length === 0) return null; if (!raw || raw.byteLength <= 8) return false;
// Decode base64 → binary string → Uint8ClampedArray const view = new DataView(raw);
const binaryStr = atob(raw); const rx = view.getUint16(0, true);
const bytes = new Uint8ClampedArray(binaryStr.length); const ry = view.getUint16(2, true);
for (let i = 0; i < binaryStr.length; i++) { const rw = view.getUint16(4, true);
bytes[i] = binaryStr.charCodeAt(i); const rh = view.getUint16(6, true);
} const pixelData = new Uint8ClampedArray(raw, 8);
// Validate: RGBA requires exactly width * height * 4 bytes if (rx === 0 && ry === 0 && rw === 0 && rh === 0) {
// Full frame
const expected = width * height * 4; const expected = width * height * 4;
if (bytes.length !== expected) { if (pixelData.length !== expected) return false;
console.warn( ctx.putImageData(new ImageData(pixelData, width, height), 0, 0);
`[useRdp] Frame size mismatch: got ${bytes.length}, expected ${expected}`, } else {
); // Dirty rectangle — apply at offset
return null; const expected = rw * rh * 4;
if (pixelData.length !== expected) return false;
ctx.putImageData(new ImageData(pixelData, rw, rh), rx, ry);
} }
return new ImageData(bytes, width, height); return true;
} }
/** /**
@ -303,26 +310,36 @@ export function useRdp(): UseRdpReturn {
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
function renderLoop(): void { let fetchPending = false;
frameCount++; let rafScheduled = false;
// Throttle to ~30fps by skipping odd-numbered rAF ticks // Fetch and render dirty region when backend signals new frame data.
if (frameCount % 2 === 0) { // Uses rAF to coalesce rapid events into one fetch per display frame.
fetchFrame(sessionId, width, height).then((imageData) => { function scheduleFrameFetch(): void {
if (imageData && ctx) { if (rafScheduled) return;
ctx.putImageData(imageData, 0, 0); rafScheduled = true;
// Mark connected on first successful frame animFrameId = requestAnimationFrame(async () => {
if (!connected.value) { rafScheduled = false;
connected.value = true; if (fetchPending) return;
} fetchPending = true;
} if (!ctx) return;
const rendered = await fetchAndRender(sessionId, width, height, ctx);
fetchPending = false;
if (rendered && !connected.value) connected.value = true;
}); });
} }
animFrameId = requestAnimationFrame(renderLoop); // Listen for frame events from the backend (push model)
} import("@tauri-apps/api/event").then(({ listen }) => {
listen(`rdp:frame:${sessionId}`, () => {
scheduleFrameFetch();
}).then((unlisten) => {
unlistenFrame = unlisten;
});
});
animFrameId = requestAnimationFrame(renderLoop); // Initial poll in case frames arrived before listener was set up
scheduleFrameFetch();
} }
/** /**
@ -333,8 +350,11 @@ export function useRdp(): UseRdpReturn {
cancelAnimationFrame(animFrameId); cancelAnimationFrame(animFrameId);
animFrameId = null; animFrameId = null;
} }
if (unlistenFrame !== null) {
unlistenFrame();
unlistenFrame = null;
}
connected.value = false; connected.value = false;
frameCount = 0;
} }
function toggleKeyboardGrab(): void { function toggleKeyboardGrab(): void {
@ -353,7 +373,7 @@ export function useRdp(): UseRdpReturn {
connected, connected,
keyboardGrabbed, keyboardGrabbed,
clipboardSync, clipboardSync,
fetchFrame, fetchAndRender,
sendMouse, sendMouse,
sendKey, sendKey,
sendClipboard, sendClipboard,

View File

@ -1,4 +1,4 @@
import { ref, onBeforeUnmount, type Ref } from "vue"; import { ref, watch, onBeforeUnmount, type Ref } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
@ -21,20 +21,29 @@ export interface UseSftpReturn {
refresh: () => Promise<void>; refresh: () => Promise<void>;
} }
// Persist the last browsed path per session so switching tabs restores position
const sessionPaths: Record<string, string> = {};
/** Remove a session's saved path from the module-level cache. Call on session close. */
export function cleanupSession(sessionId: string): void {
delete sessionPaths[sessionId];
}
/** /**
* Composable that manages SFTP file browsing state. * Composable that manages SFTP file browsing state.
* Calls the Rust SFTP commands via Tauri invoke. * Accepts a reactive session ID ref so it reinitializes on tab switch
* without destroying the component.
*/ */
export function useSftp(sessionId: string): UseSftpReturn { export function useSftp(sessionIdRef: Ref<string>): UseSftpReturn {
const currentPath = ref("/"); const currentPath = ref("/");
const entries = ref<FileEntry[]>([]); const entries = ref<FileEntry[]>([]);
const isLoading = ref(false); const isLoading = ref(false);
const followTerminal = ref(true); const followTerminal = ref(true);
// Holds the unlisten function returned by listen() — called on cleanup.
let unlistenCwd: UnlistenFn | null = null; let unlistenCwd: UnlistenFn | null = null;
let currentSessionId = "";
async function listDirectory(path: string): Promise<FileEntry[]> { async function listDirectory(sessionId: string, path: string): Promise<FileEntry[]> {
try { try {
const result = await invoke<FileEntry[]>("sftp_list", { sessionId, path }); const result = await invoke<FileEntry[]>("sftp_list", { sessionId, path });
return result ?? []; return result ?? [];
@ -45,10 +54,12 @@ export function useSftp(sessionId: string): UseSftpReturn {
} }
async function navigateTo(path: string): Promise<void> { async function navigateTo(path: string): Promise<void> {
if (!currentSessionId) return;
isLoading.value = true; isLoading.value = true;
try { try {
currentPath.value = path; currentPath.value = path;
entries.value = await listDirectory(path); sessionPaths[currentSessionId] = path;
entries.value = await listDirectory(currentSessionId, path);
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
@ -68,25 +79,63 @@ export function useSftp(sessionId: string): UseSftpReturn {
await navigateTo(currentPath.value); await navigateTo(currentPath.value);
} }
// Listen for CWD changes from the Rust backend (OSC 7 tracking). async function switchToSession(sessionId: string): Promise<void> {
// listen() returns Promise<UnlistenFn> — store it for cleanup. if (!sessionId) {
listen<string>(`ssh:cwd:${sessionId}`, (event) => { entries.value = [];
return;
}
// Save current path for the old session
if (currentSessionId) {
sessionPaths[currentSessionId] = currentPath.value;
}
// Unlisten old CWD events
if (unlistenCwd) {
unlistenCwd();
unlistenCwd = null;
}
currentSessionId = sessionId;
// Restore saved path or default to root
const savedPath = sessionPaths[sessionId] || "/";
currentPath.value = savedPath;
// Load the directory
isLoading.value = true;
try {
entries.value = await listDirectory(sessionId, savedPath);
} finally {
isLoading.value = false;
}
// Listen for CWD changes on the new session
try {
unlistenCwd = await listen<string>(`ssh:cwd:${sessionId}`, (event) => {
if (!followTerminal.value) return; if (!followTerminal.value) return;
const newPath = event.payload; const newPath = event.payload;
if (newPath && newPath !== currentPath.value) { if (newPath && newPath !== currentPath.value) {
navigateTo(newPath); navigateTo(newPath);
} }
}).then((unlisten) => {
unlistenCwd = unlisten;
}); });
} catch {
// Event listener setup failed — non-fatal
}
}
// React to session ID changes
watch(sessionIdRef, (newId) => {
switchToSession(newId);
}, { immediate: true });
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (currentSessionId) {
sessionPaths[currentSessionId] = currentPath.value;
}
if (unlistenCwd) unlistenCwd(); if (unlistenCwd) unlistenCwd();
}); });
// Load home directory on init
navigateTo("/home");
return { return {
currentPath, currentPath,
entries, entries,

View File

@ -5,6 +5,7 @@ import { SearchAddon } from "@xterm/addon-search";
import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebLinksAddon } from "@xterm/addon-web-links";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useSessionStore } from "@/stores/session.store";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
/** MobaXTerm Classicinspired terminal theme colors. */ /** MobaXTerm Classicinspired terminal theme colors. */
@ -13,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",
@ -69,7 +71,9 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
cursorStyle: "block", cursorStyle: "block",
scrollback: 10000, scrollback: 10000,
allowProposedApi: true, allowProposedApi: true,
convertEol: backend === 'ssh', // SSH always needs EOL conversion. PTY needs it on Windows (ConPTY sends bare \n)
// but not on Unix (PTY driver handles LF→CRLF). navigator.platform is the simplest check.
convertEol: backend === 'ssh' || navigator.platform.startsWith('Win'),
rightClickSelectsWord: false, rightClickSelectsWord: false,
}); });
@ -152,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
@ -160,7 +165,17 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
// Subscribe to SSH output events for this session. // Subscribe to SSH output events for this session.
// Tauri v2 listen() callback receives { payload: T } — the base64 string // Tauri v2 listen() callback receives { payload: T } — the base64 string
// is in event.payload (not event.data as in Wails). // is in event.payload (not event.data as in Wails).
// Throttle activity marking to avoid Vue reactivity storms
let lastActivityMark = 0;
unlistenPromise = listen<string>(dataEvent, (event) => { unlistenPromise = listen<string>(dataEvent, (event) => {
// Mark tab activity at most once per second
const now = Date.now();
if (now - lastActivityMark > 1000) {
lastActivityMark = now;
try { useSessionStore().markActivity(sessionId); } catch {}
}
const b64data = event.payload; const b64data = event.payload;
try { try {
@ -187,9 +202,12 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
unlistenFn = fn; unlistenFn = fn;
}); });
// Auto-fit when the container resizes // Auto-fit when the container resizes — but only if visible
resizeObserver = new ResizeObserver(() => { resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 50 && entry.contentRect.height > 50) {
fitAddon.fit(); fitAddon.fit();
}
}); });
resizeObserver.observe(container); resizeObserver.observe(container);
} }

View File

@ -50,6 +50,138 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Tools menu -->
<div class="relative">
<button
class="text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer px-2 py-1 rounded hover:bg-[var(--wraith-bg-tertiary)]"
@click="showToolsMenu = !showToolsMenu"
@blur="closeToolsMenuDeferred"
>
Tools
</button>
<div
v-if="showToolsMenu"
class="absolute top-full left-0 mt-0.5 w-56 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden z-50 py-1"
>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('network-scanner')"
>
<span class="flex-1">Network Scanner</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('port-scanner')"
>
<span class="flex-1">Port Scanner</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('ping')"
>
<span class="flex-1">Ping</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('traceroute')"
>
<span class="flex-1">Traceroute</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('dns-lookup')"
>
<span class="flex-1">DNS Lookup</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('whois')"
>
<span class="flex-1">Whois</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('bandwidth')"
>
<span class="flex-1">Bandwidth Test</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('subnet-calc')"
>
<span class="flex-1">Subnet Calculator</span>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('docker')"
>
<span class="flex-1">Docker Manager</span>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('wake-on-lan')"
>
<span class="flex-1">Wake on LAN</span>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('ssh-keygen')"
>
<span class="flex-1">SSH Key Generator</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleToolAction('password-gen')"
>
<span class="flex-1">Password Generator</span>
</button>
</div>
</div>
<!-- Help menu -->
<div class="relative">
<button
class="text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer px-2 py-1 rounded hover:bg-[var(--wraith-bg-tertiary)]"
@click="showHelpMenu = !showHelpMenu"
@blur="closeHelpMenuDeferred"
>
Help
</button>
<div
v-if="showHelpMenu"
class="absolute top-full left-0 mt-0.5 w-56 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden z-50 py-1"
>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleHelpAction('guide')"
>
<span class="flex-1">Getting Started</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleHelpAction('shortcuts')"
>
<span class="flex-1">Keyboard Shortcuts</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleHelpAction('mcp')"
>
<span class="flex-1">MCP Integration</span>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleHelpAction('about')"
>
<span class="flex-1">About Wraith</span>
</button>
</div>
</div>
</div> </div>
<!-- Quick Connect --> <!-- Quick Connect -->
@ -151,15 +283,6 @@
<!-- Tab bar --> <!-- Tab bar -->
<TabBar /> <TabBar />
<!-- Inline file editor -->
<EditorWindow
v-if="editorFile"
:content="editorFile.content"
:file-path="editorFile.path"
:session-id="editorFile.sessionId"
@close="editorFile = null"
/>
<!-- Session area --> <!-- Session area -->
<SessionContainer ref="sessionContainer" /> <SessionContainer ref="sessionContainer" />
</div> </div>
@ -185,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";
@ -201,7 +325,6 @@ import SettingsModal from "@/components/common/SettingsModal.vue";
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue"; import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
import FileTree from "@/components/sftp/FileTree.vue"; import FileTree from "@/components/sftp/FileTree.vue";
import TransferProgress from "@/components/sftp/TransferProgress.vue"; import TransferProgress from "@/components/sftp/TransferProgress.vue";
import EditorWindow from "@/components/editor/EditorWindow.vue";
import CopilotPanel from "@/components/ai/CopilotPanel.vue"; import CopilotPanel from "@/components/ai/CopilotPanel.vue";
import type { FileEntry } from "@/composables/useSftp"; import type { FileEntry } from "@/composables/useSftp";
@ -226,14 +349,75 @@ const connectionEditDialog = ref<InstanceType<typeof ConnectionEditDialog> | nul
const statusBar = ref<InstanceType<typeof StatusBar> | null>(null); const statusBar = ref<InstanceType<typeof StatusBar> | null>(null);
const sessionContainer = ref<InstanceType<typeof SessionContainer> | null>(null); const sessionContainer = ref<InstanceType<typeof SessionContainer> | null>(null);
interface EditorFile { path: string; content: string; sessionId: string; }
const editorFile = ref<EditorFile | null>(null);
const showFileMenu = ref(false); const showFileMenu = ref(false);
const showToolsMenu = ref(false);
const showHelpMenu = ref(false);
function closeFileMenuDeferred(): void { function closeFileMenuDeferred(): void {
setTimeout(() => { showFileMenu.value = false; }, 150); setTimeout(() => { showFileMenu.value = false; }, 150);
} }
function closeToolsMenuDeferred(): void {
setTimeout(() => { showToolsMenu.value = false; }, 150);
}
function closeHelpMenuDeferred(): void {
setTimeout(() => { showHelpMenu.value = false; }, 150);
}
async function handleHelpAction(page: string): Promise<void> {
showHelpMenu.value = false;
try {
await invoke("open_child_window", {
label: `help-${page}-${Date.now()}`,
title: "Wraith — Help",
url: `index.html#/tool/help?page=${page}`,
width: 750, height: 600,
});
} catch (err) { console.error("Help window error:", err); alert("Window error: " + String(err)); }
}
async function handleToolAction(tool: string): Promise<void> {
showToolsMenu.value = false;
// Tools that don't need a session
const localTools = ["ssh-keygen", "password-gen", "subnet-calc"];
if (!localTools.includes(tool) && !activeSessionId.value) {
alert("Connect to a server first — network tools run through SSH sessions.");
return;
}
const toolConfig: Record<string, { title: string; width: number; height: number }> = {
"network-scanner": { title: "Network Scanner", width: 800, height: 600 },
"port-scanner": { title: "Port Scanner", width: 700, height: 500 },
"ping": { title: "Ping", width: 600, height: 400 },
"traceroute": { title: "Traceroute", width: 600, height: 500 },
"dns-lookup": { title: "DNS Lookup", width: 600, height: 400 },
"whois": { title: "Whois", width: 700, height: 500 },
"bandwidth": { title: "Bandwidth Test", width: 700, height: 450 },
"subnet-calc": { title: "Subnet Calculator", width: 650, height: 350 },
"docker": { title: "Docker Manager", width: 900, height: 600 },
"wake-on-lan": { title: "Wake on LAN", width: 500, height: 300 },
"ssh-keygen": { title: "SSH Key Generator", width: 700, height: 500 },
"password-gen": { title: "Password Generator", width: 500, height: 400 },
};
const config = toolConfig[tool];
if (!config) return;
const sessionId = activeSessionId.value || "";
try {
await invoke("open_child_window", {
label: `tool-${tool}-${Date.now()}`,
title: `Wraith — ${config.title}`,
url: `index.html#/tool/${tool}?sessionId=${sessionId}`,
width: config.width, height: config.height,
});
} catch (err) { console.error("Tool window error:", err); alert("Tool window error: " + String(err)); }
}
async function handleFileMenuAction(action: string): Promise<void> { async function handleFileMenuAction(action: string): Promise<void> {
showFileMenu.value = false; showFileMenu.value = false;
switch (action) { switch (action) {
@ -251,9 +435,15 @@ 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 content = await invoke<string>("sftp_read_file", { sessionId: activeSessionId.value, path: entry.path }); const fileName = entry.path.split("/").pop() || entry.path;
editorFile.value = { path: entry.path, content, sessionId: activeSessionId.value }; const sessionId = activeSessionId.value;
} catch (err) { console.error("Failed to open SFTP file:", err); } await invoke("open_child_window", {
label: `editor-${Date.now()}`,
title: `${fileName} — Wraith Editor`,
url: `index.html#/tool/editor?sessionId=${sessionId}&path=${encodeURIComponent(entry.path)}`,
width: 800, height: 600,
});
} catch (err) { console.error("Failed to open editor:", err); }
} }
async function handleQuickConnect(): Promise<void> { async function handleQuickConnect(): Promise<void> {
@ -279,27 +469,81 @@ 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; } let workspaceSaveInterval: ReturnType<typeof setInterval> | null = null;
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; } function handleBeforeUnload(e: BeforeUnloadEvent): void {
if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") { event.preventDefault(); copilotVisible.value = !copilotVisible.value; return; } if (sessionStore.sessions.length > 0) {
if (ctrl && event.key === "f") { const active = sessionStore.activeSession; if (active?.protocol === "ssh") { event.preventDefault(); sessionContainer.value?.openActiveSearch(); } return; } e.preventDefault();
}
} }
onMounted(async () => { onMounted(async () => {
document.addEventListener("keydown", handleKeydown); // Confirm before closing if sessions are active (synchronous won't hang)
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)
setTimeout(async () => {
try {
const workspace = await invoke<{ tabs: { connectionId: number; protocol: string; position: number }[] } | null>("load_workspace");
if (workspace?.tabs?.length) {
for (const tab of workspace.tabs.sort((a, b) => a.position - b.position)) {
try { await sessionStore.connect(tab.connectionId); } catch {}
}
}
} catch {}
}, 500);
// Auto-save workspace every 30 seconds instead of on close
// (onCloseRequested was hanging the window close on Windows)
workspaceSaveInterval = setInterval(() => {
const tabs = sessionStore.sessions
.filter(s => s.protocol === "ssh" || s.protocol === "rdp")
.map((s, i) => ({ connectionId: s.connectionId, protocol: s.protocol, position: i }));
if (tabs.length > 0) {
invoke("save_workspace", { tabs }).catch(() => {});
}
}, 30000);
// Check for updates on startup via Tauri updater plugin (non-blocking)
invoke<{ currentVersion: string; latestVersion: string; updateAvailable: boolean; downloadUrl: string }>("check_for_updates")
.then((info) => {
if (info.updateAvailable) {
if (confirm(`Wraith v${info.latestVersion} is available (you have v${info.currentVersion}). Open download page?`)) {
import("@tauri-apps/plugin-shell").then(({ open }) => open(info.downloadUrl)).catch(() => window.open(info.downloadUrl, "_blank"));
}
}
})
.catch(() => {});
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown); window.removeEventListener("beforeunload", handleBeforeUnload);
if (workspaceSaveInterval !== null) {
clearInterval(workspaceSaveInterval);
workspaceSaveInterval = null;
}
}); });
</script> </script>

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", () => {
); );
}); });
/** Get connections belonging to a specific group. */ /** Memoized map of groupId → filtered connections. Recomputes only when connections or searchQuery change. */
function connectionsByGroup(groupId: number): Connection[] { const connectionsByGroupMap = computed<Record<number, Connection[]>>(() => {
const q = searchQuery.value.toLowerCase().trim(); const q = searchQuery.value.toLowerCase().trim();
const groupConns = connections.value.filter((c) => c.groupId === groupId); const map: Record<number, Connection[]> = {};
if (!q) return groupConns; for (const c of connections.value) {
return groupConns.filter( if (c.groupId === null) continue;
(c) => if (q) {
const match =
c.name.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) ||
c.hostname.toLowerCase().includes(q) || c.hostname.toLowerCase().includes(q) ||
c.tags?.some((t) => t.toLowerCase().includes(q)), c.tags?.some((t) => t.toLowerCase().includes(q));
); if (!match) continue;
}
if (!map[c.groupId]) map[c.groupId] = [];
map[c.groupId].push(c);
}
return map;
});
/** Get connections belonging to a specific group. */
function connectionsByGroup(groupId: number): Connection[] {
return connectionsByGroupMap.value[groupId] ?? [];
} }
/** 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";
@ -9,10 +10,11 @@ export interface Session {
id: string; id: string;
connectionId: number; connectionId: number;
name: string; name: string;
protocol: "ssh" | "rdp"; protocol: "ssh" | "rdp" | "local";
active: boolean; active: boolean;
username?: string; username?: string;
status: "connected" | "disconnected"; status: "connected" | "disconnected";
hasActivity: boolean;
} }
export interface TerminalDimensions { export interface TerminalDimensions {
@ -38,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 {
@ -51,6 +57,16 @@ export const useSessionStore = defineStore("session", () => {
function activateSession(id: string): void { function activateSession(id: string): void {
activeSessionId.value = id; activeSessionId.value = id;
// Clear activity indicator when switching to tab
const session = sessions.value.find(s => s.id === id);
if (session) session.hasActivity = false;
}
/** Mark a background tab as having new activity. */
function markActivity(sessionId: string): void {
if (sessionId === activeSessionId.value) return; // don't flash the active tab
const session = sessions.value.find(s => s.id === sessionId);
if (session) session.hasActivity = true;
} }
/** Reorder sessions by moving a tab from one index to another. */ /** Reorder sessions by moving a tab from one index to another. */
@ -70,7 +86,9 @@ export const useSessionStore = defineStore("session", () => {
// Disconnect the backend session using the protocol-appropriate command // Disconnect the backend session using the protocol-appropriate command
try { try {
if (session.protocol === "rdp") { if (session.protocol === "local") {
await invoke("disconnect_pty", { sessionId: session.id });
} else if (session.protocol === "rdp") {
await invoke("disconnect_rdp", { sessionId: session.id }); await invoke("disconnect_rdp", { sessionId: session.id });
} else { } else {
await invoke("disconnect_session", { sessionId: session.id }); await invoke("disconnect_session", { sessionId: session.id });
@ -79,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) {
@ -102,38 +126,31 @@ export const useSessionStore = defineStore("session", () => {
return count === 0 ? baseName : `${baseName} (${count + 1})`; return count === 0 ? baseName : `${baseName} (${count + 1})`;
} }
/** type CredentialRow = { id: number; name: string; username: string | null; domain?: string | null; credentialType: string; sshKeyId: number | null };
* Connect to a server by connection ID.
* Multiple sessions to the same host are allowed (MobaXTerm-style).
* Each gets its own tab with a disambiguated name like "Asgard (2)".
*
* For Tauri: we must resolve the connection details ourselves and pass
* hostname/port/username/password directly to connect_ssh, because the
* Rust side has no knowledge of connection IDs the vault owns credentials.
*/
async function connect(connectionId: number): Promise<void> {
const connectionStore = useConnectionStore();
const conn = connectionStore.connections.find((c) => c.id === connectionId);
if (!conn) return;
connecting.value = true; async function resolveCredentials(credentialId: number): Promise<CredentialRow | null> {
try { try {
if (conn.protocol === "ssh") { const allCreds = await invoke<CredentialRow[]>("list_credentials");
return allCreds.find((c) => c.id === credentialId) ?? null;
} catch (credErr) {
console.warn("Failed to resolve credential:", credErr);
return null;
}
}
async function connectSsh(
conn: { id: number; name: string; hostname: string; port: number; credentialId?: number | null },
connectionId: number,
): Promise<void> {
let sessionId: string; let sessionId: string;
let resolvedUsername = ""; let resolvedUsername = "";
let resolvedPassword = ""; let resolvedPassword = "";
// If connection has a linked credential, decrypt it from the vault
if (conn.credentialId) { if (conn.credentialId) {
try { const cred = await resolveCredentials(conn.credentialId);
const allCreds = await invoke<{ id: number; name: string; username: string | null; credentialType: string; sshKeyId: number | null }[]>("list_credentials");
const cred = allCreds.find((c) => c.id === conn.credentialId);
if (cred) { if (cred) {
resolvedUsername = cred.username ?? ""; resolvedUsername = cred.username ?? "";
if (cred.credentialType === "ssh_key" && cred.sshKeyId) { if (cred.credentialType === "ssh_key" && cred.sshKeyId) {
// SSH key auth — decrypt key from vault
const [privateKey, passphrase] = await invoke<[string, string]>("decrypt_ssh_key", { sshKeyId: cred.sshKeyId }); const [privateKey, passphrase] = await invoke<[string, string]>("decrypt_ssh_key", { sshKeyId: cred.sshKeyId });
sessionId = await invoke<string>("connect_ssh_with_key", { sessionId = await invoke<string>("connect_ssh_with_key", {
hostname: conn.hostname, hostname: conn.hostname,
@ -144,7 +161,6 @@ export const useSessionStore = defineStore("session", () => {
cols: 120, cols: 120,
rows: 40, rows: 40,
}); });
sessions.value.push({ sessions.value.push({
id: sessionId, id: sessionId,
connectionId, connectionId,
@ -153,25 +169,19 @@ export const useSessionStore = defineStore("session", () => {
active: true, active: true,
username: resolvedUsername, username: resolvedUsername,
status: "connected", status: "connected",
hasActivity: false,
}); });
setupStatusListeners(sessionId); setupStatusListeners(sessionId);
activeSessionId.value = sessionId; activeSessionId.value = sessionId;
return; // early return — key auth handled return;
} else { } else {
// Password auth — decrypt password from vault
resolvedPassword = await invoke<string>("decrypt_password", { credentialId: cred.id }); resolvedPassword = await invoke<string>("decrypt_password", { credentialId: cred.id });
} }
} }
} catch (credErr) {
console.warn("Failed to resolve credential, will prompt:", credErr);
}
} }
try { try {
if (!resolvedUsername) { if (!resolvedUsername) throw new Error("NO_CREDENTIALS");
// No credential linked — prompt immediately
throw new Error("NO_CREDENTIALS");
}
sessionId = await invoke<string>("connect_ssh", { sessionId = await invoke<string>("connect_ssh", {
hostname: conn.hostname, hostname: conn.hostname,
port: conn.port, port: conn.port,
@ -181,20 +191,13 @@ export const useSessionStore = defineStore("session", () => {
rows: 40, rows: 40,
}); });
} catch (sshErr: unknown) { } catch (sshErr: unknown) {
const errMsg = sshErr instanceof Error const errMsg = sshErr instanceof Error ? sshErr.message : typeof sshErr === "string" ? sshErr : String(sshErr);
? sshErr.message
: typeof sshErr === "string"
? sshErr
: String(sshErr);
// If no credentials or auth failed, prompt for username/password
const errLower = errMsg.toLowerCase(); const errLower = errMsg.toLowerCase();
if (errLower.includes("no_credentials") || errLower.includes("unable to authenticate") || errLower.includes("authentication") || errLower.includes("rejected")) { if (errLower.includes("no_credentials") || errLower.includes("unable to authenticate") || errLower.includes("authentication") || errLower.includes("rejected")) {
const username = window.prompt(`Username for ${conn.hostname}:`, resolvedUsername || "root"); const username = window.prompt(`Username for ${conn.hostname}:`, resolvedUsername || "root");
if (!username) throw new Error("Connection cancelled"); if (!username) throw new Error("Connection cancelled");
const password = window.prompt(`Password for ${username}@${conn.hostname}:`); const password = window.prompt(`Password for ${username}@${conn.hostname}:`);
if (password === null) throw new Error("Connection cancelled"); if (password === null) throw new Error("Connection cancelled");
resolvedUsername = username; resolvedUsername = username;
sessionId = await invoke<string>("connect_ssh", { sessionId = await invoke<string>("connect_ssh", {
hostname: conn.hostname, hostname: conn.hostname,
@ -217,16 +220,30 @@ export const useSessionStore = defineStore("session", () => {
active: true, active: true,
username: resolvedUsername, username: resolvedUsername,
status: "connected", status: "connected",
hasActivity: false,
}); });
setupStatusListeners(sessionId); setupStatusListeners(sessionId);
activeSessionId.value = sessionId; activeSessionId.value = sessionId;
} else if (conn.protocol === "rdp") { }
async function connectRdp(
conn: { id: number; name: string; hostname: string; port: number; credentialId?: number | null; options?: string },
connectionId: number,
): Promise<void> {
let username = ""; let username = "";
let password = ""; let password = "";
let domain = ""; let domain = "";
// Extract stored credentials from connection options JSON if present if (conn.credentialId) {
if (conn.options) { const cred = await resolveCredentials(conn.credentialId);
if (cred && cred.credentialType === "password") {
username = cred.username ?? "";
domain = cred.domain ?? "";
password = await invoke<string>("decrypt_password", { credentialId: cred.id });
}
}
if (!username && conn.options) {
try { try {
const opts = JSON.parse(conn.options); const opts = JSON.parse(conn.options);
if (opts?.username) username = opts.username; if (opts?.username) username = opts.username;
@ -240,53 +257,19 @@ export const useSessionStore = defineStore("session", () => {
let sessionId: string; let sessionId: string;
try { try {
sessionId = await invoke<string>("connect_rdp", { sessionId = await invoke<string>("connect_rdp", {
config: { config: { hostname: conn.hostname, port: conn.port, username, password, domain, width: 1920, height: 1080 },
hostname: conn.hostname,
port: conn.port,
username,
password,
domain,
width: 1920,
height: 1080,
},
}); });
} catch (rdpErr: unknown) { } catch (rdpErr: unknown) {
const errMsg = const errMsg = rdpErr instanceof Error ? rdpErr.message : typeof rdpErr === "string" ? rdpErr : String(rdpErr);
rdpErr instanceof Error if (errMsg.includes("NO_CREDENTIALS") || errMsg.includes("authentication") || errMsg.includes("logon failure")) {
? rdpErr.message const promptedUsername = prompt(`Username for ${conn.hostname}:`, "Administrator");
: typeof rdpErr === "string"
? rdpErr
: String(rdpErr);
// If credentials are missing or rejected, prompt the operator
if (
errMsg.includes("NO_CREDENTIALS") ||
errMsg.includes("authentication") ||
errMsg.includes("logon failure")
) {
const promptedUsername = prompt(
`Username for ${conn.hostname}:`,
"Administrator",
);
if (!promptedUsername) throw new Error("Connection cancelled"); if (!promptedUsername) throw new Error("Connection cancelled");
const promptedPassword = prompt( const promptedPassword = prompt(`Password for ${promptedUsername}@${conn.hostname}:`);
`Password for ${promptedUsername}@${conn.hostname}:`,
);
if (promptedPassword === null) throw new Error("Connection cancelled"); if (promptedPassword === null) throw new Error("Connection cancelled");
const promptedDomain = prompt(`Domain (leave blank if none):`, "") ?? ""; const promptedDomain = prompt(`Domain (leave blank if none):`, "") ?? "";
username = promptedUsername; username = promptedUsername;
sessionId = await invoke<string>("connect_rdp", { sessionId = await invoke<string>("connect_rdp", {
config: { config: { hostname: conn.hostname, port: conn.port, username: promptedUsername, password: promptedPassword, domain: promptedDomain, width: 1920, height: 1080 },
hostname: conn.hostname,
port: conn.port,
username: promptedUsername,
password: promptedPassword,
domain: promptedDomain,
width: 1920,
height: 1080,
},
}); });
} else { } else {
throw rdpErr; throw rdpErr;
@ -301,20 +284,68 @@ export const useSessionStore = defineStore("session", () => {
active: true, active: true,
username, username,
status: "connected", status: "connected",
hasActivity: false,
}); });
activeSessionId.value = sessionId; activeSessionId.value = sessionId;
} }
/**
* Connect to a server by connection ID.
* Multiple sessions to the same host are allowed (MobaXTerm-style).
* Each gets its own tab with a disambiguated name like "Asgard (2)".
*/
async function connect(connectionId: number): Promise<void> {
const connectionStore = useConnectionStore();
const conn = connectionStore.connections.find((c) => c.id === connectionId);
if (!conn) return;
connecting.value = true;
try {
if (conn.protocol === "ssh") {
await connectSsh(conn, connectionId);
} else if (conn.protocol === "rdp") {
await connectRdp(conn, connectionId);
}
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err); const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
console.error("Connection failed:", msg); console.error("Connection failed:", msg);
lastError.value = msg; lastError.value = msg;
// Show error as native alert so it's visible without DevTools
alert(`Connection failed: ${msg}`); alert(`Connection failed: ${msg}`);
} finally { } finally {
connecting.value = false; connecting.value = false;
} }
} }
/** Spawn a local shell as a full-size tab. */
async function spawnLocalTab(shellName: string, shellPath: string): Promise<void> {
try {
const sessionId = await invoke<string>("spawn_local_shell", {
shellPath,
cols: 120,
rows: 40,
});
sessions.value.push({
id: sessionId,
connectionId: 0,
name: shellName,
protocol: "local",
active: true,
status: "connected",
hasActivity: false,
});
// Listen for PTY close
const unlistenPty = await listen(`pty:close:${sessionId}`, () => markDisconnected(sessionId));
sessionUnlisteners.set(sessionId, [unlistenPty]);
activeSessionId.value = sessionId;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
alert(`Failed to spawn local shell: ${msg}`);
}
}
/** Apply a theme to all active terminal instances. */ /** Apply a theme to all active terminal instances. */
function setTheme(theme: ThemeDefinition): void { function setTheme(theme: ThemeDefinition): void {
activeTheme.value = theme; activeTheme.value = theme;
@ -344,7 +375,9 @@ export const useSessionStore = defineStore("session", () => {
activateSession, activateSession,
closeSession, closeSession,
connect, connect,
spawnLocalTab,
moveSession, moveSession,
markActivity,
setTheme, setTheme,
setTerminalDimensions, setTerminalDimensions,
}; };

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