Compare commits
No commits in common. "main" and "v1.2.2" have entirely different histories.
@ -61,24 +61,13 @@ 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 (with update signing)
|
- name: Build Tauri app
|
||||||
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\*
|
Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
|
||||||
|
|
||||||
- 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
|
||||||
@ -104,10 +93,7 @@ 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")
|
||||||
# Sign NSIS installers + MCP bridge binary
|
$binaries = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
|
||||||
$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
|
||||||
@ -115,83 +101,42 @@ jobs:
|
|||||||
}
|
}
|
||||||
Remove-Item "$env:TEMP\aztoken.txt" -ErrorAction SilentlyContinue
|
Remove-Item "$env:TEMP\aztoken.txt" -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
- name: Upload all artifacts to SeaweedFS
|
- name: Upload to Gitea
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
$ver = ("${{ github.ref_name }}" -replace '^v','')
|
$ver = ("${{ github.ref_name }}" -replace '^v','')
|
||||||
$s3 = "https://files.command.vigilcyber.com/wraith"
|
$giteaUrl = "https://git.command.vigilcyber.com"
|
||||||
|
$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 "$s3/$ver/$($file.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $file.FullName
|
Invoke-RestMethod -Uri "$giteaUrl/api/packages/vstockwell/generic/wraith/$ver/$($file.Name)" -Method PUT -Headers $headers -ContentType "application/octet-stream" -InFile $file.FullName
|
||||||
# Also upload as 'latest' for direct download links
|
|
||||||
Invoke-RestMethod -Uri "$s3/latest/$($file.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $file.FullName
|
Write-Host "Uploading: version.json"
|
||||||
|
Invoke-RestMethod -Uri "$giteaUrl/api/packages/vstockwell/generic/wraith/$ver/version.json" -Method PUT -Headers $headers -ContentType "application/octet-stream" -InFile version.json
|
||||||
}
|
}
|
||||||
|
|
||||||
# Upload MCP bridge binary
|
Write-Host "=== Upload complete ==="
|
||||||
$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
|
|
||||||
}
|
|
||||||
|
|
||||||
# Upload .nsis.zip for Tauri auto-updater
|
- name: Create Release and attach installers
|
||||||
$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','')
|
||||||
$s3 = "https://files.command.vigilcyber.com/wraith"
|
$giteaUrl = "https://git.command.vigilcyber.com"
|
||||||
|
$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)"
|
||||||
|
|
||||||
$sigFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip.sig | Select-Object -First 1
|
$installers = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
|
||||||
$zipFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip | Select-Object -First 1
|
$uploadHeaders = @{ Authorization = "token ${{ secrets.GIT_TOKEN }}" }
|
||||||
|
foreach ($file in $installers) {
|
||||||
if ($sigFile -and $zipFile) {
|
Write-Host "Attaching $($file.Name) to release..."
|
||||||
$signature = Get-Content $sigFile.FullName -Raw
|
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
|
||||||
$downloadUrl = "$s3/$ver/$($zipFile.Name)"
|
Write-Host "Attached: $($file.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
2
.gitignore
vendored
@ -1,7 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
src-tauri/binaries/
|
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.claude/worktrees/
|
|
||||||
|
|||||||
63
AGENTS.md
63
AGENTS.md
@ -1,63 +0,0 @@
|
|||||||
# AGENTS.md — Wraith Desktop v2
|
|
||||||
|
|
||||||
## Agent Roster
|
|
||||||
|
|
||||||
Three tiers. Use the right tool for the job.
|
|
||||||
|
|
||||||
### Architect (Opus)
|
|
||||||
|
|
||||||
**Role:** Strategy, COAs, root cause analysis, architectural decisions.
|
|
||||||
**When to use:** Design questions, complex debugging, trade-off analysis, cross-module planning.
|
|
||||||
**How to use:** Plans, not code. The Architect reasons about the problem and presents options. The Commander decides. Then Specialists execute.
|
|
||||||
|
|
||||||
### Specialist (Sonnet)
|
|
||||||
|
|
||||||
**Role:** Full-stack execution. Writes code, fixes bugs, builds features.
|
|
||||||
**When to use:** Implementation tasks with clear requirements. Feature builds, bug fixes, refactoring, test writing.
|
|
||||||
**How to use:** `subagent_type: general-purpose, model: sonnet`. Give precise briefs with file paths, expected behavior, and acceptance criteria.
|
|
||||||
|
|
||||||
### Scout (Sonnet/Haiku)
|
|
||||||
|
|
||||||
**Role:** Recon, context mapping, read-only exploration.
|
|
||||||
**When to use:** Before any implementation. Understanding code structure, finding patterns, mapping dependencies.
|
|
||||||
**How to use:** `subagent_type: Explore, model: sonnet` for thorough exploration. Haiku for quick file lookups. Scouts NEVER modify files.
|
|
||||||
|
|
||||||
## Dispatch Rules
|
|
||||||
|
|
||||||
- **Simple bug fix (1-2 files):** Do it yourself. Don't burn an agent on a one-liner.
|
|
||||||
- **Feature build (3+ files):** Dispatch a Specialist with a complete brief.
|
|
||||||
- **Unknown territory:** Scout first, then Specialist.
|
|
||||||
- **Architecture decision:** Architect agent OR present COAs to the Commander directly.
|
|
||||||
- **Mechanical bulk work (renaming, formatting, repetitive edits):** Sonnet Specialist. Don't waste Opus on mechanical tasks.
|
|
||||||
- **Security-critical code (vault, crypto, auth):** Opus Architect reviews. Sonnet Specialist implements. Both touch the code.
|
|
||||||
|
|
||||||
## Cross-Project Context
|
|
||||||
|
|
||||||
Wraith is part of the Vigilsynth portfolio alongside:
|
|
||||||
|
|
||||||
- **Vigilance HQ** (`../vigilance-hq`) — MSP operations platform. Vue 3 + Express.js. 1,216+ commits. Production.
|
|
||||||
- **Vigilance Command** (`../vigilance-command-v2`) — Security OS. NestJS + Rust agent. 16 modules. Active development.
|
|
||||||
- **Vigilance Complete** (`../vigilance-complete`) — The merge. HQ + Command unified.
|
|
||||||
|
|
||||||
The Commander manages multiple AI XOs across all repos simultaneously. Context from one repo may inform work in another. When the Commander references HQ patterns, Command architecture, or Vigilsynth strategy, that's cross-project context — use it.
|
|
||||||
|
|
||||||
## Gemini CLI
|
|
||||||
|
|
||||||
Gemini CLI is available in the Commander's environment. Gemini specializes in:
|
|
||||||
- Architecture and protocol design
|
|
||||||
- Library/crate research and evaluation
|
|
||||||
- Deep code audits against specifications
|
|
||||||
- Optimization identification
|
|
||||||
|
|
||||||
The Commander may direct Gemini to work on Wraith alongside or instead of Claude. Both AIs follow the same CLAUDE.md doctrine. The Commander routes tasks to whichever AI is best suited.
|
|
||||||
|
|
||||||
## The Go Reference
|
|
||||||
|
|
||||||
The Go version of Wraith lives at `../wraith-go-archive`. It is the reference implementation:
|
|
||||||
- SSH terminal with xterm.js worked
|
|
||||||
- SFTP sidebar with CWD following worked
|
|
||||||
- Connection manager with groups and search worked
|
|
||||||
- Credential vault with Argon2id encryption worked
|
|
||||||
- Multi-tab sessions worked
|
|
||||||
|
|
||||||
When building features, Scouts should read the Go version first. Specialists should match or exceed Go's capabilities. Don't reinvent what was already solved — port it better.
|
|
||||||
12
CLAUDE.md
12
CLAUDE.md
@ -16,7 +16,7 @@ You are the Wraith XO. The Commander built this from a working Go/Wails v3 proto
|
|||||||
|
|
||||||
**Don't be timid.** The Go version worked. Users connected to servers, transferred files, managed sessions. Your Rust version needs to match that and surpass it. If something is broken, fix it. If something is missing, build it. If you need to make an architectural decision, present COAs — don't ask "should I proceed?"
|
**Don't be timid.** The Go version worked. Users connected to servers, transferred files, managed sessions. Your Rust version needs to match that and surpass it. If something is broken, fix it. If something is missing, build it. If you need to make an architectural decision, present COAs — don't ask "should I proceed?"
|
||||||
|
|
||||||
**The Go version is your reference implementation.** It lives at `../wraith-go-archive`. The SSH terminal worked. The SFTP sidebar worked. The connection manager worked. The vault worked. When in doubt about what a feature should do, read the Go code. It's the spec that ran in production.
|
**The Go version is your reference implementation.** It lives at `../wraith`. The SSH terminal worked. The SFTP sidebar worked. The connection manager worked. The vault worked. When in doubt about what a feature should do, read the Go code. It's the spec that ran in production.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ npm install # Install frontend deps
|
|||||||
npm run dev # Vite dev server only
|
npm run dev # Vite dev server only
|
||||||
cargo tauri dev # Full app (Rust + frontend)
|
cargo tauri dev # Full app (Rust + frontend)
|
||||||
cargo tauri build # Production build
|
cargo tauri build # Production build
|
||||||
cd src-tauri && cargo test # Run Rust tests (95 tests)
|
cd src-tauri && cargo test # Run Rust tests (82 tests)
|
||||||
cd src-tauri && cargo build # Build Rust only
|
cd src-tauri && cargo build # Build Rust only
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -76,7 +76,7 @@ cd src-tauri && cargo build # Build Rust only
|
|||||||
|
|
||||||
## V4_WORKFLOW — Standard Operating Procedure
|
## V4_WORKFLOW — Standard Operating Procedure
|
||||||
|
|
||||||
**Phase 1: RECON** — Read all relevant files before proposing changes. Understand patterns, dependencies, blast radius. When touching Rust, check the Go version at `../wraith-go-archive` for how it was done before.
|
**Phase 1: RECON** — Read all relevant files before proposing changes. Understand patterns, dependencies, blast radius. When touching Rust, check the Go version at `../wraith` for how it was done before.
|
||||||
|
|
||||||
**Phase 2: PLAN** — Present approach for approval. **Never make executive decisions autonomously** — surface trade-offs as COAs (Courses of Action).
|
**Phase 2: PLAN** — Present approach for approval. **Never make executive decisions autonomously** — surface trade-offs as COAs (Courses of Action).
|
||||||
|
|
||||||
@ -92,7 +92,7 @@ cd src-tauri && cargo build # Build Rust only
|
|||||||
- **Don't ask "should I proceed?" when the answer is obviously yes.** Read the room. If the Commander gave you a task, execute it.
|
- **Don't ask "should I proceed?" when the answer is obviously yes.** Read the room. If the Commander gave you a task, execute it.
|
||||||
- **If something is broken, fix it.** Don't document it and move on. Fix it.
|
- **If something is broken, fix it.** Don't document it and move on. Fix it.
|
||||||
- **Tauri v2 ACL is mandatory.** Every new Tauri command or event MUST be added to `capabilities/default.json` or it will silently fail.
|
- **Tauri v2 ACL is mandatory.** Every new Tauri command or event MUST be added to `capabilities/default.json` or it will silently fail.
|
||||||
- **Check the Go version first.** Before building any feature, read how `../wraith-go-archive` did it. Don't reinvent what was already solved.
|
- **Check the Go version first.** Before building any feature, read how `../wraith` did it. Don't reinvent what was already solved.
|
||||||
|
|
||||||
## Key Design Decisions
|
## Key Design Decisions
|
||||||
|
|
||||||
@ -106,7 +106,7 @@ cd src-tauri && cargo build # Build Rust only
|
|||||||
|
|
||||||
1. **Tauri v2 capabilities are not optional.** The blank screen bug that stumped the first XO was a missing `"url": "index.html"` in `tauri.conf.json` and an empty `capabilities/` directory. Tauri v2's security model blocks ALL frontend event listeners and IPC calls without explicit permissions. Every new feature that uses `emit()`, `listen()`, or `invoke()` must have a corresponding entry in `capabilities/default.json`. If the frontend silently does nothing, check capabilities first.
|
1. **Tauri v2 capabilities are not optional.** The blank screen bug that stumped the first XO was a missing `"url": "index.html"` in `tauri.conf.json` and an empty `capabilities/` directory. Tauri v2's security model blocks ALL frontend event listeners and IPC calls without explicit permissions. Every new feature that uses `emit()`, `listen()`, or `invoke()` must have a corresponding entry in `capabilities/default.json`. If the frontend silently does nothing, check capabilities first.
|
||||||
|
|
||||||
2. **The Go version is the spec.** When in doubt about what a feature should do, how it should behave, or what edge cases to handle — read the Go code at `../wraith-go-archive`. It ran. Users used it. The terminal worked, the SFTP worked, the vault worked. Don't guess. Read.
|
2. **The Go version is the spec.** When in doubt about what a feature should do, how it should behave, or what edge cases to handle — read the Go code at `../wraith`. It ran. Users used it. The terminal worked, the SFTP worked, the vault worked. Don't guess. Read.
|
||||||
|
|
||||||
3. **Rust backend command names must match frontend invoke names exactly.** If the frontend calls `invoke('disconnect_ssh')` but the backend exports `disconnect_session`, nothing happens. No error. Silent failure. When adding Tauri commands, grep the frontend for the exact invoke string.
|
3. **Rust backend command names must match frontend invoke names exactly.** If the frontend calls `invoke('disconnect_ssh')` but the backend exports `disconnect_session`, nothing happens. No error. Silent failure. When adding Tauri commands, grep the frontend for the exact invoke string.
|
||||||
|
|
||||||
@ -116,7 +116,7 @@ cd src-tauri && cargo build # Build Rust only
|
|||||||
|
|
||||||
## Lineage
|
## Lineage
|
||||||
|
|
||||||
This is a ground-up Rust rewrite of `wraith` (Go/Wails v3). The Go version is at `../wraith-go-archive`. The original design spec is at `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` in the Go repo. The enterprise feature roadmap is at `../wraith-go-archive/docs/FUTURE-FEATURES.md`.
|
This is a ground-up Rust rewrite of `wraith` (Go/Wails v3). The Go version is at `../wraith`. The original design spec is at `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` in the Go repo. The enterprise feature roadmap is at `../wraith/docs/FUTURE-FEATURES.md`.
|
||||||
|
|
||||||
## Future Vision
|
## Future Vision
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
@ -1,340 +0,0 @@
|
|||||||
# Wraith Terminal MCP — Design Specification
|
|
||||||
|
|
||||||
**Date:** March 25, 2026
|
|
||||||
**Status:** Draft
|
|
||||||
**Author:** Gargoyle (HQ XO)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Problem
|
|
||||||
|
|
||||||
The AI copilot panel in Wraith runs CLI tools (Claude, Gemini, Codex) in a local PTY. The AI can chat with the user, but it cannot independently interact with active SSH/RDP sessions. The technician has to manually copy-paste terminal output into the AI and relay commands back.
|
|
||||||
|
|
||||||
The goal: let the AI **drive** the terminal. Read output. Execute commands. Take screenshots. React to errors. All through a standardized protocol.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Solution: Wraith Terminal MCP Server
|
|
||||||
|
|
||||||
Implement an MCP (Model Context Protocol) server inside Wraith's Rust backend that exposes active sessions as tools and resources. The AI CLI running in the copilot panel connects to this MCP server and gains programmatic access to every open session.
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
AI CLI (claude/gemini)
|
|
||||||
|
|
|
||||||
+-- MCP Client (built into the CLI)
|
|
||||||
|
|
|
||||||
+-- connects to localhost:PORT or Unix socket
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Wraith MCP Server (Rust, runs inside Tauri)
|
|
||||||
|
|
|
||||||
+-- Tool: terminal_execute(session_id, command)
|
|
||||||
+-- Tool: terminal_read(session_id, lines?)
|
|
||||||
+-- Tool: terminal_screenshot(session_id) [RDP only]
|
|
||||||
+-- Tool: sftp_list(session_id, path)
|
|
||||||
+-- Tool: sftp_read(session_id, path)
|
|
||||||
+-- Tool: sftp_write(session_id, path, content)
|
|
||||||
+-- Resource: sessions://list
|
|
||||||
+-- Resource: sessions://{id}/info
|
|
||||||
+-- Resource: sessions://{id}/scrollback
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. MCP Server Implementation
|
|
||||||
|
|
||||||
### 3.1 Transport
|
|
||||||
|
|
||||||
Two options for how the AI CLI connects to the MCP server:
|
|
||||||
|
|
||||||
**Option A: stdio (Recommended for v1)**
|
|
||||||
- The copilot panel spawns the AI CLI with `--mcp-server` flag pointing to a Wraith helper binary
|
|
||||||
- The helper binary communicates with Wraith's Tauri backend via Tauri commands
|
|
||||||
- Simple, no port management, no firewall issues
|
|
||||||
- Pattern: AI CLI → stdio → wraith-mcp-bridge → Tauri invoke → session data
|
|
||||||
|
|
||||||
**Option B: HTTP/SSE (Future)**
|
|
||||||
- Wraith runs an HTTP server on localhost:random-port
|
|
||||||
- AI CLI connects via `--mcp-server http://localhost:PORT`
|
|
||||||
- More flexible (multiple AI CLIs can connect), but requires port management
|
|
||||||
- Pattern: AI CLI → HTTP → Wraith MCP HTTP handler → session data
|
|
||||||
|
|
||||||
### 3.2 Rust Implementation
|
|
||||||
|
|
||||||
```
|
|
||||||
src-tauri/src/mcp/
|
|
||||||
mod.rs — MCP server lifecycle, transport handling
|
|
||||||
tools.rs — Tool definitions (terminal_execute, screenshot, etc.)
|
|
||||||
resources.rs — Resource definitions (session list, scrollback)
|
|
||||||
bridge.rs — Bridge between MCP protocol and existing services
|
|
||||||
```
|
|
||||||
|
|
||||||
The MCP server reuses existing services:
|
|
||||||
- `SshService` — for terminal_execute, terminal_read on SSH sessions
|
|
||||||
- `RdpService` — for terminal_screenshot on RDP sessions
|
|
||||||
- `SftpService` — for sftp_list, sftp_read, sftp_write
|
|
||||||
- `PtyService` — for local shell access
|
|
||||||
- `SessionStore` (DashMap) — for session enumeration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Tools
|
|
||||||
|
|
||||||
### 4.1 terminal_execute
|
|
||||||
|
|
||||||
Execute a command in an active SSH or local PTY session and return the output.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "terminal_execute",
|
|
||||||
"description": "Execute a command in a terminal session and return output",
|
|
||||||
"parameters": {
|
|
||||||
"session_id": "string — the active session ID",
|
|
||||||
"command": "string — the command to run (newline appended automatically)",
|
|
||||||
"timeout_ms": "number — max wait for output (default: 5000)"
|
|
||||||
},
|
|
||||||
"returns": "string — captured terminal output after command execution"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation:** Write command + `\n` to the session's writer. Start capturing output from the session's reader. Wait for a shell prompt pattern or timeout. Return captured bytes as UTF-8 string.
|
|
||||||
|
|
||||||
**Challenge:** Detecting when command output is "done" — shell prompt detection is fragile. Options:
|
|
||||||
- **Marker approach:** Send `echo __WRAITH_DONE__` after the command, capture until marker appears
|
|
||||||
- **Timeout approach:** Wait N ms after last output byte, assume done
|
|
||||||
- **Prompt regex:** Configurable prompt pattern (default: `$ `, `# `, `> `, `PS>`)
|
|
||||||
|
|
||||||
Recommend: marker approach for SSH, timeout approach for PTY (since local shells have predictable prompt timing).
|
|
||||||
|
|
||||||
### 4.2 terminal_read
|
|
||||||
|
|
||||||
Read the current scrollback or recent output from a session without executing anything.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "terminal_read",
|
|
||||||
"description": "Read recent terminal output from a session",
|
|
||||||
"parameters": {
|
|
||||||
"session_id": "string",
|
|
||||||
"lines": "number — last N lines (default: 50)"
|
|
||||||
},
|
|
||||||
"returns": "string — terminal scrollback content (ANSI stripped)"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation:** Maintain a circular buffer of recent output per session (last 10KB). On read, return the last N lines with ANSI escape codes stripped.
|
|
||||||
|
|
||||||
**Note:** The buffer exists in the Rust backend, not xterm.js. The AI doesn't need to scrape the DOM — it reads from the same data stream that feeds the terminal.
|
|
||||||
|
|
||||||
### 4.3 terminal_screenshot
|
|
||||||
|
|
||||||
Capture the current frame of an RDP session as a base64-encoded PNG.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "terminal_screenshot",
|
|
||||||
"description": "Capture a screenshot of an RDP session",
|
|
||||||
"parameters": {
|
|
||||||
"session_id": "string — must be an RDP session"
|
|
||||||
},
|
|
||||||
"returns": "string — base64-encoded PNG image"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation:** The RDP frame buffer is already maintained by `RdpService`. Encode the current frame as PNG (using the `image` crate), base64 encode, return. The AI CLI passes this to the multimodal AI provider for visual analysis.
|
|
||||||
|
|
||||||
**Use case:** "Screenshot the error on screen. What can you tell me about it?"
|
|
||||||
|
|
||||||
### 4.4 sftp_list
|
|
||||||
|
|
||||||
List files in a directory on the remote host via the session's SFTP channel.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "sftp_list",
|
|
||||||
"description": "List files in a remote directory",
|
|
||||||
"parameters": {
|
|
||||||
"session_id": "string",
|
|
||||||
"path": "string — remote directory path"
|
|
||||||
},
|
|
||||||
"returns": "array of { name, size, modified, is_dir }"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.5 sftp_read
|
|
||||||
|
|
||||||
Read a file from the remote host.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "sftp_read",
|
|
||||||
"description": "Read a file from the remote host",
|
|
||||||
"parameters": {
|
|
||||||
"session_id": "string",
|
|
||||||
"path": "string — remote file path",
|
|
||||||
"max_bytes": "number — limit (default: 1MB)"
|
|
||||||
},
|
|
||||||
"returns": "string — file content (UTF-8) or base64 for binary"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.6 sftp_write
|
|
||||||
|
|
||||||
Write a file to the remote host.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "sftp_write",
|
|
||||||
"description": "Write content to a file on the remote host",
|
|
||||||
"parameters": {
|
|
||||||
"session_id": "string",
|
|
||||||
"path": "string — remote file path",
|
|
||||||
"content": "string — file content"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Resources
|
|
||||||
|
|
||||||
### 5.1 sessions://list
|
|
||||||
|
|
||||||
Returns all active sessions with their type, connection info, and status.
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "ssh-abc123",
|
|
||||||
"type": "ssh",
|
|
||||||
"name": "prod-web-01",
|
|
||||||
"host": "10.0.1.50",
|
|
||||||
"username": "admin",
|
|
||||||
"status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "rdp-def456",
|
|
||||||
"type": "rdp",
|
|
||||||
"name": "dc-01",
|
|
||||||
"host": "10.0.1.10",
|
|
||||||
"status": "connected"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 sessions://{id}/info
|
|
||||||
|
|
||||||
Detailed info about a specific session — connection parameters, uptime, bytes transferred.
|
|
||||||
|
|
||||||
### 5.3 sessions://{id}/scrollback
|
|
||||||
|
|
||||||
Full scrollback buffer for a terminal session (last 10KB, ANSI stripped).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Security
|
|
||||||
|
|
||||||
- **MCP server only binds to localhost** — no remote access, no network exposure
|
|
||||||
- **Session access inherits Wraith's auth** — if the user is logged into Wraith, the MCP server trusts the connection
|
|
||||||
- **No credential exposure** — the MCP tools execute commands through existing authenticated sessions. The AI never sees passwords or SSH keys.
|
|
||||||
- **Audit trail** — every MCP tool invocation logged with timestamp, session ID, command, and result size
|
|
||||||
- **Read-only option** — sessions can be marked read-only in connection settings, preventing terminal_execute and sftp_write
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. AI CLI Integration
|
|
||||||
|
|
||||||
### 7.1 Claude Code
|
|
||||||
|
|
||||||
Claude Code already supports MCP servers via `--mcp-server` flag or `.claude/settings.json`. Configuration:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"wraith": {
|
|
||||||
"command": "wraith-mcp-bridge",
|
|
||||||
"args": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `wraith-mcp-bridge` is a small binary that Wraith ships alongside the main app. It communicates with the running Wraith instance via Tauri's IPC.
|
|
||||||
|
|
||||||
### 7.2 Gemini CLI
|
|
||||||
|
|
||||||
Gemini CLI supports MCP servers similarly. Same bridge binary, same configuration pattern.
|
|
||||||
|
|
||||||
### 7.3 Auto-Configuration
|
|
||||||
|
|
||||||
When the copilot panel launches an AI CLI, Wraith can auto-inject the MCP server configuration via environment variables or command-line flags, so the user doesn't have to manually configure anything.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// When spawning the AI CLI in the PTY:
|
|
||||||
let mut cmd = CommandBuilder::new(shell_path);
|
|
||||||
cmd.env("CLAUDE_MCP_SERVERS", r#"{"wraith":{"command":"wraith-mcp-bridge"}}"#);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Data Flow Example
|
|
||||||
|
|
||||||
**User says to Claude in copilot panel:** "Check disk space on the server I'm connected to"
|
|
||||||
|
|
||||||
1. Claude's MCP client calls `sessions://list` → gets `[{id: "ssh-abc", name: "prod-web-01", ...}]`
|
|
||||||
2. Claude calls `terminal_execute(session_id: "ssh-abc", command: "df -h")`
|
|
||||||
3. Wraith MCP bridge → Tauri invoke → SshService.write("ssh-abc", "df -h\n")
|
|
||||||
4. Wraith captures output until prompt marker
|
|
||||||
5. Returns: `/dev/sda1 50G 45G 5G 90% /`
|
|
||||||
6. Claude analyzes: "Your root partition is at 90%. You should clean up /var/log or expand the disk."
|
|
||||||
|
|
||||||
**User says:** "Screenshot the RDP session, what's that error?"
|
|
||||||
|
|
||||||
1. Claude calls `terminal_screenshot(session_id: "rdp-def")`
|
|
||||||
2. Wraith MCP bridge → RdpService.get_frame("rdp-def") → PNG encode → base64
|
|
||||||
3. Returns 200KB base64 PNG
|
|
||||||
4. Claude (multimodal) analyzes the image: "That's a Windows Event Viewer showing Event ID 1001 — application crash in outlook.exe. The faulting module is mso.dll. This is a known Office corruption issue. Run `sfc /scannow` or repair Office from Control Panel."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Implementation Phases
|
|
||||||
|
|
||||||
### Phase 1: Bridge + Basic Tools (MVP)
|
|
||||||
- `wraith-mcp-bridge` binary (stdio transport)
|
|
||||||
- `terminal_execute` tool (marker-based output capture)
|
|
||||||
- `terminal_read` tool (scrollback buffer)
|
|
||||||
- `sessions://list` resource
|
|
||||||
- Auto-configuration when spawning AI CLI
|
|
||||||
|
|
||||||
### Phase 2: SFTP + Screenshot
|
|
||||||
- `sftp_list`, `sftp_read`, `sftp_write` tools
|
|
||||||
- `terminal_screenshot` tool (RDP frame capture)
|
|
||||||
- Session info resource
|
|
||||||
|
|
||||||
### Phase 3: Advanced
|
|
||||||
- HTTP/SSE transport for multi-client access
|
|
||||||
- Read-only session enforcement
|
|
||||||
- Audit trail logging
|
|
||||||
- AI-initiated session creation ("Connect me to prod-web-01")
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Dependencies
|
|
||||||
|
|
||||||
| Component | Crate/Tool | License |
|
|
||||||
|---|---|---|
|
|
||||||
| MCP protocol | Custom implementation (JSON-RPC over stdio) | Proprietary |
|
|
||||||
| PNG encoding | `image` crate | MIT/Apache-2.0 |
|
|
||||||
| Base64 | `base64` crate (already in deps) | MIT/Apache-2.0 |
|
|
||||||
| ANSI stripping | `strip-ansi-escapes` crate | MIT/Apache-2.0 |
|
|
||||||
| Bridge binary | Rust, ships alongside Wraith | Proprietary |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Black Binder Note
|
|
||||||
|
|
||||||
An MCP server embedded in a remote access client that gives AI tools programmatic access to live SSH, RDP, and SFTP sessions is, to the company's knowledge, a novel integration. No competing SSH/RDP client ships with an MCP server that allows AI assistants to interact with active remote sessions.
|
|
||||||
|
|
||||||
The combination of terminal command execution, RDP screenshot analysis, and SFTP file operations through a standardized AI tool protocol represents a new category of AI-augmented remote access.
|
|
||||||
@ -1,780 +0,0 @@
|
|||||||
# Local PTY Copilot Panel — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Replace the Gemini API stub with a local PTY terminal in the sidebar where users run CLI tools (claude, gemini, codex) directly.
|
|
||||||
|
|
||||||
**Architecture:** New `PtyService` module mirrors `SshService` patterns — DashMap session registry, `portable-pty` for cross-platform PTY spawn, `spawn_blocking` output loop emitting Tauri events. Frontend reuses existing `useTerminal` composable with a new `backend` parameter. Gemini stub deleted entirely.
|
|
||||||
|
|
||||||
**Tech Stack:** portable-pty (Rust PTY), xterm.js (existing), Tauri v2 events (existing)
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-03-24-local-pty-copilot-design.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Add portable-pty dependency
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src-tauri/Cargo.toml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add portable-pty to Cargo.toml**
|
|
||||||
|
|
||||||
Add under the existing dependencies:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
portable-pty = "0.8"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify it resolves**
|
|
||||||
|
|
||||||
Run: `cd src-tauri && cargo check`
|
|
||||||
Expected: compiles with no errors
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src-tauri/Cargo.toml src-tauri/Cargo.lock
|
|
||||||
git commit -m "deps: add portable-pty for local PTY support"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Create PtyService backend module
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src-tauri/src/pty/mod.rs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the pty module with PtyService, PtySession, ShellInfo, list_shells**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
//! Local PTY service — spawns shells for the AI copilot panel.
|
|
||||||
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use base64::Engine;
|
|
||||||
use dashmap::DashMap;
|
|
||||||
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
|
||||||
use serde::Serialize;
|
|
||||||
use tauri::{AppHandle, Emitter};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
|
||||||
pub struct ShellInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PtySession {
|
|
||||||
pub id: String,
|
|
||||||
pub shell_path: String,
|
|
||||||
writer: Mutex<Box<dyn Write + Send>>,
|
|
||||||
master: Mutex<Box<dyn MasterPty + Send>>,
|
|
||||||
child: Mutex<Box<dyn Child + Send + Sync>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PtyService {
|
|
||||||
sessions: DashMap<String, Arc<PtySession>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PtyService {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self { sessions: DashMap::new() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect available shells on the system.
|
|
||||||
pub fn list_shells(&self) -> Vec<ShellInfo> {
|
|
||||||
let mut shells = Vec::new();
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
// Check $SHELL first (user's default)
|
|
||||||
if let Ok(user_shell) = std::env::var("SHELL") {
|
|
||||||
if std::path::Path::new(&user_shell).exists() {
|
|
||||||
let name = std::path::Path::new(&user_shell)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or("shell")
|
|
||||||
.to_string();
|
|
||||||
shells.push(ShellInfo { name, path: user_shell });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (name, path) in [("bash", "/bin/bash"), ("zsh", "/bin/zsh"), ("sh", "/bin/sh")] {
|
|
||||||
if std::path::Path::new(path).exists() && !shells.iter().any(|s| s.path == path) {
|
|
||||||
shells.push(ShellInfo { name: name.to_string(), path: path.to_string() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
shells.push(ShellInfo { name: "PowerShell".to_string(), path: "powershell.exe".to_string() });
|
|
||||||
shells.push(ShellInfo { name: "CMD".to_string(), path: "cmd.exe".to_string() });
|
|
||||||
for git_bash in [
|
|
||||||
r"C:\Program Files\Git\bin\bash.exe",
|
|
||||||
r"C:\Program Files (x86)\Git\bin\bash.exe",
|
|
||||||
] {
|
|
||||||
if std::path::Path::new(git_bash).exists() {
|
|
||||||
shells.push(ShellInfo { name: "Git Bash".to_string(), path: git_bash.to_string() });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
shells
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn a local shell and start reading its output.
|
|
||||||
pub fn spawn(
|
|
||||||
&self,
|
|
||||||
shell_path: &str,
|
|
||||||
cols: u16,
|
|
||||||
rows: u16,
|
|
||||||
app_handle: AppHandle,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
|
||||||
let pty_system = native_pty_system();
|
|
||||||
|
|
||||||
let pair = pty_system
|
|
||||||
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
|
||||||
.map_err(|e| format!("Failed to open PTY: {}", e))?;
|
|
||||||
|
|
||||||
let mut cmd = CommandBuilder::new(shell_path);
|
|
||||||
// Inherit parent environment so PATH includes CLI tools
|
|
||||||
// CommandBuilder inherits env by default — no action needed
|
|
||||||
|
|
||||||
let child = pair.slave
|
|
||||||
.spawn_command(cmd)
|
|
||||||
.map_err(|e| format!("Failed to spawn shell '{}': {}", shell_path, e))?;
|
|
||||||
|
|
||||||
let reader = pair.master
|
|
||||||
.try_clone_reader()
|
|
||||||
.map_err(|e| format!("Failed to clone PTY reader: {}", e))?;
|
|
||||||
|
|
||||||
let writer = pair.master
|
|
||||||
.take_writer()
|
|
||||||
.map_err(|e| format!("Failed to take PTY writer: {}", e))?;
|
|
||||||
|
|
||||||
let session = Arc::new(PtySession {
|
|
||||||
id: session_id.clone(),
|
|
||||||
shell_path: shell_path.to_string(),
|
|
||||||
writer: Mutex::new(writer),
|
|
||||||
master: Mutex::new(pair.master),
|
|
||||||
child: Mutex::new(child),
|
|
||||||
});
|
|
||||||
|
|
||||||
self.sessions.insert(session_id.clone(), session);
|
|
||||||
|
|
||||||
// Output reader loop — runs in a blocking thread because
|
|
||||||
// portable-pty's reader is synchronous (std::io::Read).
|
|
||||||
let sid = session_id.clone();
|
|
||||||
let app = app_handle;
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let mut reader = std::io::BufReader::new(reader);
|
|
||||||
let mut buf = [0u8; 4096];
|
|
||||||
loop {
|
|
||||||
match reader.read(&mut buf) {
|
|
||||||
Ok(0) => {
|
|
||||||
let _ = app.emit(&format!("pty:close:{}", sid), ());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Ok(n) => {
|
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&buf[..n]);
|
|
||||||
let _ = app.emit(&format!("pty:data:{}", sid), encoded);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
let _ = app.emit(&format!("pty:close:{}", sid), ());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(session_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write data to a PTY session's stdin.
|
|
||||||
pub fn write(&self, session_id: &str, data: &[u8]) -> Result<(), String> {
|
|
||||||
let session = self.sessions.get(session_id)
|
|
||||||
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
|
|
||||||
let mut writer = session.writer.lock()
|
|
||||||
.map_err(|e| format!("Failed to lock PTY writer: {}", e))?;
|
|
||||||
writer.write_all(data)
|
|
||||||
.map_err(|e| format!("Failed to write to PTY {}: {}", session_id, e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resize a PTY session.
|
|
||||||
pub fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
|
||||||
let session = self.sessions.get(session_id)
|
|
||||||
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
|
|
||||||
let master = session.master.lock()
|
|
||||||
.map_err(|e| format!("Failed to lock PTY master: {}", e))?;
|
|
||||||
master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
|
||||||
.map_err(|e| format!("Failed to resize PTY {}: {}", session_id, e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Kill and remove a PTY session.
|
|
||||||
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
|
|
||||||
let (_, session) = self.sessions.remove(session_id)
|
|
||||||
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
|
|
||||||
if let Ok(mut child) = session.child.lock() {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify it compiles**
|
|
||||||
|
|
||||||
Add `pub mod pty;` to `src-tauri/src/lib.rs` temporarily (just the module declaration, full AppState wiring comes in Task 4).
|
|
||||||
|
|
||||||
Run: `cd src-tauri && cargo check`
|
|
||||||
Expected: compiles (warnings about unused code are fine here)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src-tauri/src/pty/mod.rs src-tauri/src/lib.rs
|
|
||||||
git commit -m "feat: PtyService — local PTY spawn, write, resize, disconnect"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Create PTY Tauri commands
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src-tauri/src/commands/pty_commands.rs`
|
|
||||||
- Modify: `src-tauri/src/commands/mod.rs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create pty_commands.rs**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
//! Tauri commands for local PTY session management.
|
|
||||||
|
|
||||||
use tauri::{AppHandle, State};
|
|
||||||
|
|
||||||
use crate::pty::ShellInfo;
|
|
||||||
use crate::AppState;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_available_shells(state: State<'_, AppState>) -> Vec<ShellInfo> {
|
|
||||||
state.pty.list_shells()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn spawn_local_shell(
|
|
||||||
shell_path: String,
|
|
||||||
cols: u32,
|
|
||||||
rows: u32,
|
|
||||||
app_handle: AppHandle,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
state.pty.spawn(&shell_path, cols as u16, rows as u16, app_handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn pty_write(
|
|
||||||
session_id: String,
|
|
||||||
data: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.pty.write(&session_id, data.as_bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn pty_resize(
|
|
||||||
session_id: String,
|
|
||||||
cols: u32,
|
|
||||||
rows: u32,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.pty.resize(&session_id, cols as u16, rows as u16)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn disconnect_pty(
|
|
||||||
session_id: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.pty.disconnect(&session_id)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `pub mod pty_commands;` to `src-tauri/src/commands/mod.rs`**
|
|
||||||
|
|
||||||
Replace the `ai_commands` line:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub mod vault;
|
|
||||||
pub mod settings;
|
|
||||||
pub mod connections;
|
|
||||||
pub mod credentials;
|
|
||||||
pub mod ssh_commands;
|
|
||||||
pub mod sftp_commands;
|
|
||||||
pub mod rdp_commands;
|
|
||||||
pub mod theme_commands;
|
|
||||||
pub mod pty_commands;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src-tauri/src/commands/pty_commands.rs src-tauri/src/commands/mod.rs
|
|
||||||
git commit -m "feat: PTY Tauri commands — spawn, write, resize, disconnect, list shells"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Wire PtyService into AppState and delete Gemini stub
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src-tauri/src/lib.rs`
|
|
||||||
- Delete: `src-tauri/src/ai/mod.rs`
|
|
||||||
- Delete: `src-tauri/src/commands/ai_commands.rs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update lib.rs**
|
|
||||||
|
|
||||||
Full replacement of `lib.rs`:
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
1. Replace `pub mod ai;` with `pub mod pty;`
|
|
||||||
2. Replace `use` for ai with `use pty::PtyService;`
|
|
||||||
3. Replace `gemini: Mutex<Option<ai::GeminiClient>>` with `pub pty: PtyService`
|
|
||||||
4. Replace `gemini: Mutex::new(None)` with `pty: PtyService::new()`
|
|
||||||
5. Replace AI command registrations with PTY command registrations in `generate_handler!`
|
|
||||||
|
|
||||||
The `generate_handler!` line 110 should change from:
|
|
||||||
```
|
|
||||||
commands::ai_commands::set_gemini_auth, commands::ai_commands::gemini_chat, commands::ai_commands::is_gemini_authenticated,
|
|
||||||
```
|
|
||||||
to:
|
|
||||||
```
|
|
||||||
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,
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Delete Gemini files**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm src-tauri/src/ai/mod.rs
|
|
||||||
rmdir src-tauri/src/ai
|
|
||||||
rm src-tauri/src/commands/ai_commands.rs
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify build**
|
|
||||||
|
|
||||||
Run: `cd src-tauri && cargo build`
|
|
||||||
Expected: compiles with zero warnings
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests**
|
|
||||||
|
|
||||||
Run: `cd src-tauri && cargo test`
|
|
||||||
Expected: 82 tests pass (existing tests unaffected)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "refactor: replace Gemini stub with PtyService in AppState"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Parameterize useTerminal composable
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/composables/useTerminal.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add backend parameter**
|
|
||||||
|
|
||||||
Change the function signature from:
|
|
||||||
```typescript
|
|
||||||
export function useTerminal(sessionId: string): UseTerminalReturn {
|
|
||||||
```
|
|
||||||
to:
|
|
||||||
```typescript
|
|
||||||
export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'): UseTerminalReturn {
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Derive command/event names from backend**
|
|
||||||
|
|
||||||
Add at the top of the function body (after the addons, before the Terminal constructor):
|
|
||||||
```typescript
|
|
||||||
const writeCmd = backend === 'ssh' ? 'ssh_write' : 'pty_write';
|
|
||||||
const resizeCmd = backend === 'ssh' ? 'ssh_resize' : 'pty_resize';
|
|
||||||
const dataEvent = backend === 'ssh' ? `ssh:data:${sessionId}` : `pty:data:${sessionId}`;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Set convertEol based on backend**
|
|
||||||
|
|
||||||
In the Terminal constructor options, change:
|
|
||||||
```typescript
|
|
||||||
convertEol: true,
|
|
||||||
```
|
|
||||||
to:
|
|
||||||
```typescript
|
|
||||||
convertEol: backend === 'ssh',
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Replace hardcoded command names**
|
|
||||||
|
|
||||||
Replace all `invoke("ssh_write"` with `invoke(writeCmd` (3 occurrences: onData handler, right-click paste handler).
|
|
||||||
|
|
||||||
Replace `invoke("ssh_resize"` with `invoke(resizeCmd` (1 occurrence: onResize handler).
|
|
||||||
|
|
||||||
Replace `` `ssh:data:${sessionId}` `` with `dataEvent` (1 occurrence: listen call in mount).
|
|
||||||
|
|
||||||
Replace error log strings: `"SSH write error:"` → `"Write error:"`, `"SSH resize error:"` → `"Resize error:"`.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Verify existing SSH path still works**
|
|
||||||
|
|
||||||
Run: `npx vue-tsc --noEmit` — should compile clean. Existing callers pass no second argument, so they default to `'ssh'`.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/composables/useTerminal.ts
|
|
||||||
git commit -m "refactor: parameterize useTerminal for ssh/pty backends"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: Create CopilotPanel.vue
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/components/ai/CopilotPanel.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the component**
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] w-80">
|
|
||||||
<!-- Header -->
|
|
||||||
<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>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<select
|
|
||||||
v-model="selectedShell"
|
|
||||||
class="bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-1.5 py-0.5 text-[10px] text-[var(--wraith-text-secondary)] outline-none"
|
|
||||||
:disabled="connected"
|
|
||||||
>
|
|
||||||
<option v-for="shell in shells" :key="shell.path" :value="shell.path">
|
|
||||||
{{ shell.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
v-if="!connected"
|
|
||||||
class="px-2 py-0.5 text-[10px] font-bold rounded bg-[var(--wraith-accent-blue)] text-black cursor-pointer"
|
|
||||||
:disabled="!selectedShell"
|
|
||||||
@click="launch"
|
|
||||||
>
|
|
||||||
Launch
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="px-2 py-0.5 text-[10px] font-bold rounded bg-[var(--wraith-accent-red,#f85149)] text-white cursor-pointer"
|
|
||||||
@click="kill"
|
|
||||||
>
|
|
||||||
Kill
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Terminal area -->
|
|
||||||
<div v-if="connected" ref="containerRef" class="flex-1 min-h-0" />
|
|
||||||
|
|
||||||
<!-- Session ended prompt -->
|
|
||||||
<div v-else-if="sessionEnded" class="flex-1 flex flex-col items-center justify-center gap-3 p-4">
|
|
||||||
<p class="text-xs text-[var(--wraith-text-muted)]">Session ended</p>
|
|
||||||
<button
|
|
||||||
class="px-3 py-1.5 text-xs rounded bg-[var(--wraith-accent-blue)] text-black font-bold cursor-pointer"
|
|
||||||
@click="launch"
|
|
||||||
>
|
|
||||||
Relaunch
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
<div v-else class="flex-1 flex flex-col items-center justify-center gap-2 p-4">
|
|
||||||
<p class="text-xs text-[var(--wraith-text-muted)] text-center">
|
|
||||||
Select a shell and click Launch to start a local terminal.
|
|
||||||
</p>
|
|
||||||
<p class="text-[10px] text-[var(--wraith-text-muted)] text-center">
|
|
||||||
Run <code class="text-[var(--wraith-accent-blue)]">claude</code>,
|
|
||||||
<code class="text-[var(--wraith-accent-blue)]">gemini</code>, or
|
|
||||||
<code class="text-[var(--wraith-accent-blue)]">codex</code> here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, nextTick, onMounted, onBeforeUnmount } from "vue";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
|
||||||
import { useTerminal } from "@/composables/useTerminal";
|
|
||||||
|
|
||||||
interface ShellInfo { name: string; path: string; }
|
|
||||||
|
|
||||||
const shells = ref<ShellInfo[]>([]);
|
|
||||||
const selectedShell = ref("");
|
|
||||||
const connected = ref(false);
|
|
||||||
const sessionEnded = ref(false);
|
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
let sessionId = "";
|
|
||||||
let terminalInstance: ReturnType<typeof useTerminal> | null = null;
|
|
||||||
let closeUnlisten: UnlistenFn | null = null;
|
|
||||||
|
|
||||||
async function loadShells(): Promise<void> {
|
|
||||||
try {
|
|
||||||
shells.value = await invoke<ShellInfo[]>("list_available_shells");
|
|
||||||
if (shells.value.length > 0 && !selectedShell.value) {
|
|
||||||
selectedShell.value = shells.value[0].path;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to list shells:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launch(): Promise<void> {
|
|
||||||
if (!selectedShell.value) return;
|
|
||||||
sessionEnded.value = false;
|
|
||||||
|
|
||||||
// Use defaults until terminal is mounted and measured
|
|
||||||
const cols = 80;
|
|
||||||
const rows = 24;
|
|
||||||
|
|
||||||
try {
|
|
||||||
sessionId = await invoke<string>("spawn_local_shell", {
|
|
||||||
shellPath: selectedShell.value,
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
});
|
|
||||||
connected.value = true;
|
|
||||||
|
|
||||||
// Wait for DOM update so containerRef is available
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
if (containerRef.value) {
|
|
||||||
terminalInstance = useTerminal(sessionId, "pty");
|
|
||||||
terminalInstance.mount(containerRef.value);
|
|
||||||
|
|
||||||
// Fit after mount to get real dimensions, then resize the PTY
|
|
||||||
setTimeout(() => {
|
|
||||||
if (terminalInstance) {
|
|
||||||
terminalInstance.fit();
|
|
||||||
const term = terminalInstance.terminal;
|
|
||||||
invoke("pty_resize", {
|
|
||||||
sessionId,
|
|
||||||
cols: term.cols,
|
|
||||||
rows: term.rows,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for shell exit
|
|
||||||
closeUnlisten = await listen(`pty:close:${sessionId}`, () => {
|
|
||||||
cleanup();
|
|
||||||
sessionEnded.value = true;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to spawn shell:", err);
|
|
||||||
connected.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function kill(): void {
|
|
||||||
if (sessionId) {
|
|
||||||
invoke("disconnect_pty", { sessionId }).catch(() => {});
|
|
||||||
}
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup(): void {
|
|
||||||
if (terminalInstance) {
|
|
||||||
terminalInstance.destroy();
|
|
||||||
terminalInstance = null;
|
|
||||||
}
|
|
||||||
if (closeUnlisten) {
|
|
||||||
closeUnlisten();
|
|
||||||
closeUnlisten = null;
|
|
||||||
}
|
|
||||||
connected.value = false;
|
|
||||||
sessionId = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadShells);
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (connected.value) kill();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
Run: `npx vue-tsc --noEmit`
|
|
||||||
Expected: no errors
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/components/ai/CopilotPanel.vue
|
|
||||||
git commit -m "feat: CopilotPanel — local PTY terminal in AI sidebar"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: Update MainLayout and delete GeminiPanel
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/layouts/MainLayout.vue`
|
|
||||||
- Delete: `src/components/ai/GeminiPanel.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update MainLayout imports and template**
|
|
||||||
|
|
||||||
In `MainLayout.vue`:
|
|
||||||
|
|
||||||
Replace the import (line 205):
|
|
||||||
```typescript
|
|
||||||
import GeminiPanel from "@/components/ai/GeminiPanel.vue";
|
|
||||||
```
|
|
||||||
with:
|
|
||||||
```typescript
|
|
||||||
import CopilotPanel from "@/components/ai/CopilotPanel.vue";
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace the template usage (line 168):
|
|
||||||
```html
|
|
||||||
<GeminiPanel v-if="geminiVisible" />
|
|
||||||
```
|
|
||||||
with:
|
|
||||||
```html
|
|
||||||
<CopilotPanel v-if="copilotVisible" />
|
|
||||||
```
|
|
||||||
|
|
||||||
Rename the ref and all references (line 219, 71, 73, 293):
|
|
||||||
- `geminiVisible` → `copilotVisible`
|
|
||||||
- Update the toolbar button title: `"Gemini XO (Ctrl+Shift+G)"` → `"AI Copilot (Ctrl+Shift+G)"`
|
|
||||||
|
|
||||||
- [ ] **Step 2: Delete GeminiPanel.vue**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm src/components/ai/GeminiPanel.vue
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify frontend compiles**
|
|
||||||
|
|
||||||
Run: `npx vue-tsc --noEmit`
|
|
||||||
Expected: no errors
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "feat: swap GeminiPanel for CopilotPanel in MainLayout"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 8: Add PTY tests
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src-tauri/src/pty/mod.rs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add test module to pty/mod.rs**
|
|
||||||
|
|
||||||
Append to the bottom of `src-tauri/src/pty/mod.rs`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_shells_returns_at_least_one() {
|
|
||||||
let svc = PtyService::new();
|
|
||||||
let shells = svc.list_shells();
|
|
||||||
assert!(!shells.is_empty(), "should find at least one shell");
|
|
||||||
for shell in &shells {
|
|
||||||
assert!(!shell.name.is_empty());
|
|
||||||
assert!(!shell.path.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_shells_no_duplicates() {
|
|
||||||
let svc = PtyService::new();
|
|
||||||
let shells = svc.list_shells();
|
|
||||||
let paths: Vec<&str> = shells.iter().map(|s| s.path.as_str()).collect();
|
|
||||||
let mut unique = paths.clone();
|
|
||||||
unique.sort();
|
|
||||||
unique.dedup();
|
|
||||||
assert_eq!(paths.len(), unique.len(), "shell list should not contain duplicates");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn disconnect_nonexistent_session_errors() {
|
|
||||||
let svc = PtyService::new();
|
|
||||||
assert!(svc.disconnect("nonexistent").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn write_nonexistent_session_errors() {
|
|
||||||
let svc = PtyService::new();
|
|
||||||
assert!(svc.write("nonexistent", b"hello").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resize_nonexistent_session_errors() {
|
|
||||||
let svc = PtyService::new();
|
|
||||||
assert!(svc.resize("nonexistent", 80, 24).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests**
|
|
||||||
|
|
||||||
Run: `cd src-tauri && cargo test`
|
|
||||||
Expected: 87+ tests pass (82 existing + 5 new), zero warnings
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src-tauri/src/pty/mod.rs
|
|
||||||
git commit -m "test: PtyService unit tests — shell detection, error paths"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 9: Final build, verify, tag
|
|
||||||
|
|
||||||
**Files:** None (verification only)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Full Rust build with zero warnings**
|
|
||||||
|
|
||||||
Run: `cd src-tauri && cargo build`
|
|
||||||
Expected: zero warnings, zero errors
|
|
||||||
|
|
||||||
- [ ] **Step 2: Full test suite**
|
|
||||||
|
|
||||||
Run: `cd src-tauri && cargo test`
|
|
||||||
Expected: 87+ tests, all passing
|
|
||||||
|
|
||||||
- [ ] **Step 3: Frontend type check**
|
|
||||||
|
|
||||||
Run: `npx vue-tsc --noEmit`
|
|
||||||
Expected: no errors
|
|
||||||
|
|
||||||
- [ ] **Step 4: Push and tag**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git push
|
|
||||||
git tag v1.2.5
|
|
||||||
git push origin v1.2.5
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update CLAUDE.md test count**
|
|
||||||
|
|
||||||
Update the test count in CLAUDE.md to reflect the new total. Commit and push (do NOT re-tag).
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
# Local PTY Copilot Panel — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-03-24
|
|
||||||
**Status:** Approved
|
|
||||||
**Author:** Claude Opus 4.6 (XO)
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The AI panel is a Gemini API stub (~130 lines backend, ~124 lines frontend) with no OAuth, no conversation history, no tool use. The Commander pays $200/mo Claude Max, $20/mo Gemini, $20/mo ChatGPT — all of which include CLI tool access (Claude Code, Gemini CLI, Codex CLI). These CLIs are designed for terminals. Wraith has a terminal. Ship a local PTY in the sidebar and let the user run whichever CLI they want.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
Replace the Gemini stub with a local PTY terminal in the sidebar panel. Reuse the existing xterm.js infrastructure. User picks a shell (bash, sh, zsh, PowerShell, Git Bash), the panel spawns it locally, and they run `claude`, `gemini`, `codex`, or anything else.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Backend — `src-tauri/src/pty/mod.rs`
|
|
||||||
|
|
||||||
New module following the same patterns as `SshService`:
|
|
||||||
|
|
||||||
```
|
|
||||||
PtyService
|
|
||||||
sessions: DashMap<String, Arc<PtySession>>
|
|
||||||
|
|
||||||
PtySession
|
|
||||||
id: String
|
|
||||||
writer: Mutex<Box<dyn Write + Send>> // from master.take_writer()
|
|
||||||
master: Mutex<Box<dyn MasterPty + Send>> // kept for resize()
|
|
||||||
child: Mutex<Box<dyn Child + Send + Sync>> // for kill/wait
|
|
||||||
shell_path: String
|
|
||||||
|
|
||||||
PtyService methods:
|
|
||||||
spawn(shell_path, cols, rows, app_handle) -> Result<String, String>
|
|
||||||
write(session_id, data) -> Result<(), String>
|
|
||||||
resize(session_id, cols, rows) -> Result<(), String>
|
|
||||||
disconnect(session_id) -> Result<(), String>
|
|
||||||
list_shells() -> Vec<ShellInfo>
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `writer` and `master` require `Mutex` wrappers because `portable-pty` trait objects are `Send` but not `Sync`, and the `DashMap` requires `Sync` on stored values.
|
|
||||||
|
|
||||||
**PTY crate:** `portable-pty` — cross-platform (Unix PTY, Windows ConPTY). MIT licensed. Part of the wezterm project.
|
|
||||||
|
|
||||||
**Shell detection** (`list_shells`):
|
|
||||||
- Unix: check existence of `/bin/bash`, `/bin/sh`, `/bin/zsh`, `$SHELL`
|
|
||||||
- Windows: `powershell.exe`, `cmd.exe`, plus scan for Git Bash at common paths (`C:\Program Files\Git\bin\bash.exe`, `C:\Program Files (x86)\Git\bin\bash.exe`)
|
|
||||||
- Return `Vec<ShellInfo>` with `{ name, path }` pairs
|
|
||||||
|
|
||||||
**Output loop** — spawned per session via `spawn_blocking` (not async — `portable-pty` reader is synchronous `std::io::Read`):
|
|
||||||
```rust
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let mut reader = BufReader::new(pty_reader);
|
|
||||||
let mut buf = [0u8; 4096];
|
|
||||||
loop {
|
|
||||||
match reader.read(&mut buf) {
|
|
||||||
Ok(0) => { app.emit("pty:close:{id}", ()); break; }
|
|
||||||
Ok(n) => { app.emit("pty:data:{id}", base64(&buf[..n])); }
|
|
||||||
Err(_) => break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
`AppHandle::emit()` is synchronous in Tauri v2, so it works from a blocking thread context without issues.
|
|
||||||
|
|
||||||
**Environment:** `CommandBuilder` inherits the parent process environment by default. This is required so that `PATH` includes the user's CLI tools (`claude`, `gemini`, `codex`). No env filtering should be applied.
|
|
||||||
|
|
||||||
### Backend — Tauri Commands (`src-tauri/src/commands/pty_commands.rs`)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
spawn_local_shell(shell_path: String, cols: u32, rows: u32) -> Result<String, String>
|
|
||||||
pty_write(session_id: String, data: String) -> Result<(), String>
|
|
||||||
pty_resize(session_id: String, cols: u32, rows: u32) -> Result<(), String>
|
|
||||||
disconnect_pty(session_id: String) -> Result<(), String>
|
|
||||||
list_available_shells() -> Result<Vec<ShellInfo>, String>
|
|
||||||
```
|
|
||||||
|
|
||||||
All registered in `lib.rs` invoke handler. All added to `capabilities/default.json`.
|
|
||||||
|
|
||||||
### Backend — AppState Changes
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct AppState {
|
|
||||||
// ... existing fields ...
|
|
||||||
pub pty: PtyService, // ADD
|
|
||||||
// pub gemini: Mutex<...>, // DELETE
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend — `src/components/ai/CopilotPanel.vue`
|
|
||||||
|
|
||||||
Replaces `GeminiPanel.vue`. Structure:
|
|
||||||
|
|
||||||
1. **Header bar:** "AI Copilot" title + shell selector dropdown + spawn/kill buttons
|
|
||||||
2. **Terminal area:** xterm.js instance via `useTerminal` composable (adapted for PTY events)
|
|
||||||
3. **State:** shell list (from `list_available_shells`), active session ID, connected flag
|
|
||||||
4. **Close handling:** Listen for `pty:close:{session_id}` events to update `connected` state and show "Session ended — Relaunch?" UI. This differs from the SSH path where tab closure handles cleanup.
|
|
||||||
|
|
||||||
Shell selector is a `<select>` dropdown populated on mount. "Launch" button calls `spawn_local_shell`. Terminal mounts when session starts.
|
|
||||||
|
|
||||||
**Initial terminal size:** On spawn, measure terminal dimensions via `fitAddon.fit()` before invoking `spawn_local_shell`. Pass the measured cols/rows. If the terminal is not yet mounted, use defaults (80x24) and immediately resize after mount.
|
|
||||||
|
|
||||||
### Frontend — `useTerminal` Adaptation
|
|
||||||
|
|
||||||
Current `useTerminal.ts` hardcodes `ssh_write`, `ssh_resize`, and `ssh:data:` events.
|
|
||||||
|
|
||||||
**Chosen approach:** Parameterize the composable to accept a "backend type":
|
|
||||||
```typescript
|
|
||||||
export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh')
|
|
||||||
```
|
|
||||||
|
|
||||||
- `backend === 'ssh'` → `invoke("ssh_write")`, `listen("ssh:data:{id}")`, `convertEol: true`
|
|
||||||
- `backend === 'pty'` → `invoke("pty_write")`, `listen("pty:data:{id}")`, `convertEol: false`
|
|
||||||
|
|
||||||
Same xterm.js instance, same resize observer, same clipboard, same base64 decode. Only the invoke target, event prefix, and EOL conversion change.
|
|
||||||
|
|
||||||
**Important:** The local PTY driver already translates LF to CRLF. The SSH path needs `convertEol: true` because raw SSH streams may send bare LF. Setting `convertEol: true` on the PTY path would produce double newlines.
|
|
||||||
|
|
||||||
### Cleanup — Delete Gemini Stub
|
|
||||||
|
|
||||||
Remove entirely:
|
|
||||||
- `src-tauri/src/ai/mod.rs`
|
|
||||||
- `src-tauri/src/commands/ai_commands.rs`
|
|
||||||
- `src/components/ai/GeminiPanel.vue`
|
|
||||||
- `AppState.gemini` field and `Mutex<Option<ai::GeminiClient>>` in `lib.rs`
|
|
||||||
- AI command registrations from invoke handler
|
|
||||||
- `pub mod ai;` from `lib.rs`
|
|
||||||
|
|
||||||
Keep `reqwest` in `Cargo.toml` — the RDP stack (`ironrdp-tokio`, `sspi`) depends on it transitively and may require the `json` feature flag our direct dependency enables.
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
User types in copilot panel
|
|
||||||
→ useTerminal.onData(data)
|
|
||||||
→ invoke("pty_write", { sessionId, data })
|
|
||||||
→ PtyService.write() → writer.write_all(data)
|
|
||||||
→ PTY stdin → shell process
|
|
||||||
|
|
||||||
Shell output → PTY stdout
|
|
||||||
→ output reader loop (spawn_blocking)
|
|
||||||
→ app.emit("pty:data:{id}", base64(bytes))
|
|
||||||
→ useTerminal listener → base64 decode → xterm.js.write()
|
|
||||||
|
|
||||||
Shell exits (user types "exit" or CLI tool quits)
|
|
||||||
→ reader returns Ok(0)
|
|
||||||
→ app.emit("pty:close:{id}", ())
|
|
||||||
→ CopilotPanel listens → updates connected state → shows relaunch UI
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tauri ACL
|
|
||||||
|
|
||||||
No changes needed to `capabilities/default.json` — `core:default` covers command invocation, `core:event:default` covers event listening. The PTY commands are registered via `generate_handler!`.
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
**Rust tests:**
|
|
||||||
- `list_shells()` returns at least one shell on any platform
|
|
||||||
- `spawn()` + `write("echo hello\n")` + read output contains "hello"
|
|
||||||
- `resize()` doesn't error on active session
|
|
||||||
- `disconnect()` removes session from registry
|
|
||||||
- `disconnect()` on nonexistent session returns error
|
|
||||||
|
|
||||||
**Frontend:** `useTerminal` composable already tested via SSH path. The `backend` parameter is a simple branch — no separate test needed.
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
**Add:**
|
|
||||||
- `portable-pty` — cross-platform PTY (MIT license, part of wezterm project)
|
|
||||||
|
|
||||||
**No removals** — `reqwest`, `md5`, `pem`, and other existing deps serve SSH and RDP functionality.
|
|
||||||
|
|
||||||
### Migration
|
|
||||||
|
|
||||||
No data migration needed. The Gemini stub stores nothing persistent — no DB tables, no settings, no vault entries. Clean delete.
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
1. Commander opens Wraith, presses Ctrl+Shift+G
|
|
||||||
2. Shell dropdown shows detected shells (bash on macOS, PowerShell + Git Bash on Windows)
|
|
||||||
3. Selects shell, clicks Launch
|
|
||||||
4. Full interactive terminal appears in sidebar
|
|
||||||
5. Types `claude` (or `gemini` or `codex`) — CLI launches, works normally
|
|
||||||
6. Resize sidebar → terminal reflows
|
|
||||||
7. Close panel or kill session → PTY process terminates cleanly
|
|
||||||
8. Shell exits → panel shows "Session ended — Relaunch?" prompt
|
|
||||||
20
package-lock.json
generated
20
package-lock.json
generated
@ -19,9 +19,7 @@
|
|||||||
"@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",
|
||||||
@ -1518,15 +1516,6 @@
|
|||||||
"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",
|
||||||
@ -1536,15 +1525,6 @@
|
|||||||
"@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",
|
||||||
|
|||||||
36
package.json
36
package.json
@ -10,33 +10,31 @@
|
|||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.0.0",
|
"vue": "^3.5.0",
|
||||||
"@codemirror/commands": "^6.0.0",
|
"pinia": "^3.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",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
"@xterm/xterm": "^6.0.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",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@codemirror/view": "^6.0.0",
|
||||||
"pinia": "^3.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
"vue": "^3.5.0"
|
"@codemirror/commands": "^6.0.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",
|
||||||
"vue-tsc": "^2.0.0"
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"vue-tsc": "^2.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
284
src-tauri/Cargo.lock
generated
284
src-tauri/Cargo.lock
generated
@ -356,58 +356,6 @@ 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"
|
||||||
@ -1468,12 +1416,6 @@ dependencies = [
|
|||||||
"tendril 0.5.0",
|
"tendril 0.5.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "downcast-rs"
|
|
||||||
version = "1.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dpi"
|
name = "dpi"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@ -1787,21 +1729,10 @@ version = "0.3.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
|
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset 0.9.1",
|
"memoffset",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "filedescriptor"
|
|
||||||
version = "0.8.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filetime"
|
name = "filetime"
|
||||||
version = "0.2.27"
|
version = "0.2.27"
|
||||||
@ -2648,12 +2579,6 @@ 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"
|
||||||
@ -2679,7 +2604,6 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
"httpdate",
|
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
@ -2946,15 +2870,6 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ioctl-rs"
|
|
||||||
version = "0.1.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipconfig"
|
name = "ipconfig"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@ -2991,7 +2906,6 @@ 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",
|
||||||
@ -3555,12 +3469,6 @@ 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"
|
||||||
@ -3602,15 +3510,6 @@ version = "2.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "memoffset"
|
|
||||||
version = "0.6.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
|
|
||||||
dependencies = [
|
|
||||||
"autocfg",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memoffset"
|
name = "memoffset"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@ -3750,20 +3649,6 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nix"
|
|
||||||
version = "0.25.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
|
|
||||||
dependencies = [
|
|
||||||
"autocfg",
|
|
||||||
"bitflags 1.3.2",
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"memoffset 0.6.5",
|
|
||||||
"pin-utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nodrop"
|
name = "nodrop"
|
||||||
version = "0.1.14"
|
version = "0.1.14"
|
||||||
@ -4326,16 +4211,6 @@ dependencies = [
|
|||||||
"sha1 0.11.0-rc.2",
|
"sha1 0.11.0-rc.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pem"
|
|
||||||
version = "3.0.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
|
||||||
dependencies = [
|
|
||||||
"base64 0.22.1",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@ -4843,27 +4718,6 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-pty"
|
|
||||||
version = "0.8.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"bitflags 1.3.2",
|
|
||||||
"downcast-rs",
|
|
||||||
"filedescriptor",
|
|
||||||
"lazy_static",
|
|
||||||
"libc",
|
|
||||||
"log",
|
|
||||||
"nix",
|
|
||||||
"serial",
|
|
||||||
"shared_library",
|
|
||||||
"shell-words",
|
|
||||||
"winapi",
|
|
||||||
"winreg 0.10.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portpicker"
|
name = "portpicker"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@ -6040,17 +5894,6 @@ 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"
|
||||||
@ -6133,48 +5976,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serial"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86"
|
|
||||||
dependencies = [
|
|
||||||
"serial-core",
|
|
||||||
"serial-unix",
|
|
||||||
"serial-windows",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serial-core"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serial-unix"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7"
|
|
||||||
dependencies = [
|
|
||||||
"ioctl-rs",
|
|
||||||
"libc",
|
|
||||||
"serial-core",
|
|
||||||
"termios",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serial-windows"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"serial-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serialize-to-javascript"
|
name = "serialize-to-javascript"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@ -6281,22 +6082,6 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "shared_library"
|
|
||||||
version = "0.1.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
|
||||||
dependencies = [
|
|
||||||
"lazy_static",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "shell-words"
|
|
||||||
version = "1.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@ -7152,15 +6937,6 @@ dependencies = [
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "termios"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@ -7479,7 +7255,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -7669,35 +7444,6 @@ 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"
|
||||||
@ -7729,12 +7475,6 @@ 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"
|
||||||
@ -8732,15 +8472,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winreg"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
|
||||||
dependencies = [
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.50.0"
|
version = "0.50.0"
|
||||||
@ -8876,15 +8607,11 @@ dependencies = [
|
|||||||
name = "wraith"
|
name = "wraith"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.8.4",
|
|
||||||
"aes-gcm 0.10.3",
|
"aes-gcm 0.10.3",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"block-padding 0.3.3",
|
|
||||||
"cbc 0.1.2",
|
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"hex",
|
"hex",
|
||||||
@ -8892,17 +8619,11 @@ dependencies = [
|
|||||||
"ironrdp-tls",
|
"ironrdp-tls",
|
||||||
"ironrdp-tokio",
|
"ironrdp-tokio",
|
||||||
"log",
|
"log",
|
||||||
"md5",
|
|
||||||
"pem",
|
|
||||||
"pkcs8 0.10.2",
|
|
||||||
"png",
|
|
||||||
"portable-pty",
|
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"russh",
|
"russh",
|
||||||
"russh-sftp",
|
"russh-sftp",
|
||||||
"sec1 0.7.3",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ssh-key",
|
"ssh-key",
|
||||||
@ -8914,11 +8635,8 @@ 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]]
|
||||||
|
|||||||
@ -2,25 +2,16 @@
|
|||||||
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 = [] }
|
tauri = { version = "2", features = ["devtools"] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
@ -37,8 +28,6 @@ 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"
|
||||||
@ -47,25 +36,8 @@ russh = "0.48"
|
|||||||
russh-sftp = "2.1.1"
|
russh-sftp = "2.1.1"
|
||||||
ssh-key = { version = "0.6", features = ["ed25519", "rsa"] }
|
ssh-key = { version = "0.6", features = ["ed25519", "rsa"] }
|
||||||
|
|
||||||
# EC key PEM decryption (all already in dep tree via russh)
|
|
||||||
md5 = "0.7"
|
|
||||||
aes = "0.8"
|
|
||||||
cbc = "0.1"
|
|
||||||
block-padding = "0.3"
|
|
||||||
pem = "3"
|
|
||||||
pkcs8 = { version = "0.10", features = ["pem"] }
|
|
||||||
sec1 = { version = "0.7", features = ["pem"] }
|
|
||||||
|
|
||||||
# Local PTY for AI copilot panel
|
|
||||||
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", "displaycontrol"] }
|
ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input"] }
|
||||||
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"
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
{
|
{
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default capabilities for the main Wraith window",
|
"description": "Default capabilities for the main Wraith window",
|
||||||
"windows": ["main", "tool-*", "detached-*", "editor-*", "help-*"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:event:default",
|
"core:event:default",
|
||||||
"core:window:default",
|
"core:window:default",
|
||||||
"core:window:allow-create",
|
"shell:allow-open"
|
||||||
"core:webview:default",
|
|
||||||
"core:webview:allow-create-webview-window",
|
|
||||||
"shell:allow-open",
|
|
||||||
"updater:default"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
{"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"]}}
|
{}
|
||||||
@ -1,39 +1,18 @@
|
|||||||
!include "MUI2.nsh"
|
!include "MUI2.nsh"
|
||||||
!include "nsDialogs.nsh"
|
|
||||||
!include "LogicLib.nsh"
|
|
||||||
!include "WinMessages.nsh"
|
|
||||||
Name "Wraith"
|
Name "Wraith"
|
||||||
OutFile "OUTFILE_PLACEHOLDER"
|
OutFile "OUTFILE_PLACEHOLDER"
|
||||||
InstallDir "$PROGRAMFILES64\Wraith"
|
InstallDir "$PROGRAMFILES64\Wraith"
|
||||||
RequestExecutionLevel admin
|
RequestExecutionLevel admin
|
||||||
Var DesktopShortcut
|
|
||||||
!insertmacro MUI_PAGE_DIRECTORY
|
!insertmacro MUI_PAGE_DIRECTORY
|
||||||
Page custom OptionsPage OptionsPageLeave
|
|
||||||
!insertmacro MUI_PAGE_INSTFILES
|
!insertmacro MUI_PAGE_INSTFILES
|
||||||
!insertmacro MUI_LANGUAGE "English"
|
!insertmacro MUI_LANGUAGE "English"
|
||||||
|
|
||||||
Function OptionsPage
|
|
||||||
nsDialogs::Create 1018
|
|
||||||
Pop $0
|
|
||||||
${NSD_CreateCheckbox} 0 0 100% 12u "Create Desktop Shortcut"
|
|
||||||
Pop $1
|
|
||||||
; Unchecked by default — no ${NSD_Check}
|
|
||||||
nsDialogs::Show
|
|
||||||
FunctionEnd
|
|
||||||
|
|
||||||
Function OptionsPageLeave
|
|
||||||
${NSD_GetState} $1 $DesktopShortcut
|
|
||||||
FunctionEnd
|
|
||||||
|
|
||||||
Section "Install"
|
Section "Install"
|
||||||
SetOutPath "$INSTDIR"
|
SetOutPath "$INSTDIR"
|
||||||
File "Wraith.exe"
|
File "Wraith.exe"
|
||||||
File "wraith.ico"
|
File "wraith.ico"
|
||||||
CreateDirectory "$SMPROGRAMS\Wraith"
|
CreateDirectory "$SMPROGRAMS\Wraith"
|
||||||
CreateShortcut "$SMPROGRAMS\Wraith\Wraith.lnk" "$INSTDIR\Wraith.exe" "" "$INSTDIR\wraith.ico"
|
CreateShortcut "$SMPROGRAMS\Wraith\Wraith.lnk" "$INSTDIR\Wraith.exe" "" "$INSTDIR\wraith.ico"
|
||||||
${If} $DesktopShortcut == ${BST_CHECKED}
|
|
||||||
CreateShortcut "$DESKTOP\Wraith.lnk" "$INSTDIR\Wraith.exe" "" "$INSTDIR\wraith.ico"
|
CreateShortcut "$DESKTOP\Wraith.lnk" "$INSTDIR\Wraith.exe" "" "$INSTDIR\wraith.ico"
|
||||||
${EndIf}
|
|
||||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "DisplayName" "Wraith"
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "DisplayName" "Wraith"
|
||||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "UninstallString" "$INSTDIR\uninstall.exe"
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "UninstallString" "$INSTDIR\uninstall.exe"
|
||||||
|
|||||||
51
src-tauri/src/ai/mod.rs
Normal file
51
src-tauri/src/ai/mod.rs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(tag = "type", content = "value")]
|
||||||
|
pub enum AuthMethod {
|
||||||
|
ApiKey(String),
|
||||||
|
ServiceAccount(String),
|
||||||
|
GoogleAccount {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
expiry: Option<u64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GeminiClient {
|
||||||
|
auth: Arc<TokioMutex<AuthMethod>>,
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeminiClient {
|
||||||
|
pub fn new(auth: AuthMethod, model: String) -> Self {
|
||||||
|
Self {
|
||||||
|
auth: Arc::new(TokioMutex::new(auth)),
|
||||||
|
model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn chat(&self, message: &str) -> anyhow::Result<String> {
|
||||||
|
let auth = self.auth.lock().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!("https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent", self.model);
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"contents": [{"parts": [{"text": message}]}]
|
||||||
|
});
|
||||||
|
let mut request = client.post(url);
|
||||||
|
match &*auth {
|
||||||
|
AuthMethod::ApiKey(key) => { request = request.query(&[("key", key)]); }
|
||||||
|
AuthMethod::GoogleAccount { access_token, .. } => { request = request.bearer_auth(access_token); }
|
||||||
|
AuthMethod::ServiceAccount(_) => { return Err(anyhow::anyhow!("Service Account auth not yet fully implemented")); }
|
||||||
|
}
|
||||||
|
let resp = request.json(&payload).send().await?.error_for_status()?;
|
||||||
|
let json: serde_json::Value = resp.json().await?;
|
||||||
|
let text = json["candidates"][0]["content"]["parts"][0]["text"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Failed to parse Gemini response"))?;
|
||||||
|
Ok(text.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,499 +0,0 @@
|
|||||||
//! 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25
src-tauri/src/commands/ai_commands.rs
Normal file
25
src-tauri/src/commands/ai_commands.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use tauri::State;
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::ai::{AuthMethod, GeminiClient};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_gemini_auth(auth: AuthMethod, model: Option<String>, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let client = GeminiClient::new(auth, model.unwrap_or_else(|| "gemini-2.0-flash".to_string()));
|
||||||
|
*state.gemini.lock().unwrap() = Some(client);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn gemini_chat(message: String, state: State<'_, AppState>) -> Result<String, String> {
|
||||||
|
let client = {
|
||||||
|
let client_opt = state.gemini.lock().unwrap();
|
||||||
|
client_opt.as_ref().cloned().ok_or("Gemini not authenticated")?
|
||||||
|
};
|
||||||
|
|
||||||
|
client.chat(&message).await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn is_gemini_authenticated(state: State<'_, AppState>) -> bool {
|
||||||
|
state.gemini.lock().unwrap().is_some()
|
||||||
|
}
|
||||||
@ -92,19 +92,3 @@ 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,16 +3,34 @@ 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 async fn list_credentials(state: State<'_, AppState>) -> Result<Vec<Credential>, String> {
|
pub fn list_credentials(state: State<'_, AppState>) -> Result<Vec<Credential>, String> {
|
||||||
let guard = state.credentials.lock().await;
|
let guard = require_unlocked!(state);
|
||||||
let svc = guard
|
guard.as_ref().unwrap().list()
|
||||||
.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.
|
||||||
@ -21,18 +39,18 @@ pub async fn list_credentials(state: State<'_, AppState>) -> Result<Vec<Credenti
|
|||||||
/// 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 async fn create_password(
|
pub 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 = state.credentials.lock().await;
|
let guard = require_unlocked!(state);
|
||||||
let svc = guard
|
guard
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
|
.unwrap()
|
||||||
svc.create_password(name, username, password, domain)
|
.create_password(name, username, password, domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store a new SSH private key credential.
|
/// Store a new SSH private key credential.
|
||||||
@ -41,18 +59,18 @@ pub async 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 async fn create_ssh_key(
|
pub 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 = state.credentials.lock().await;
|
let guard = require_unlocked!(state);
|
||||||
let svc = guard
|
guard
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
|
.unwrap()
|
||||||
svc.create_ssh_key(name, username, private_key_pem, passphrase)
|
.create_ssh_key(name, username, private_key_pem, passphrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a credential by id.
|
/// Delete a credential by id.
|
||||||
@ -60,30 +78,21 @@ pub async 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 async fn delete_credential(id: i64, state: State<'_, AppState>) -> Result<(), String> {
|
pub fn delete_credential(id: i64, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
let guard = state.credentials.lock().await;
|
let guard = require_unlocked!(state);
|
||||||
let svc = guard
|
guard.as_ref().unwrap().delete(id)
|
||||||
.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 async fn decrypt_password(credential_id: i64, state: State<'_, AppState>) -> Result<String, String> {
|
pub fn decrypt_password(credential_id: i64, state: State<'_, AppState>) -> Result<String, String> {
|
||||||
let guard = state.credentials.lock().await;
|
let guard = require_unlocked!(state);
|
||||||
let svc = guard
|
guard.as_ref().unwrap().decrypt_password(credential_id)
|
||||||
.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 async fn decrypt_ssh_key(ssh_key_id: i64, state: State<'_, AppState>) -> Result<(String, String), String> {
|
pub fn decrypt_ssh_key(ssh_key_id: i64, state: State<'_, AppState>) -> Result<(String, String), String> {
|
||||||
let guard = state.credentials.lock().await;
|
let guard = require_unlocked!(state);
|
||||||
let svc = guard
|
guard.as_ref().unwrap().decrypt_ssh_key(ssh_key_id)
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
|
|
||||||
svc.decrypt_ssh_key(ssh_key_id)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
//! 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
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
//! Tauri commands for MCP tool operations.
|
|
||||||
//!
|
|
||||||
//! These expose terminal_read, terminal_execute, and session listing to both
|
|
||||||
//! the frontend and the MCP bridge binary.
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
use tauri::State;
|
|
||||||
|
|
||||||
use crate::AppState;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct McpSessionInfo {
|
|
||||||
pub id: String,
|
|
||||||
pub session_type: String, // "ssh" or "pty"
|
|
||||||
pub name: String,
|
|
||||||
pub host: Option<String>,
|
|
||||||
pub username: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all active sessions (SSH + PTY) with metadata.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn mcp_list_sessions(state: State<'_, AppState>) -> Vec<McpSessionInfo> {
|
|
||||||
let mut sessions = Vec::new();
|
|
||||||
|
|
||||||
// SSH sessions
|
|
||||||
for info in state.ssh.list_sessions() {
|
|
||||||
sessions.push(McpSessionInfo {
|
|
||||||
id: info.id,
|
|
||||||
session_type: "ssh".to_string(),
|
|
||||||
name: format!("{}@{}:{}", info.username, info.hostname, info.port),
|
|
||||||
host: Some(info.hostname),
|
|
||||||
username: Some(info.username),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the last N lines from a session's scrollback buffer (ANSI stripped).
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn mcp_terminal_read(
|
|
||||||
session_id: String,
|
|
||||||
lines: Option<usize>,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let n = lines.unwrap_or(50);
|
|
||||||
let buf = state.scrollback.get(&session_id)
|
|
||||||
.ok_or_else(|| format!("No scrollback buffer for session {}", session_id))?;
|
|
||||||
Ok(buf.read_lines(n))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute a command in an SSH session and capture output using a marker.
|
|
||||||
///
|
|
||||||
/// Sends the command followed by `echo __WRAITH_MCP_DONE__`, then reads the
|
|
||||||
/// scrollback until the marker appears or timeout is reached.
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn mcp_terminal_execute(
|
|
||||||
session_id: String,
|
|
||||||
command: String,
|
|
||||||
timeout_ms: Option<u64>,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let timeout = timeout_ms.unwrap_or(5000);
|
|
||||||
let marker = "__WRAITH_MCP_DONE__";
|
|
||||||
|
|
||||||
// Record current buffer position
|
|
||||||
let buf = state.scrollback.get(&session_id)
|
|
||||||
.ok_or_else(|| format!("No scrollback buffer for session {}", session_id))?;
|
|
||||||
let before = buf.total_written();
|
|
||||||
|
|
||||||
// Send command + marker echo
|
|
||||||
let full_cmd = format!("{}\recho {}\r", command, marker);
|
|
||||||
state.ssh.write(&session_id, full_cmd.as_bytes()).await?;
|
|
||||||
|
|
||||||
// Poll scrollback until marker appears or timeout
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let timeout_dur = std::time::Duration::from_millis(timeout);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if start.elapsed() > timeout_dur {
|
|
||||||
// Return whatever we captured so far
|
|
||||||
let raw = buf.read_raw();
|
|
||||||
let total = buf.total_written();
|
|
||||||
// Extract just the new content since we sent the command
|
|
||||||
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(format!("[timeout after {}ms]\n{}", timeout, output));
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw = buf.read_raw();
|
|
||||||
if raw.contains(marker) {
|
|
||||||
// Extract output between command echo and 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()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Strip the command echo and marker from output
|
|
||||||
let clean = output
|
|
||||||
.lines()
|
|
||||||
.filter(|line| {
|
|
||||||
!line.contains(marker)
|
|
||||||
&& !line.trim().starts_with(&command.trim_start().chars().take(20).collect::<String>())
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
return Ok(clean.trim().to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
@ -6,12 +6,4 @@ pub mod ssh_commands;
|
|||||||
pub mod sftp_commands;
|
pub mod sftp_commands;
|
||||||
pub mod rdp_commands;
|
pub mod rdp_commands;
|
||||||
pub mod theme_commands;
|
pub mod theme_commands;
|
||||||
pub mod pty_commands;
|
pub mod ai_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;
|
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
//! Tauri commands for local PTY session management.
|
|
||||||
|
|
||||||
use tauri::{AppHandle, State};
|
|
||||||
|
|
||||||
use crate::pty::ShellInfo;
|
|
||||||
use crate::AppState;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_available_shells(state: State<'_, AppState>) -> Vec<ShellInfo> {
|
|
||||||
state.pty.list_shells()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn spawn_local_shell(
|
|
||||||
shell_path: String,
|
|
||||||
cols: u32,
|
|
||||||
rows: u32,
|
|
||||||
app_handle: AppHandle,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
state.pty.spawn(&shell_path, cols as u16, rows as u16, app_handle, &state.scrollback)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn pty_write(
|
|
||||||
session_id: String,
|
|
||||||
data: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.pty.write(&session_id, data.as_bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn pty_resize(
|
|
||||||
session_id: String,
|
|
||||||
cols: u32,
|
|
||||||
rows: u32,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.pty.resize(&session_id, cols as u16, rows as u16)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn disconnect_pty(
|
|
||||||
session_id: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.pty.disconnect(&session_id)
|
|
||||||
}
|
|
||||||
@ -3,53 +3,35 @@
|
|||||||
//! 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::{AppHandle, State};
|
use tauri::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, app_handle)
|
state.rdp.connect(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the dirty region since last call as raw RGBA bytes via binary IPC.
|
/// Get the current frame buffer as a base64-encoded RGBA string.
|
||||||
///
|
///
|
||||||
/// Binary format: 8-byte header + pixel data
|
/// The frontend decodes this and draws it onto a `<canvas>` element.
|
||||||
/// Header: [x: u16, y: u16, width: u16, height: u16] (little-endian)
|
/// Pixel format: RGBA, 4 bytes per pixel, row-major, top-left origin.
|
||||||
/// If header is all zeros, the payload is a full frame (width*height*4 bytes).
|
|
||||||
/// If header is non-zero, payload contains only the dirty rectangle pixels.
|
|
||||||
/// Returns empty payload if nothing changed.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn rdp_get_frame(
|
pub async fn rdp_get_frame(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Response, String> {
|
) -> Result<String, String> {
|
||||||
let (region, pixels) = state.rdp.get_frame(&session_id)?;
|
state.rdp.get_frame(&session_id).await
|
||||||
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.
|
||||||
@ -64,7 +46,7 @@ pub fn rdp_get_frame(
|
|||||||
/// - 0x0100 = negative wheel direction
|
/// - 0x0100 = negative wheel direction
|
||||||
/// - 0x0400 = horizontal wheel
|
/// - 0x0400 = horizontal wheel
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn rdp_send_mouse(
|
pub async fn rdp_send_mouse(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
x: u16,
|
x: u16,
|
||||||
y: u16,
|
y: u16,
|
||||||
@ -82,7 +64,7 @@ pub 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 fn rdp_send_key(
|
pub async fn rdp_send_key(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
scancode: u16,
|
scancode: u16,
|
||||||
pressed: bool,
|
pressed: bool,
|
||||||
@ -93,7 +75,7 @@ pub 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 fn rdp_send_clipboard(
|
pub async fn rdp_send_clipboard(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
text: String,
|
text: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@ -101,34 +83,11 @@ pub fn rdp_send_clipboard(
|
|||||||
state.rdp.send_clipboard(&session_id, &text)
|
state.rdp.send_clipboard(&session_id, &text)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force the next get_frame to return a full frame regardless of dirty state.
|
|
||||||
/// Used when switching tabs or after resize to ensure the canvas is fully repainted.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn rdp_force_refresh(
|
|
||||||
session_id: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.rdp.force_refresh(&session_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resize the RDP session's desktop resolution.
|
|
||||||
/// Sends a Display Control Virtual Channel request to the server.
|
|
||||||
/// The server will re-render at the new resolution and send updated frames.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn rdp_resize(
|
|
||||||
session_id: String,
|
|
||||||
width: u16,
|
|
||||||
height: u16,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.rdp.resize(&session_id, width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disconnect an RDP session.
|
/// Disconnect an RDP session.
|
||||||
///
|
///
|
||||||
/// Sends a graceful shutdown to the RDP server and removes the session.
|
/// Sends a graceful shutdown to the RDP server and removes the session.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn disconnect_rdp(
|
pub async fn disconnect_rdp(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@ -137,7 +96,7 @@ pub fn disconnect_rdp(
|
|||||||
|
|
||||||
/// List all active RDP sessions (metadata only).
|
/// List all active RDP sessions (metadata only).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_rdp_sessions(
|
pub async 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())
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
//! 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
|
|
||||||
}
|
|
||||||
@ -32,8 +32,6 @@ pub async fn connect_ssh(
|
|||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
&state.sftp,
|
&state.sftp,
|
||||||
&state.scrollback,
|
|
||||||
&state.error_watcher,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -65,8 +63,6 @@ pub async fn connect_ssh_with_key(
|
|||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
&state.sftp,
|
&state.sftp,
|
||||||
&state.scrollback,
|
|
||||||
&state.error_watcher,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,188 +0,0 @@
|
|||||||
//! 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
//! 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
//! 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, ¤t);
|
|
||||||
|
|
||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
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;
|
||||||
@ -22,15 +21,14 @@ 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 async fn create_vault(mut password: String, state: State<'_, AppState>) -> Result<(), String> {
|
pub fn create_vault(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.clone());
|
let vs = VaultService::new(key);
|
||||||
|
|
||||||
// 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))?;
|
||||||
@ -41,14 +39,10 @@ pub async fn create_vault(mut password: String, state: State<'_, AppState>) -> R
|
|||||||
|
|
||||||
// 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().await = Some(cred_svc);
|
*state.credentials.lock().unwrap() = Some(cred_svc);
|
||||||
*state.vault.lock().await = Some(vs);
|
*state.vault.lock().unwrap() = Some(vs);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}.await;
|
|
||||||
|
|
||||||
password.zeroize();
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unlock an existing vault using the master password.
|
/// Unlock an existing vault using the master password.
|
||||||
@ -58,8 +52,7 @@ pub async fn create_vault(mut password: String, state: State<'_, AppState>) -> R
|
|||||||
///
|
///
|
||||||
/// 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 async fn unlock(mut password: String, state: State<'_, AppState>) -> Result<(), String> {
|
pub fn unlock(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")
|
||||||
@ -69,7 +62,7 @@ pub async fn unlock(mut password: String, state: State<'_, AppState>) -> Result<
|
|||||||
.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.clone());
|
let vs = VaultService::new(key);
|
||||||
|
|
||||||
// Verify the password by decrypting the check value.
|
// Verify the password by decrypting the check value.
|
||||||
let check_blob = state
|
let check_blob = state
|
||||||
@ -87,18 +80,14 @@ pub async fn unlock(mut 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().await = Some(cred_svc);
|
*state.credentials.lock().unwrap() = Some(cred_svc);
|
||||||
*state.vault.lock().await = Some(vs);
|
*state.vault.lock().unwrap() = 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 async fn is_unlocked(state: State<'_, AppState>) -> Result<bool, String> {
|
pub fn is_unlocked(state: State<'_, AppState>) -> bool {
|
||||||
Ok(state.is_unlocked().await)
|
state.is_unlocked()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
use tauri::AppHandle;
|
|
||||||
use tauri::WebviewWindowBuilder;
|
|
||||||
|
|
||||||
/// Open a child window from the Rust side using WebviewWindowBuilder.
|
|
||||||
///
|
|
||||||
/// The `url` parameter supports hash fragments (e.g. "index.html#/tool/ping?sessionId=abc").
|
|
||||||
/// WebviewUrl::App takes a PathBuf and cannot handle hash/query — so we load plain
|
|
||||||
/// index.html and set the hash via JS after the window is created.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn open_child_window(
|
|
||||||
app_handle: AppHandle,
|
|
||||||
label: String,
|
|
||||||
title: String,
|
|
||||||
url: String,
|
|
||||||
width: f64,
|
|
||||||
height: f64,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// Split "index.html#/tool/ping?sessionId=abc" into path and fragment
|
|
||||||
let (path, hash) = match url.split_once('#') {
|
|
||||||
Some((p, h)) => (p.to_string(), Some(format!("#{}", h))),
|
|
||||||
None => (url.clone(), None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let webview_url = tauri::WebviewUrl::App(path.into());
|
|
||||||
let window = WebviewWindowBuilder::new(&app_handle, &label, webview_url)
|
|
||||||
.title(&title)
|
|
||||||
.inner_size(width, height)
|
|
||||||
.resizable(true)
|
|
||||||
.center()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to create window '{}': {}", label, e))?;
|
|
||||||
|
|
||||||
// Set the hash fragment after the window loads — this triggers App.vue's
|
|
||||||
// onMounted hash detection to render the correct tool/detached component.
|
|
||||||
if let Some(hash) = hash {
|
|
||||||
let _ = window.eval(&format!("window.location.hash = '{}';", hash));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
//! 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())
|
|
||||||
}
|
|
||||||
@ -19,7 +19,6 @@ 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,
|
||||||
@ -430,54 +429,6 @@ 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 ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -31,11 +31,10 @@ impl Database {
|
|||||||
|
|
||||||
/// Acquire a lock on the underlying connection.
|
/// Acquire a lock on the underlying connection.
|
||||||
///
|
///
|
||||||
/// Recovers gracefully from a poisoned mutex by taking the inner value.
|
/// Panics if the mutex was poisoned (which only happens if a thread
|
||||||
/// A poisoned mutex means a thread panicked while holding the lock; the
|
/// panicked while holding the lock — a non-recoverable situation anyway).
|
||||||
/// 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_or_else(|e| e.into_inner())
|
self.conn.lock().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run all embedded SQL migrations.
|
/// Run all embedded SQL migrations.
|
||||||
|
|||||||
@ -1,12 +1,3 @@
|
|||||||
// 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;
|
||||||
@ -17,13 +8,11 @@ pub mod sftp;
|
|||||||
pub mod rdp;
|
pub mod rdp;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
pub mod pty;
|
pub mod ai;
|
||||||
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;
|
||||||
@ -35,24 +24,19 @@ use ssh::session::SshService;
|
|||||||
use rdp::RdpService;
|
use rdp::RdpService;
|
||||||
use theme::ThemeService;
|
use theme::ThemeService;
|
||||||
use workspace::WorkspaceService;
|
use workspace::WorkspaceService;
|
||||||
use pty::PtyService;
|
|
||||||
use mcp::ScrollbackRegistry;
|
|
||||||
use mcp::error_watcher::ErrorWatcher;
|
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Database,
|
pub db: Database,
|
||||||
pub vault: tokio::sync::Mutex<Option<VaultService>>,
|
pub vault: Mutex<Option<VaultService>>,
|
||||||
pub settings: SettingsService,
|
pub settings: SettingsService,
|
||||||
pub connections: ConnectionService,
|
pub connections: ConnectionService,
|
||||||
pub credentials: tokio::sync::Mutex<Option<CredentialService>>,
|
pub credentials: Mutex<Option<CredentialService>>,
|
||||||
pub ssh: SshService,
|
pub ssh: SshService,
|
||||||
pub sftp: SftpService,
|
pub sftp: SftpService,
|
||||||
pub rdp: RdpService,
|
pub rdp: RdpService,
|
||||||
pub theme: ThemeService,
|
pub theme: ThemeService,
|
||||||
pub workspace: WorkspaceService,
|
pub workspace: WorkspaceService,
|
||||||
pub pty: PtyService,
|
pub gemini: Mutex<Option<ai::GeminiClient>>,
|
||||||
pub scrollback: ScrollbackRegistry,
|
|
||||||
pub error_watcher: std::sync::Arc<ErrorWatcher>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@ -60,34 +44,27 @@ 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: tokio::sync::Mutex::new(None),
|
vault: Mutex::new(None),
|
||||||
|
settings: SettingsService::new(database.clone()),
|
||||||
connections: ConnectionService::new(database.clone()),
|
connections: ConnectionService::new(database.clone()),
|
||||||
credentials: tokio::sync::Mutex::new(None),
|
credentials: 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),
|
theme: ThemeService::new(database.clone()),
|
||||||
workspace: WorkspaceService::new(settings.clone()),
|
workspace: WorkspaceService::new(SettingsService::new(database.clone())),
|
||||||
settings,
|
gemini: Mutex::new(None),
|
||||||
pty: PtyService::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 async fn is_unlocked(&self) -> bool {
|
pub fn is_unlocked(&self) -> bool {
|
||||||
self.vault.lock().await.is_some()
|
self.vault.lock().unwrap().is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,52 +78,13 @@ 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() {
|
||||||
// Install rustls crypto provider before any TLS operations (RDP needs this)
|
let app_state = AppState::new(data_directory()).expect("Failed to init AppState");
|
||||||
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)]
|
||||||
@ -156,85 +94,20 @@ 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::reorder_connections, commands::connections::reorder_groups,
|
commands::connections::list_groups, commands::connections::create_group, commands::connections::delete_group, commands::connections::rename_group, commands::connections::search_connections,
|
||||||
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_force_refresh, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::rdp_send_clipboard, commands::rdp_commands::rdp_resize, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions,
|
commands::rdp_commands::connect_rdp, commands::rdp_commands::rdp_get_frame, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::rdp_send_clipboard, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions,
|
||||||
commands::theme_commands::list_themes, commands::theme_commands::get_theme,
|
commands::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::ai_commands::set_gemini_auth, commands::ai_commands::gemini_chat, commands::ai_commands::is_gemini_authenticated,
|
||||||
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");
|
||||||
|
|||||||
@ -1,85 +0,0 @@
|
|||||||
//! 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(())
|
|
||||||
}
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
//! 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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
//! MCP (Model Context Protocol) infrastructure for Wraith.
|
|
||||||
//!
|
|
||||||
//! Provides programmatic access to active sessions so AI tools running in the
|
|
||||||
//! copilot panel can read terminal output, execute commands, and enumerate
|
|
||||||
//! sessions.
|
|
||||||
|
|
||||||
pub mod scrollback;
|
|
||||||
pub mod server;
|
|
||||||
pub mod error_watcher;
|
|
||||||
pub mod bridge_manager;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use dashmap::DashMap;
|
|
||||||
|
|
||||||
use crate::mcp::scrollback::ScrollbackBuffer;
|
|
||||||
|
|
||||||
/// Registry of scrollback buffers keyed by session ID.
|
|
||||||
/// Shared between SSH/PTY output loops (writers) and MCP tools (readers).
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ScrollbackRegistry {
|
|
||||||
buffers: Arc<DashMap<String, Arc<ScrollbackBuffer>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ScrollbackRegistry {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self { buffers: Arc::new(DashMap::new()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create and register a new scrollback buffer for a session.
|
|
||||||
pub fn create(&self, session_id: &str) -> Arc<ScrollbackBuffer> {
|
|
||||||
let buf = Arc::new(ScrollbackBuffer::new());
|
|
||||||
self.buffers.insert(session_id.to_string(), buf.clone());
|
|
||||||
buf
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the scrollback buffer for a session.
|
|
||||||
pub fn get(&self, session_id: &str) -> Option<Arc<ScrollbackBuffer>> {
|
|
||||||
self.buffers.get(session_id).map(|r| r.value().clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a session's scrollback buffer.
|
|
||||||
pub fn remove(&self, session_id: &str) {
|
|
||||||
self.buffers.remove(session_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,285 +0,0 @@
|
|||||||
//! Per-session scrollback buffer for MCP terminal_read.
|
|
||||||
//!
|
|
||||||
//! A thread-safe circular buffer that stores the last N bytes of terminal
|
|
||||||
//! output. Both SSH and PTY output loops write to it. The MCP tools read
|
|
||||||
//! from it without touching xterm.js or the frontend.
|
|
||||||
|
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
const DEFAULT_CAPACITY: usize = 64 * 1024; // 64KB per session
|
|
||||||
|
|
||||||
/// Thread-safe circular buffer for terminal output.
|
|
||||||
pub struct ScrollbackBuffer {
|
|
||||||
inner: Mutex<RingBuffer>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RingBuffer {
|
|
||||||
data: Vec<u8>,
|
|
||||||
capacity: usize,
|
|
||||||
/// Write position (wraps around)
|
|
||||||
write_pos: usize,
|
|
||||||
/// Total bytes written (for detecting wrap)
|
|
||||||
total_written: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ScrollbackBuffer {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::with_capacity(DEFAULT_CAPACITY)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_capacity(capacity: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
inner: Mutex::new(RingBuffer {
|
|
||||||
data: vec![0u8; capacity],
|
|
||||||
capacity,
|
|
||||||
write_pos: 0,
|
|
||||||
total_written: 0,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append bytes to the buffer. Old data is overwritten when full.
|
|
||||||
pub fn push(&self, bytes: &[u8]) {
|
|
||||||
if bytes.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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.
|
|
||||||
pub fn read_lines(&self, n: usize) -> String {
|
|
||||||
let raw = self.read_raw();
|
|
||||||
let text = strip_ansi(&raw);
|
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
|
||||||
let start = lines.len().saturating_sub(n);
|
|
||||||
lines[start..].join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read all buffered content as raw bytes (ordered oldest→newest).
|
|
||||||
pub fn read_raw(&self) -> String {
|
|
||||||
let buf = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let bytes = if buf.total_written >= buf.capacity {
|
|
||||||
// Buffer has wrapped — read from write_pos to end, then start to write_pos
|
|
||||||
let mut out = Vec::with_capacity(buf.capacity);
|
|
||||||
out.extend_from_slice(&buf.data[buf.write_pos..]);
|
|
||||||
out.extend_from_slice(&buf.data[..buf.write_pos]);
|
|
||||||
out
|
|
||||||
} else {
|
|
||||||
// Buffer hasn't wrapped yet
|
|
||||||
buf.data[..buf.write_pos].to_vec()
|
|
||||||
};
|
|
||||||
String::from_utf8_lossy(&bytes).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Total bytes written since creation.
|
|
||||||
pub fn total_written(&self) -> usize {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip ANSI escape sequences from text.
|
|
||||||
fn strip_ansi(input: &str) -> String {
|
|
||||||
let mut output = String::with_capacity(input.len());
|
|
||||||
let mut chars = input.chars().peekable();
|
|
||||||
|
|
||||||
while let Some(ch) = chars.next() {
|
|
||||||
if ch == '\x1b' {
|
|
||||||
// ESC sequence — consume until terminator
|
|
||||||
if let Some(&next) = chars.peek() {
|
|
||||||
if next == '[' {
|
|
||||||
chars.next(); // consume '['
|
|
||||||
// CSI sequence — consume until letter
|
|
||||||
while let Some(&c) = chars.peek() {
|
|
||||||
chars.next();
|
|
||||||
if c.is_ascii_alphabetic() || c == '~' || c == '@' {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if next == ']' {
|
|
||||||
chars.next(); // consume ']'
|
|
||||||
// OSC sequence — consume until BEL or ST
|
|
||||||
while let Some(&c) = chars.peek() {
|
|
||||||
chars.next();
|
|
||||||
if c == '\x07' {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if c == '\x1b' {
|
|
||||||
if chars.peek() == Some(&'\\') {
|
|
||||||
chars.next();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
chars.next(); // consume single-char escape
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if ch == '\r' {
|
|
||||||
// Skip carriage returns for cleaner output
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
output.push(ch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
output
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn push_and_read_basic() {
|
|
||||||
let buf = ScrollbackBuffer::new();
|
|
||||||
buf.push(b"hello world\n");
|
|
||||||
let lines = buf.read_lines(10);
|
|
||||||
assert!(lines.contains("hello world"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn read_lines_limits_output() {
|
|
||||||
let buf = ScrollbackBuffer::new();
|
|
||||||
buf.push(b"line1\nline2\nline3\nline4\nline5\n");
|
|
||||||
let lines = buf.read_lines(2);
|
|
||||||
assert!(!lines.contains("line3"));
|
|
||||||
assert!(lines.contains("line4"));
|
|
||||||
assert!(lines.contains("line5"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn circular_buffer_wraps() {
|
|
||||||
let buf = ScrollbackBuffer::with_capacity(16);
|
|
||||||
buf.push(b"AAAAAAAAAAAAAAAA"); // fill 16 bytes
|
|
||||||
buf.push(b"BBBB"); // overwrite first 4
|
|
||||||
let raw = buf.read_raw();
|
|
||||||
assert!(raw.starts_with("AAAAAAAAAAAA")); // 12 A's remain
|
|
||||||
assert!(raw.ends_with("BBBB"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strip_ansi_removes_csi() {
|
|
||||||
let input = "\x1b[32mgreen\x1b[0m normal";
|
|
||||||
assert_eq!(strip_ansi(input), "green normal");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strip_ansi_removes_osc() {
|
|
||||||
let input = "\x1b]0;title\x07text";
|
|
||||||
assert_eq!(strip_ansi(input), "text");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strip_ansi_preserves_plain_text() {
|
|
||||||
let input = "no escapes here\njust text";
|
|
||||||
assert_eq!(strip_ansi(input), "no escapes here\njust text");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_buffer_returns_empty() {
|
|
||||||
let buf = ScrollbackBuffer::new();
|
|
||||||
assert_eq!(buf.read_lines(10), "");
|
|
||||||
assert_eq!(buf.total_written(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn total_written_tracks_all_bytes() {
|
|
||||||
let buf = ScrollbackBuffer::with_capacity(8);
|
|
||||||
buf.push(b"12345678"); // 8 bytes
|
|
||||||
buf.push(b"ABCD"); // 4 more, wraps
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,631 +0,0 @@
|
|||||||
//! 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)
|
|
||||||
}
|
|
||||||
@ -1,231 +0,0 @@
|
|||||||
//! Local PTY service — spawns shells for the AI copilot panel.
|
|
||||||
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use base64::Engine;
|
|
||||||
use dashmap::DashMap;
|
|
||||||
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
|
||||||
use serde::Serialize;
|
|
||||||
use tauri::{AppHandle, Emitter};
|
|
||||||
|
|
||||||
use crate::mcp::ScrollbackRegistry;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
|
||||||
pub struct ShellInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PtySession {
|
|
||||||
pub id: String,
|
|
||||||
pub shell_path: String,
|
|
||||||
writer: Mutex<Box<dyn Write + Send>>,
|
|
||||||
master: Mutex<Box<dyn MasterPty + Send>>,
|
|
||||||
child: Mutex<Box<dyn Child + Send + Sync>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PtyService {
|
|
||||||
sessions: DashMap<String, Arc<PtySession>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PtyService {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self { sessions: DashMap::new() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect available shells on the system.
|
|
||||||
pub fn list_shells(&self) -> Vec<ShellInfo> {
|
|
||||||
let mut shells = Vec::new();
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
if let Ok(user_shell) = std::env::var("SHELL") {
|
|
||||||
if std::path::Path::new(&user_shell).exists() {
|
|
||||||
let name = std::path::Path::new(&user_shell)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or("shell")
|
|
||||||
.to_string();
|
|
||||||
shells.push(ShellInfo { name, path: user_shell });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (name, path) in [("bash", "/bin/bash"), ("zsh", "/bin/zsh"), ("sh", "/bin/sh")] {
|
|
||||||
if std::path::Path::new(path).exists() && !shells.iter().any(|s| s.path == path) {
|
|
||||||
shells.push(ShellInfo { name: name.to_string(), path: path.to_string() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
shells.push(ShellInfo { name: "PowerShell".to_string(), path: "powershell.exe".to_string() });
|
|
||||||
shells.push(ShellInfo { name: "CMD".to_string(), path: "cmd.exe".to_string() });
|
|
||||||
for git_bash in [
|
|
||||||
r"C:\Program Files\Git\bin\bash.exe",
|
|
||||||
r"C:\Program Files (x86)\Git\bin\bash.exe",
|
|
||||||
] {
|
|
||||||
if std::path::Path::new(git_bash).exists() {
|
|
||||||
shells.push(ShellInfo { name: "Git Bash".to_string(), path: git_bash.to_string() });
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn a local shell and start reading its output.
|
|
||||||
pub fn spawn(
|
|
||||||
&self,
|
|
||||||
shell_path: &str,
|
|
||||||
cols: u16,
|
|
||||||
rows: u16,
|
|
||||||
app_handle: AppHandle,
|
|
||||||
scrollback: &ScrollbackRegistry,
|
|
||||||
) -> Result<String, 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 pair = pty_system
|
|
||||||
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
|
||||||
.map_err(|e| format!("Failed to open PTY: {}", e))?;
|
|
||||||
|
|
||||||
let cmd = CommandBuilder::new(shell_path);
|
|
||||||
|
|
||||||
let child = pair.slave
|
|
||||||
.spawn_command(cmd)
|
|
||||||
.map_err(|e| format!("Failed to spawn shell '{}': {}", shell_path, e))?;
|
|
||||||
|
|
||||||
let reader = pair.master
|
|
||||||
.try_clone_reader()
|
|
||||||
.map_err(|e| format!("Failed to clone PTY reader: {}", e))?;
|
|
||||||
|
|
||||||
let writer = pair.master
|
|
||||||
.take_writer()
|
|
||||||
.map_err(|e| format!("Failed to take PTY writer: {}", e))?;
|
|
||||||
|
|
||||||
let session = Arc::new(PtySession {
|
|
||||||
id: session_id.clone(),
|
|
||||||
shell_path: shell_path.to_string(),
|
|
||||||
writer: Mutex::new(writer),
|
|
||||||
master: Mutex::new(pair.master),
|
|
||||||
child: Mutex::new(child),
|
|
||||||
});
|
|
||||||
|
|
||||||
self.sessions.insert(session_id.clone(), session);
|
|
||||||
|
|
||||||
// Create scrollback buffer for MCP terminal_read
|
|
||||||
let scrollback_buf = scrollback.create(&session_id);
|
|
||||||
|
|
||||||
// Output reader loop — runs in a dedicated OS thread because
|
|
||||||
// portable-pty's reader is synchronous (std::io::Read) and
|
|
||||||
// long-lived. Using std::thread::spawn avoids requiring a
|
|
||||||
// tokio runtime context (sync Tauri commands may not have one).
|
|
||||||
let sid = session_id.clone();
|
|
||||||
let app = app_handle;
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let mut reader = std::io::BufReader::new(reader);
|
|
||||||
let mut buf = [0u8; 4096];
|
|
||||||
loop {
|
|
||||||
match reader.read(&mut buf) {
|
|
||||||
Ok(0) => {
|
|
||||||
let _ = app.emit(&format!("pty:close:{}", sid), ());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Ok(n) => {
|
|
||||||
scrollback_buf.push(&buf[..n]);
|
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&buf[..n]);
|
|
||||||
let _ = app.emit(&format!("pty:data:{}", sid), encoded);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
let _ = app.emit(&format!("pty:close:{}", sid), ());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(session_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write data to a PTY session's stdin.
|
|
||||||
pub fn write(&self, session_id: &str, data: &[u8]) -> Result<(), String> {
|
|
||||||
let session = self.sessions.get(session_id)
|
|
||||||
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
|
|
||||||
let mut writer = session.writer.lock()
|
|
||||||
.map_err(|e| format!("Failed to lock PTY writer: {}", e))?;
|
|
||||||
writer.write_all(data)
|
|
||||||
.map_err(|e| format!("Failed to write to PTY {}: {}", session_id, e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resize a PTY session.
|
|
||||||
pub fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
|
||||||
let session = self.sessions.get(session_id)
|
|
||||||
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
|
|
||||||
let master = session.master.lock()
|
|
||||||
.map_err(|e| format!("Failed to lock PTY master: {}", e))?;
|
|
||||||
master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
|
||||||
.map_err(|e| format!("Failed to resize PTY {}: {}", session_id, e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Kill and remove a PTY session.
|
|
||||||
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
|
|
||||||
let (_, session) = self.sessions.remove(session_id)
|
|
||||||
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
|
|
||||||
if let Ok(mut child) = session.child.lock() {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_shells_returns_at_least_one() {
|
|
||||||
let svc = PtyService::new();
|
|
||||||
let shells = svc.list_shells();
|
|
||||||
assert!(!shells.is_empty(), "should find at least one shell");
|
|
||||||
for shell in &shells {
|
|
||||||
assert!(!shell.name.is_empty());
|
|
||||||
assert!(!shell.path.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn list_shells_no_duplicates() {
|
|
||||||
let svc = PtyService::new();
|
|
||||||
let shells = svc.list_shells();
|
|
||||||
let mut paths: Vec<&str> = shells.iter().map(|s| s.path.as_str()).collect();
|
|
||||||
let original_len = paths.len();
|
|
||||||
paths.sort();
|
|
||||||
paths.dedup();
|
|
||||||
assert_eq!(original_len, paths.len(), "shell list should not contain duplicates");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn disconnect_nonexistent_session_errors() {
|
|
||||||
let svc = PtyService::new();
|
|
||||||
assert!(svc.disconnect("nonexistent").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn write_nonexistent_session_errors() {
|
|
||||||
let svc = PtyService::new();
|
|
||||||
assert!(svc.write("nonexistent", b"hello").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resize_nonexistent_session_errors() {
|
|
||||||
let svc = PtyService::new();
|
|
||||||
assert!(svc.resize("nonexistent", 80, 24).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -8,12 +8,11 @@ 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;
|
||||||
@ -63,47 +62,32 @@ enum InputEvent {
|
|||||||
pressed: bool,
|
pressed: bool,
|
||||||
},
|
},
|
||||||
Clipboard(String),
|
Clipboard(String),
|
||||||
Resize { width: u16, height: u16 },
|
|
||||||
Disconnect,
|
Disconnect,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dirty rectangle from the last GraphicsUpdate — used for partial frame transfer.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DirtyRect {
|
|
||||||
pub x: u16,
|
|
||||||
pub y: u16,
|
|
||||||
pub width: u16,
|
|
||||||
pub height: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RdpSessionHandle {
|
struct RdpSessionHandle {
|
||||||
id: String,
|
id: String,
|
||||||
hostname: String,
|
hostname: String,
|
||||||
width: u16,
|
width: u16,
|
||||||
height: u16,
|
height: u16,
|
||||||
/// Frame buffer: RDP thread writes via RwLock write, IPC reads via RwLock read.
|
frame_buffer: Arc<TokioMutex<Vec<u8>>>,
|
||||||
front_buffer: Arc<std::sync::RwLock<Vec<u8>>>,
|
|
||||||
/// Accumulated dirty region since last get_frame — union of all GraphicsUpdate rects.
|
|
||||||
dirty_region: Arc<std::sync::Mutex<Option<DirtyRect>>>,
|
|
||||||
frame_dirty: Arc<AtomicBool>,
|
frame_dirty: Arc<AtomicBool>,
|
||||||
input_tx: mpsc::UnboundedSender<InputEvent>,
|
input_tx: mpsc::UnboundedSender<InputEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RdpService {
|
pub struct RdpService {
|
||||||
sessions: Arc<DashMap<String, Arc<RdpSessionHandle>>>,
|
sessions: DashMap<String, Arc<RdpSessionHandle>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RdpService {
|
impl RdpService {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
sessions: Arc::new(DashMap::new()),
|
sessions: DashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn connect(&self, config: RdpConfig, app_handle: tauri::AppHandle) -> Result<String, String> {
|
pub fn connect(&self, config: RdpConfig) -> 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();
|
||||||
@ -113,8 +97,7 @@ impl RdpService {
|
|||||||
for pixel in initial_buf.chunks_exact_mut(4) {
|
for pixel in initial_buf.chunks_exact_mut(4) {
|
||||||
pixel[3] = 255;
|
pixel[3] = 255;
|
||||||
}
|
}
|
||||||
let front_buffer = Arc::new(std::sync::RwLock::new(initial_buf));
|
let frame_buffer = Arc::new(TokioMutex::new(initial_buf));
|
||||||
let dirty_region = Arc::new(std::sync::Mutex::new(None));
|
|
||||||
let frame_dirty = Arc::new(AtomicBool::new(false));
|
let frame_dirty = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
let (input_tx, input_rx) = mpsc::unbounded_channel();
|
let (input_tx, input_rx) = mpsc::unbounded_channel();
|
||||||
@ -124,8 +107,7 @@ impl RdpService {
|
|||||||
hostname: hostname.clone(),
|
hostname: hostname.clone(),
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
front_buffer: front_buffer.clone(),
|
frame_buffer: frame_buffer.clone(),
|
||||||
dirty_region: dirty_region.clone(),
|
|
||||||
frame_dirty: frame_dirty.clone(),
|
frame_dirty: frame_dirty.clone(),
|
||||||
input_tx,
|
input_tx,
|
||||||
});
|
});
|
||||||
@ -137,7 +119,6 @@ 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()
|
||||||
@ -172,14 +153,11 @@ impl RdpService {
|
|||||||
if let Err(e) = run_active_session(
|
if let Err(e) = run_active_session(
|
||||||
connection_result,
|
connection_result,
|
||||||
framed,
|
framed,
|
||||||
front_buffer,
|
frame_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
|
||||||
{
|
{
|
||||||
@ -188,18 +166,6 @@ 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() {
|
||||||
@ -210,81 +176,29 @@ impl RdpService {
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
self.sessions.remove(&session_id);
|
self.sessions.remove(&session_id);
|
||||||
return Err("RDP connection thread panicked — check wraith.log for details".into());
|
return Err("RDP connection thread died unexpectedly".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(session_id)
|
Ok(session_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the dirty region since the last call. Returns (region_metadata, pixel_bytes).
|
pub async fn get_frame(&self, session_id: &str) -> Result<String, String> {
|
||||||
/// The pixel bytes contain only the dirty rectangle in row-major RGBA order.
|
|
||||||
/// If nothing changed, returns empty bytes. If the dirty region covers >50% of the
|
|
||||||
/// frame, falls back to full frame for efficiency (avoids row-by-row extraction).
|
|
||||||
pub fn get_frame(&self, session_id: &str) -> Result<(Option<DirtyRect>, Vec<u8>), String> {
|
|
||||||
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||||
if !handle.frame_dirty.swap(false, Ordering::Acquire) {
|
if !handle.frame_dirty.swap(false, Ordering::Relaxed) {
|
||||||
return Ok((None, Vec::new()));
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
let buf = handle.frame_buffer.lock().await;
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&*buf);
|
||||||
|
Ok(encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
let region = handle.dirty_region.lock().unwrap_or_else(|e| e.into_inner()).take();
|
pub async fn get_frame_raw(&self, session_id: &str) -> Result<Vec<u8>, String> {
|
||||||
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.front_buffer.read().unwrap_or_else(|e| e.into_inner());
|
let buf = handle.frame_buffer.lock().await;
|
||||||
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))
|
||||||
@ -300,19 +214,6 @@ 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);
|
||||||
@ -332,7 +233,7 @@ impl RdpService {
|
|||||||
|
|
||||||
impl Clone for RdpService {
|
impl Clone for RdpService {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
Self { sessions: self.sessions.clone() }
|
unreachable!("RdpService should not be cloned — access via State<AppState>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,11 +267,7 @@ 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::DISABLE_WALLPAPER
|
performance_flags: PerformanceFlags::default(),
|
||||||
| 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,
|
||||||
@ -400,7 +297,7 @@ async fn establish_connection(config: connector::Config, hostname: &str, port: u
|
|||||||
Ok((connection_result, upgraded_framed))
|
Ok((connection_result, upgraded_framed))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, front_buffer: Arc<std::sync::RwLock<Vec<u8>>>, dirty_region: Arc<std::sync::Mutex<Option<DirtyRect>>>, frame_dirty: Arc<AtomicBool>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, mut width: u16, mut height: u16, app_handle: tauri::AppHandle, session_id: String) -> Result<(), String> {
|
async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, frame_buffer: Arc<TokioMutex<Vec<u8>>>, frame_dirty: Arc<AtomicBool>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, width: u16, height: u16) -> 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);
|
||||||
@ -452,68 +349,17 @@ 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 rx = region.left as usize;
|
let mut buf = frame_buffer.lock().await;
|
||||||
let ry = region.top as usize;
|
|
||||||
let rr = (region.right as usize).saturating_add(1).min(width as usize);
|
|
||||||
let rb = (region.bottom as usize).saturating_add(1).min(height as usize);
|
|
||||||
let stride = width as usize * 4;
|
|
||||||
|
|
||||||
// Copy only the dirty rectangle rows from decoded image → front buffer
|
|
||||||
{
|
|
||||||
let src = image.data();
|
let src = image.data();
|
||||||
let mut front = front_buffer.write().unwrap_or_else(|e| e.into_inner());
|
if src.len() == buf.len() { buf.copy_from_slice(src); } else { *buf = src.to_vec(); }
|
||||||
for row in ry..rb {
|
frame_dirty.store(true, Ordering::Relaxed);
|
||||||
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(()); }
|
||||||
|
|||||||
@ -1,255 +0,0 @@
|
|||||||
//! 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 1–3 ASCII digits (e.g. "192.168.1").
|
|
||||||
/// Returns an error string if the format is invalid.
|
|
||||||
fn validate_subnet(subnet: &str) -> Result<(), String> {
|
|
||||||
let parts: Vec<&str> = subnet.split('.').collect();
|
|
||||||
if parts.len() != 3 {
|
|
||||||
return Err(format!(
|
|
||||||
"Invalid subnet '{}': expected three octets (e.g. 192.168.1)",
|
|
||||||
subnet
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for part in &parts {
|
|
||||||
if part.is_empty() || part.len() > 3 || !part.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
return Err(format!(
|
|
||||||
"Invalid subnet '{}': each octet must be 1–3 decimal digits",
|
|
||||||
subnet
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Discover hosts on the remote network using ARP table and ping sweep.
|
|
||||||
pub async fn scan_network(
|
|
||||||
handle: &Arc<TokioMutex<Handle<SshClient>>>,
|
|
||||||
subnet: &str,
|
|
||||||
) -> Result<Vec<DiscoveredHost>, String> {
|
|
||||||
// Validate subnet format before using it in remote shell commands.
|
|
||||||
validate_subnet(subnet)?;
|
|
||||||
|
|
||||||
// Script that works on Linux and macOS:
|
|
||||||
// 1. Ping sweep the subnet to populate ARP cache
|
|
||||||
// 2. Read ARP table for IP/MAC pairs
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
@ -8,7 +8,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
//! 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};
|
||||||
@ -34,6 +35,9 @@ 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.
|
||||||
@ -50,10 +54,12 @@ 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",
|
||||||
@ -71,6 +77,9 @@ fn format_mtime(unix_secs: u32) -> String {
|
|||||||
_ => "???",
|
_ => "???",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Suppress unused variable warning — st is only used as a sanity anchor.
|
||||||
|
let _ = st;
|
||||||
|
|
||||||
format!("{} {:2} {:02}:{:02}", month, d, hours, minutes)
|
format!("{} {:2} {:02}:{:02}", month, d, hours, minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,18 +94,17 @@ 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: Arc<DashMap<String, Arc<TokioMutex<SftpSession>>>>,
|
clients: DashMap<String, Arc<TokioMutex<SftpSession>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SftpService {
|
impl SftpService {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
clients: Arc::new(DashMap::new()),
|
clients: DashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,7 +318,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.value().clone())
|
.map(|r| r.clone())
|
||||||
.ok_or_else(|| format!("No SFTP client for session {}", session_id))
|
.ok_or_else(|| format!("No SFTP client for session {}", session_id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,6 @@ 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;
|
||||||
|
|
||||||
@ -40,15 +39,13 @@ 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 cancelled via the `CancellationToken`, or until the
|
/// The task runs until the SSH connection is closed or the channel cannot
|
||||||
/// SSH connection is closed or the channel cannot be opened.
|
/// be opened. CWD changes are emitted as `ssh:cwd:{session_id}` events.
|
||||||
/// 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();
|
||||||
|
|
||||||
@ -59,10 +56,6 @@ 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.
|
||||||
@ -126,11 +119,8 @@ impl CwdTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait 2 seconds before the next poll, or cancel.
|
// Wait 2 seconds before the next poll.
|
||||||
tokio::select! {
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||||
_ = 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);
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
//! Shared SSH exec-channel helper used by commands, MCP handlers, and tools.
|
|
||||||
//!
|
|
||||||
//! Opens a one-shot exec channel on an existing SSH handle, runs `cmd`, collects
|
|
||||||
//! all stdout/stderr, and returns it as a `String`. The caller is responsible
|
|
||||||
//! for ensuring the session is still alive.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
|
||||||
|
|
||||||
use crate::ssh::session::SshClient;
|
|
||||||
|
|
||||||
/// Execute `cmd` on a separate exec channel and return all output as a `String`.
|
|
||||||
///
|
|
||||||
/// Locks the handle for only as long as it takes to open the channel, then
|
|
||||||
/// releases it before reading — this avoids holding the lock while waiting on
|
|
||||||
/// remote I/O.
|
|
||||||
pub async fn exec_on_session(
|
|
||||||
handle: &Arc<TokioMutex<russh::client::Handle<SshClient>>>,
|
|
||||||
cmd: &str,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let mut channel = {
|
|
||||||
let h = handle.lock().await;
|
|
||||||
h.channel_open_session()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Exec channel failed: {}", e))?
|
|
||||||
};
|
|
||||||
|
|
||||||
channel
|
|
||||||
.exec(true, cmd)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Exec failed: {}", e))?;
|
|
||||||
|
|
||||||
let mut output = String::new();
|
|
||||||
loop {
|
|
||||||
match channel.wait().await {
|
|
||||||
Some(russh::ChannelMsg::Data { ref data }) => {
|
|
||||||
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
|
|
||||||
output.push_str(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(russh::ChannelMsg::Eof)
|
|
||||||
| Some(russh::ChannelMsg::Close)
|
|
||||||
| None => break,
|
|
||||||
Some(russh::ChannelMsg::ExitStatus { .. }) => {}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
@ -1,5 +1,3 @@
|
|||||||
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;
|
|
||||||
|
|||||||
@ -1,197 +0,0 @@
|
|||||||
//! 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)
|
|
||||||
}
|
|
||||||
@ -12,12 +12,9 @@ use tokio::sync::Mutex as TokioMutex;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
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),
|
||||||
@ -48,7 +45,6 @@ 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 {
|
||||||
@ -76,20 +72,18 @@ impl client::Handler for SshClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SshService {
|
pub struct SshService {
|
||||||
sessions: Arc<DashMap<String, Arc<SshSession>>>,
|
sessions: 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: Arc::new(DashMap::new()), db }
|
Self { sessions: 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, error_watcher: &ErrorWatcher) -> 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) -> 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 };
|
||||||
|
|
||||||
@ -106,21 +100,7 @@ impl SshService {
|
|||||||
.map_err(|e| format!("SSH authentication error: {}", e))?
|
.map_err(|e| format!("SSH authentication error: {}", e))?
|
||||||
}
|
}
|
||||||
AuthMethod::Key { ref private_key_pem, ref passphrase } => {
|
AuthMethod::Key { ref private_key_pem, ref passphrase } => {
|
||||||
let pem = resolve_private_key(private_key_pem)?;
|
let key = russh::keys::decode_secret_key(private_key_pem, passphrase.as_deref()).map_err(|e| format!("Failed to decode private key: {}", e))?;
|
||||||
let key = match russh::keys::decode_secret_key(&pem, passphrase.as_deref()) {
|
|
||||||
Ok(k) => k,
|
|
||||||
Err(_) if pem.contains("BEGIN EC PRIVATE KEY") => {
|
|
||||||
// EC keys in SEC1 format — decrypt and convert to PKCS#8
|
|
||||||
let converted = convert_ec_key_to_pkcs8(&pem, passphrase.as_deref())?;
|
|
||||||
russh::keys::decode_secret_key(&converted, None).map_err(|e| {
|
|
||||||
format!("Failed to decode converted EC key: {}", e)
|
|
||||||
})?
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let first_line = pem.lines().next().unwrap_or("<empty>");
|
|
||||||
return Err(format!("Failed to decode private key (header: '{}'): {}", first_line, e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(10), handle.authenticate_publickey(username, Arc::new(key)))
|
tokio::time::timeout(std::time::Duration::from_secs(10), handle.authenticate_publickey(username, Arc::new(key)))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| "SSH key authentication timed out after 10s".to_string())?
|
.map_err(|_| "SSH key authentication timed out after 10s".to_string())?
|
||||||
@ -137,11 +117,10 @@ 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(), cancel_token.clone());
|
cwd_tracker.start(handle.clone(), app_handle.clone(), session_id.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), cancel_token: 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) });
|
||||||
self.sessions.insert(session_id.clone(), session);
|
self.sessions.insert(session_id.clone(), session);
|
||||||
|
|
||||||
{ let h = handle.lock().await;
|
{ let h = handle.lock().await;
|
||||||
@ -154,32 +133,6 @@ impl SshService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wraith_log!("[SSH] Connected and authenticated: {}", session_id);
|
|
||||||
|
|
||||||
// Create scrollback buffer for MCP terminal_read
|
|
||||||
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.
|
||||||
let sid = session_id.clone();
|
let sid = session_id.clone();
|
||||||
@ -190,16 +143,10 @@ impl SshService {
|
|||||||
msg = channel.wait() => {
|
msg = channel.wait() => {
|
||||||
match msg {
|
match msg {
|
||||||
Some(ChannelMsg::Data { ref data }) => {
|
Some(ChannelMsg::Data { ref data }) => {
|
||||||
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);
|
||||||
}
|
}
|
||||||
Some(ChannelMsg::ExtendedData { ref data, .. }) => {
|
Some(ChannelMsg::ExtendedData { ref data, .. }) => {
|
||||||
scrollback_buf.push(data.as_ref());
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
@ -249,8 +196,6 @@ 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);
|
||||||
@ -258,7 +203,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(|r| r.value().clone())
|
self.sessions.get(session_id).map(|entry| entry.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||||
@ -268,193 +213,3 @@ impl SshService {
|
|||||||
}).collect()
|
}).collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt a legacy PEM-encrypted EC key and re-encode as unencrypted PKCS#8.
|
|
||||||
/// Handles -----BEGIN EC PRIVATE KEY----- with Proc-Type/DEK-Info headers.
|
|
||||||
/// Uses the same MD5-based EVP_BytesToKey KDF that OpenSSL/russh use for RSA.
|
|
||||||
fn convert_ec_key_to_pkcs8(pem_text: &str, passphrase: Option<&str>) -> Result<String, String> {
|
|
||||||
use aes::cipher::{BlockDecryptMut, KeyIvInit};
|
|
||||||
|
|
||||||
// Parse PEM to extract headers and base64 body
|
|
||||||
let parsed = pem::parse(pem_text)
|
|
||||||
.map_err(|e| format!("Failed to parse PEM: {}", e))?;
|
|
||||||
|
|
||||||
if parsed.tag() != "EC PRIVATE KEY" {
|
|
||||||
return Err(format!("Expected EC PRIVATE KEY, got {}", parsed.tag()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let der_bytes = parsed.contents();
|
|
||||||
|
|
||||||
// Check if the PEM has encryption headers (Proc-Type: 4,ENCRYPTED)
|
|
||||||
let is_encrypted = pem_text.contains("Proc-Type: 4,ENCRYPTED");
|
|
||||||
|
|
||||||
let decrypted = if is_encrypted {
|
|
||||||
let pass = passphrase
|
|
||||||
.ok_or_else(|| "EC key is encrypted but no passphrase provided".to_string())?;
|
|
||||||
|
|
||||||
// Extract IV from DEK-Info header
|
|
||||||
let iv = extract_dek_iv(pem_text)?;
|
|
||||||
|
|
||||||
// EVP_BytesToKey: key = MD5(password + iv[:8])
|
|
||||||
let mut ctx = md5::Context::new();
|
|
||||||
ctx.consume(pass.as_bytes());
|
|
||||||
ctx.consume(&iv[..8]);
|
|
||||||
let key_bytes = ctx.compute();
|
|
||||||
|
|
||||||
// Decrypt AES-128-CBC
|
|
||||||
let decryptor = cbc::Decryptor::<aes::Aes128>::new_from_slices(&key_bytes.0, &iv)
|
|
||||||
.map_err(|e| format!("AES init failed: {}", e))?;
|
|
||||||
let mut buf = der_bytes.to_vec();
|
|
||||||
let decrypted = decryptor
|
|
||||||
.decrypt_padded_mut::<block_padding::Pkcs7>(&mut buf)
|
|
||||||
.map_err(|_| "Decryption failed — wrong passphrase?".to_string())?;
|
|
||||||
decrypted.to_vec()
|
|
||||||
} else {
|
|
||||||
der_bytes.to_vec()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse SEC1 DER → re-encode as PKCS#8 PEM
|
|
||||||
use sec1::der::Decode;
|
|
||||||
let ec_key = sec1::EcPrivateKey::from_der(&decrypted)
|
|
||||||
.map_err(|e| format!("Failed to parse EC key DER: {}", e))?;
|
|
||||||
|
|
||||||
// Build PKCS#8 wrapper around the SEC1 key
|
|
||||||
// The OID for the curve is embedded in the SEC1 parameters field
|
|
||||||
let oid = ec_key.parameters
|
|
||||||
.map(|p| { let sec1::EcParameters::NamedCurve(oid) = p; oid })
|
|
||||||
.ok_or_else(|| "EC key missing curve OID in parameters".to_string())?;
|
|
||||||
|
|
||||||
// Re-encode as PKCS#8 OneAsymmetricKey
|
|
||||||
use pkcs8::der::Encode;
|
|
||||||
let inner_der = ec_key.to_der()
|
|
||||||
.map_err(|e| format!("Failed to re-encode EC key: {}", e))?;
|
|
||||||
|
|
||||||
let algorithm = pkcs8::AlgorithmIdentifierRef {
|
|
||||||
oid: pkcs8::ObjectIdentifier::new("1.2.840.10045.2.1")
|
|
||||||
.map_err(|e| format!("Bad EC OID: {}", e))?,
|
|
||||||
parameters: Some(
|
|
||||||
pkcs8::der::asn1::AnyRef::new(pkcs8::der::Tag::ObjectIdentifier, oid.as_bytes())
|
|
||||||
.map_err(|e| format!("Bad curve param: {}", e))?
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
let pkcs8_info = pkcs8::PrivateKeyInfo {
|
|
||||||
algorithm,
|
|
||||||
private_key: &inner_der,
|
|
||||||
public_key: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let pkcs8_der = pkcs8_info.to_der()
|
|
||||||
.map_err(|e| format!("Failed to encode PKCS#8: {}", e))?;
|
|
||||||
|
|
||||||
// Wrap in PEM
|
|
||||||
let pkcs8_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", pkcs8_der));
|
|
||||||
Ok(pkcs8_pem)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the 16-byte IV from a DEK-Info: AES-128-CBC,<hex> header.
|
|
||||||
fn extract_dek_iv(pem_text: &str) -> Result<[u8; 16], String> {
|
|
||||||
for line in pem_text.lines() {
|
|
||||||
if let Some(rest) = line.strip_prefix("DEK-Info: AES-128-CBC,") {
|
|
||||||
let iv_hex = rest.trim();
|
|
||||||
let iv_bytes = hex::decode(iv_hex)
|
|
||||||
.map_err(|e| format!("Invalid DEK-Info IV hex: {}", e))?;
|
|
||||||
if iv_bytes.len() != 16 {
|
|
||||||
return Err(format!("IV must be 16 bytes, got {}", iv_bytes.len()));
|
|
||||||
}
|
|
||||||
let mut iv = [0u8; 16];
|
|
||||||
iv.copy_from_slice(&iv_bytes);
|
|
||||||
return Ok(iv);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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.
|
|
||||||
/// If it looks like a file path, read the file. Strip BOM and normalize.
|
|
||||||
fn resolve_private_key(input: &str) -> Result<String, String> {
|
|
||||||
let input = input.trim();
|
|
||||||
// Strip UTF-8 BOM if present
|
|
||||||
let input = input.strip_prefix('\u{feff}').unwrap_or(input);
|
|
||||||
|
|
||||||
if input.starts_with("-----BEGIN ") {
|
|
||||||
return Ok(input.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Doesn't look like PEM — try as file path
|
|
||||||
let path = if input.starts_with('~') {
|
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
|
||||||
input.replacen('~', &home, 1)
|
|
||||||
} else {
|
|
||||||
input.to_string()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
input.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let path = std::path::Path::new(&path);
|
|
||||||
if path.exists() && path.is_file() {
|
|
||||||
std::fs::read_to_string(path)
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.map_err(|e| format!("Failed to read private key file '{}': {}", path.display(), e))
|
|
||||||
} else if input.contains('/') || input.contains('\\') {
|
|
||||||
Err(format!("Private key file not found: {}", input))
|
|
||||||
} else {
|
|
||||||
// Neither PEM nor a path — pass through and let russh give its error
|
|
||||||
Ok(input.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -59,7 +59,6 @@ struct BuiltinTheme {
|
|||||||
|
|
||||||
// ── service ───────────────────────────────────────────────────────────────────
|
// ── service ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ThemeService {
|
pub struct ThemeService {
|
||||||
db: Database,
|
db: Database,
|
||||||
}
|
}
|
||||||
@ -254,7 +253,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,
|
||||||
],
|
],
|
||||||
) {
|
) {
|
||||||
wraith_log!("theme::seed_builtins: failed to seed '{}': {}", t.name, e);
|
eprintln!("theme::seed_builtins: failed to seed '{}': {}", t.name, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -273,7 +272,7 @@ impl ThemeService {
|
|||||||
) {
|
) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
wraith_log!("theme::list: failed to prepare query: {}", e);
|
eprintln!("theme::list: failed to prepare query: {}", e);
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -281,12 +280,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| wraith_log!("theme::list: row error: {}", e))
|
r.map_err(|e| eprintln!("theme::list: row error: {}", e))
|
||||||
.ok()
|
.ok()
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
wraith_log!("theme::list: query failed: {}", e);
|
eprintln!("theme::list: query failed: {}", e);
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
//! 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('\'', "'\\''"))
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@ 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
|
||||||
@ -22,18 +21,18 @@ use zeroize::Zeroizing;
|
|||||||
/// 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: Zeroizing<[u8; 32]>,
|
key: [u8; 32],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VaultService {
|
impl VaultService {
|
||||||
pub fn new(key: Zeroizing<[u8; 32]>) -> Self {
|
pub fn new(key: [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).
|
||||||
@ -72,7 +71,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);
|
||||||
|
|
||||||
@ -96,7 +95,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]) -> Zeroizing<[u8; 32]> {
|
pub fn derive_key(password: &str, salt: &[u8]) -> [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
|
||||||
@ -107,9 +106,9 @@ pub fn derive_key(password: &str, salt: &[u8]) -> Zeroizing<[u8; 32]> {
|
|||||||
|
|
||||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||||
|
|
||||||
let mut output_key = Zeroizing::new([0u8; 32]);
|
let mut output_key = [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
|
||||||
|
|||||||
@ -24,7 +24,6 @@ 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,
|
||||||
}
|
}
|
||||||
@ -48,7 +47,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| wraith_log!("workspace::load: failed to deserialize snapshot: {e}"))
|
.map_err(|e| eprintln!("workspace::load: failed to deserialize snapshot: {e}"))
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,15 +17,13 @@
|
|||||||
"minWidth": 800,
|
"minWidth": 800,
|
||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"decorations": true,
|
"decorations": true,
|
||||||
"resizable": true,
|
"resizable": true
|
||||||
"dragDropEnabled": false,
|
|
||||||
"additionalBrowserArgs": "--enable-gpu-rasterization --enable-zero-copy --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null
|
||||||
},
|
},
|
||||||
"withGlobalTauri": false
|
"withGlobalTauri": true
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
@ -41,20 +39,13 @@
|
|||||||
"nsis": {
|
"nsis": {
|
||||||
"displayLanguageSelector": false,
|
"displayLanguageSelector": false,
|
||||||
"installerIcon": "icons/icon.ico",
|
"installerIcon": "icons/icon.ico",
|
||||||
"installMode": "perMachine",
|
"installMode": "perMachine"
|
||||||
"installerHooks": "./windows/hooks.nsh"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"shell": {
|
"shell": {
|
||||||
"open": true
|
"open": true
|
||||||
},
|
|
||||||
"updater": {
|
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNCRkQ2OUY2OEY0Q0ZFQkYKUldTLy9reVA5bW45T3dUQ1R5OFNCenVhL2srTXlLcHR4cFNaeCtJSmJUSTZKSUNHVTRIbWZwanEK",
|
|
||||||
"endpoints": [
|
|
||||||
"https://files.command.vigilcyber.com/wraith/update.json"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
; Wraith NSIS installer hooks
|
|
||||||
; Desktop shortcut is OFF by default — Tauri creates one, we remove it.
|
|
||||||
|
|
||||||
!macro NSIS_HOOK_POSTINSTALL
|
|
||||||
Delete "$DESKTOP\${MAINBINARYNAME}.lnk"
|
|
||||||
!macroend
|
|
||||||
60
src/App.vue
60
src/App.vue
@ -1,67 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onErrorCaptured, defineAsyncComponent } from "vue";
|
import { onMounted } from "vue";
|
||||||
import { useAppStore } from "@/stores/app.store";
|
import { useAppStore } from "@/stores/app.store";
|
||||||
import UnlockLayout from "@/layouts/UnlockLayout.vue";
|
import UnlockLayout from "@/layouts/UnlockLayout.vue";
|
||||||
import ToolWindow from "@/components/tools/ToolWindow.vue";
|
|
||||||
|
|
||||||
const MainLayout = defineAsyncComponent({
|
// MainLayout is the full app shell — lazy-load it so the unlock screen is
|
||||||
loader: () => import("@/layouts/MainLayout.vue"),
|
// instant and the heavy editor/terminal code only lands after auth.
|
||||||
onError(error) { console.error("[App] MainLayout load failed:", error); },
|
import { defineAsyncComponent } from "vue";
|
||||||
});
|
const MainLayout = defineAsyncComponent(
|
||||||
const DetachedSession = defineAsyncComponent({
|
() => import("@/layouts/MainLayout.vue")
|
||||||
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 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">
|
<div class="app-root">
|
||||||
{{ appError }}
|
<!-- Show the unlock/create-vault screen until the store confirms we're in -->
|
||||||
</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>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
.terminal-container {
|
.terminal-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--wraith-bg-primary);
|
background: var(--wraith-bg-primary);
|
||||||
@ -20,16 +20,14 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* WKWebView focus fix: xterm.js hides its helper textarea with opacity: 0,
|
/* Selection styling */
|
||||||
width/height: 0, left: -9999em. macOS WKWebView doesn't reliably focus
|
.terminal-container .xterm-selection div {
|
||||||
elements with zero dimensions positioned off-screen. Override to keep it
|
background-color: rgba(88, 166, 255, 0.3) !important;
|
||||||
within the viewport with non-zero dimensions so focus events fire. */
|
}
|
||||||
.terminal-container .xterm .xterm-helper-textarea {
|
|
||||||
left: 0 !important;
|
/* Cursor styling */
|
||||||
top: 0 !important;
|
.terminal-container .xterm-cursor-layer {
|
||||||
width: 1px !important;
|
z-index: 4;
|
||||||
height: 1px !important;
|
|
||||||
opacity: 0.01 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar inside terminal */
|
/* Scrollbar inside terminal */
|
||||||
|
|||||||
@ -1,306 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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 -->
|
|
||||||
<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>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<select
|
|
||||||
v-model="selectedShell"
|
|
||||||
class="bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-1.5 py-0.5 text-[10px] text-[var(--wraith-text-secondary)] outline-none"
|
|
||||||
:disabled="connected"
|
|
||||||
>
|
|
||||||
<option v-for="shell in shells" :key="shell.path" :value="shell.path">
|
|
||||||
{{ shell.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
v-if="!connected"
|
|
||||||
class="px-2 py-0.5 text-[10px] font-bold rounded bg-[var(--wraith-accent-blue)] text-black cursor-pointer"
|
|
||||||
:disabled="!selectedShell"
|
|
||||||
@click="launch"
|
|
||||||
>
|
|
||||||
Launch
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="px-2 py-0.5 text-[10px] font-bold rounded bg-[var(--wraith-accent-red,#f85149)] text-white cursor-pointer"
|
|
||||||
@click="kill"
|
|
||||||
>
|
|
||||||
Kill
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<!-- Terminal area -->
|
|
||||||
<div v-if="connected" ref="containerRef" class="flex-1 min-h-0" />
|
|
||||||
|
|
||||||
<!-- Session ended prompt -->
|
|
||||||
<div v-else-if="sessionEnded" class="flex-1 flex flex-col items-center justify-center gap-3 p-4">
|
|
||||||
<p class="text-xs text-[var(--wraith-text-muted)]">Session ended</p>
|
|
||||||
<button
|
|
||||||
class="px-3 py-1.5 text-xs rounded bg-[var(--wraith-accent-blue)] text-black font-bold cursor-pointer"
|
|
||||||
@click="launch"
|
|
||||||
>
|
|
||||||
Relaunch
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty state with quick-launch presets -->
|
|
||||||
<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">
|
|
||||||
Select a shell and click Launch, or use a preset:
|
|
||||||
</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">
|
|
||||||
Configure presets in Settings → AI Copilot
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, nextTick, onMounted, onBeforeUnmount } from "vue";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
|
||||||
import { useTerminal } from "@/composables/useTerminal";
|
|
||||||
|
|
||||||
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 selectedShell = ref("");
|
|
||||||
const connected = ref(false);
|
|
||||||
const sessionEnded = ref(false);
|
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
let sessionId = "";
|
|
||||||
let terminalInstance: ReturnType<typeof useTerminal> | null = null;
|
|
||||||
let closeUnlisten: UnlistenFn | null = null;
|
|
||||||
|
|
||||||
async function loadShells(): Promise<void> {
|
|
||||||
try {
|
|
||||||
shells.value = await invoke<ShellInfo[]>("list_available_shells");
|
|
||||||
if (shells.value.length > 0 && !selectedShell.value) {
|
|
||||||
selectedShell.value = shells.value[0].path;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to list shells:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
|
||||||
if (!selectedShell.value) return;
|
|
||||||
sessionEnded.value = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
sessionId = await invoke<string>("spawn_local_shell", {
|
|
||||||
shellPath: selectedShell.value,
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
connected.value = true;
|
|
||||||
|
|
||||||
// Instantiate terminal synchronously (before any further awaits) now that
|
|
||||||
// sessionId is known. Cleanup is owned by this component's onBeforeUnmount.
|
|
||||||
terminalInstance = useTerminal(sessionId, "pty");
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
if (containerRef.value && terminalInstance) {
|
|
||||||
terminalInstance.mount(containerRef.value);
|
|
||||||
|
|
||||||
// Fit after mount to get real dimensions, then resize the PTY
|
|
||||||
setTimeout(() => {
|
|
||||||
if (terminalInstance) {
|
|
||||||
terminalInstance.fit();
|
|
||||||
const term = terminalInstance.terminal;
|
|
||||||
invoke("pty_resize", {
|
|
||||||
sessionId,
|
|
||||||
cols: term.cols,
|
|
||||||
rows: term.rows,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for shell exit
|
|
||||||
closeUnlisten = await listen(`pty:close:${sessionId}`, () => {
|
|
||||||
cleanup();
|
|
||||||
sessionEnded.value = true;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to spawn shell:", err);
|
|
||||||
connected.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
if (sessionId) {
|
|
||||||
invoke("disconnect_pty", { sessionId }).catch(() => {});
|
|
||||||
}
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup(): void {
|
|
||||||
if (terminalInstance) {
|
|
||||||
terminalInstance.destroy();
|
|
||||||
terminalInstance = null;
|
|
||||||
}
|
|
||||||
if (closeUnlisten) {
|
|
||||||
closeUnlisten();
|
|
||||||
closeUnlisten = null;
|
|
||||||
}
|
|
||||||
connected.value = false;
|
|
||||||
sessionId = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadShells();
|
|
||||||
loadPresets();
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (connected.value) kill();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
124
src/components/ai/GeminiPanel.vue
Normal file
124
src/components/ai/GeminiPanel.vue
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
const messages = ref<{role: string, content: string}[]>([]);
|
||||||
|
const input = ref("");
|
||||||
|
const isAuthenticated = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const authType = ref<"ApiKey" | "ServiceAccount" | "GoogleAccount">("ApiKey");
|
||||||
|
const apiKey = ref("");
|
||||||
|
const saJson = ref("");
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
isAuthenticated.value = await invoke("is_gemini_authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuth() {
|
||||||
|
let authValue: any = null;
|
||||||
|
if (authType.value === "ApiKey") authValue = { type: "ApiKey", value: apiKey.value };
|
||||||
|
else if (authType.value === "ServiceAccount") authValue = { type: "ServiceAccount", value: saJson.value };
|
||||||
|
|
||||||
|
if (!authValue) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("set_gemini_auth", { auth: authValue });
|
||||||
|
isAuthenticated.value = true;
|
||||||
|
} catch (e) {
|
||||||
|
alert(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!input.value.trim() || loading.value) return;
|
||||||
|
const userMsg = input.value;
|
||||||
|
messages.value.push({ role: "user", content: userMsg });
|
||||||
|
input.value = "";
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await invoke<string>("gemini_chat", { message: userMsg });
|
||||||
|
messages.value.push({ role: "model", content: response });
|
||||||
|
} catch (e) {
|
||||||
|
messages.value.push({ role: "error", content: String(e) });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(checkAuth);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] w-80">
|
||||||
|
<div class="p-3 border-b border-[var(--wraith-border)] flex items-center justify-between">
|
||||||
|
<span class="text-xs font-bold tracking-widest text-[var(--wraith-accent-blue)]">GEMINI XO</span>
|
||||||
|
<button v-if="isAuthenticated" @click="isAuthenticated = false" class="text-[10px] text-[var(--wraith-text-muted)] hover:text-white">REAUTH</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isAuthenticated" class="p-4 space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[10px] uppercase text-[var(--wraith-text-secondary)]">Auth Method</label>
|
||||||
|
<select v-model="authType" class="w-full bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1 text-xs outline-none">
|
||||||
|
<option value="ApiKey">API Key</option>
|
||||||
|
<option value="ServiceAccount">Service Account</option>
|
||||||
|
<option value="GoogleAccount">Google Account (OAuth)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="authType === 'ApiKey'" class="space-y-2">
|
||||||
|
<label class="text-[10px] uppercase text-[var(--wraith-text-secondary)]">API Key</label>
|
||||||
|
<input v-model="apiKey" type="password" class="w-full bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1 text-xs outline-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="authType === 'ServiceAccount'" class="space-y-2">
|
||||||
|
<label class="text-[10px] uppercase text-[var(--wraith-text-secondary)]">JSON Config</label>
|
||||||
|
<textarea v-model="saJson" rows="5" class="w-full bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1 text-xs outline-none resize-none"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="authType === 'GoogleAccount'" class="p-4 bg-[var(--wraith-bg-tertiary)] rounded-lg text-center border border-dashed border-[var(--wraith-border)]">
|
||||||
|
<p class="text-[10px] text-[var(--wraith-text-muted)] mb-3">OAuth2 PKCE flow not yet active in this stub</p>
|
||||||
|
<button disabled class="w-full py-2 bg-white text-black font-bold rounded text-xs opacity-50">SIGN IN WITH GOOGLE</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="authType !== 'GoogleAccount'" @click="handleAuth" class="w-full py-2 bg-[var(--wraith-accent-blue)] text-black font-bold rounded text-xs">ACTIVATE XO</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex-1 overflow-y-auto p-3 space-y-4">
|
||||||
|
<div v-if="messages.length === 0" class="text-center text-[10px] text-[var(--wraith-text-muted)] pt-8">
|
||||||
|
<p class="mb-1">Gemini XO ready.</p>
|
||||||
|
<p>Ask me anything about your sessions.</p>
|
||||||
|
</div>
|
||||||
|
<div v-for="(msg, i) in messages" :key="i"
|
||||||
|
:class="['text-xs rounded-lg px-3 py-2 max-w-[90%]',
|
||||||
|
msg.role === 'user' ? 'bg-[var(--wraith-accent-blue)]/15 text-[var(--wraith-text-primary)] ml-auto' : '',
|
||||||
|
msg.role === 'model' ? 'bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-primary)]' : '',
|
||||||
|
msg.role === 'error' ? 'bg-red-500/15 text-red-400' : '']">
|
||||||
|
{{ msg.content }}
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="text-xs text-[var(--wraith-text-muted)] animate-pulse">Thinking...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 border-t border-[var(--wraith-border)]">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ask the XO..."
|
||||||
|
class="flex-1 bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1.5 text-xs outline-none focus:border-[var(--wraith-accent-blue)]"
|
||||||
|
@keyup.enter="handleSend"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="handleSend"
|
||||||
|
:disabled="loading || !input.trim()"
|
||||||
|
class="px-3 py-1.5 bg-[var(--wraith-accent-blue)] text-black font-bold rounded text-xs disabled:opacity-40"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -116,9 +116,9 @@ const connectionStore = useConnectionStore();
|
|||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"open-import": [];
|
(e: "open-import"): void;
|
||||||
"open-settings": [];
|
(e: "open-settings"): void;
|
||||||
"open-new-connection": [protocol?: "ssh" | "rdp"];
|
(e: "open-new-connection", protocol?: "ssh" | "rdp"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const actions: PaletteAction[] = [
|
const actions: PaletteAction[] = [
|
||||||
|
|||||||
@ -154,56 +154,6 @@
|
|||||||
</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)"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</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>
|
||||||
@ -233,30 +183,6 @@
|
|||||||
</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="#"
|
||||||
@ -295,46 +221,12 @@
|
|||||||
<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" | "copilot" | "about";
|
type Section = "general" | "terminal" | "vault" | "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 = [
|
||||||
@ -353,11 +245,6 @@ 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",
|
||||||
@ -386,9 +273,6 @@ 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" }),
|
||||||
@ -422,16 +306,9 @@ 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) => {
|
(val) => invoke("set_setting", { key: "sidebar_width", value: String(val) }).catch(console.error),
|
||||||
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,
|
||||||
@ -449,33 +326,6 @@ 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 {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-[48px] flex items-center justify-between px-6 bg-[var(--wraith-bg-secondary)] border-t border-[var(--wraith-border)] text-base text-[var(--wraith-text-muted)] shrink-0">
|
<div class="h-6 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-t border-[var(--wraith-border)] text-[10px] text-[var(--wraith-text-muted)] shrink-0">
|
||||||
<!-- Left: connection info -->
|
<!-- 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<{
|
||||||
"open-theme-picker": [];
|
(e: "open-theme-picker"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const connectionInfo = computed(() => {
|
const connectionInfo = computed(() => {
|
||||||
|
|||||||
@ -112,8 +112,6 @@ export interface ThemeDefinition {
|
|||||||
brightMagenta: string;
|
brightMagenta: string;
|
||||||
brightCyan: string;
|
brightCyan: string;
|
||||||
brightWhite: string;
|
brightWhite: string;
|
||||||
selectionBackground?: string;
|
|
||||||
selectionForeground?: string;
|
|
||||||
isBuiltin?: boolean;
|
isBuiltin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -270,27 +270,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Private Key (PEM)</label>
|
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Private Key (PEM)</label>
|
||||||
<div class="flex gap-2 mb-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-3 py-1.5 text-xs rounded bg-[#21262d] border border-[#30363d] text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] hover:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
|
|
||||||
@click="browseKeyFile"
|
|
||||||
>
|
|
||||||
Browse...
|
|
||||||
</button>
|
|
||||||
<span v-if="keyFileName" class="text-xs text-[var(--wraith-text-muted)] self-center truncate">{{ keyFileName }}</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref="keyFileInputRef"
|
|
||||||
type="file"
|
|
||||||
class="hidden"
|
|
||||||
accept=".pem,.key,.pub,.id_rsa,.id_ed25519,.id_ecdsa,.ppk"
|
|
||||||
@change="loadKeyFile"
|
|
||||||
/>
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newCred.privateKeyPEM"
|
v-model="newCred.privateKeyPEM"
|
||||||
rows="5"
|
rows="5"
|
||||||
placeholder="Paste key or use Browse to load from file"
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||||
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors resize-none font-mono"
|
class="w-full px-3 py-2 text-sm rounded bg-[#161b22] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors resize-none font-mono"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
/>
|
/>
|
||||||
@ -402,25 +385,6 @@ const newCred = ref<NewCredForm>({
|
|||||||
passphrase: "",
|
passphrase: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSH key file picker
|
|
||||||
const keyFileInputRef = ref<HTMLInputElement | null>(null);
|
|
||||||
const keyFileName = ref("");
|
|
||||||
|
|
||||||
function browseKeyFile(): void {
|
|
||||||
keyFileInputRef.value?.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadKeyFile(event: Event): void {
|
|
||||||
const file = (event.target as HTMLInputElement).files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
keyFileName.value = file.name;
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
newCred.value.privateKeyPEM = (reader.result as string).trim();
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = ref<ConnectionForm>({
|
const form = ref<ConnectionForm>({
|
||||||
name: "",
|
name: "",
|
||||||
hostname: "",
|
hostname: "",
|
||||||
@ -467,7 +431,6 @@ function setProtocol(protocol: "ssh" | "rdp"): void {
|
|||||||
function resetNewCredForm(): void {
|
function resetNewCredForm(): void {
|
||||||
newCred.value = { name: "", username: "", password: "", privateKeyPEM: "", passphrase: "" };
|
newCred.value = { name: "", username: "", password: "", privateKeyPEM: "", passphrase: "" };
|
||||||
newCredError.value = "";
|
newCredError.value = "";
|
||||||
keyFileName.value = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSelectedCredential(): Promise<void> {
|
async function deleteSelectedCredential(): Promise<void> {
|
||||||
|
|||||||
@ -28,8 +28,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
import { ref, 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<{
|
||||||
@ -43,8 +42,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 = computed(() => props.width ?? 1920);
|
const rdpWidth = props.width ?? 1920;
|
||||||
const rdpHeight = computed(() => props.height ?? 1080);
|
const rdpHeight = props.height ?? 1080;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
connected,
|
connected,
|
||||||
@ -77,8 +76,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 = canvas.width / rect.width;
|
const scaleX = rdpWidth / rect.width;
|
||||||
const scaleY = canvas.height / rect.height;
|
const scaleY = rdpHeight / rect.height;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: Math.floor((e.clientX - rect.left) * scaleX),
|
x: Math.floor((e.clientX - rect.left) * scaleX),
|
||||||
@ -154,95 +153,25 @@ function handleKeyUp(e: KeyboardEvent): void {
|
|||||||
sendKey(props.sessionId, e.code, false);
|
sendKey(props.sessionId, e.code, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
|
||||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (canvasRef.value) {
|
if (canvasRef.value) {
|
||||||
startFrameLoop(props.sessionId, canvasRef.value, rdpWidth.value, rdpHeight.value);
|
startFrameLoop(props.sessionId, canvasRef.value, rdpWidth, rdpHeight);
|
||||||
}
|
|
||||||
|
|
||||||
// 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, re-check dimensions, and force full frame on tab switch.
|
// Focus canvas when this tab becomes active and keyboard is grabbed
|
||||||
// Uses 300ms delay to let the flex layout fully settle (copilot panel toggle, etc.)
|
|
||||||
watch(
|
watch(
|
||||||
() => props.isActive,
|
() => props.isActive,
|
||||||
(active) => {
|
(active) => {
|
||||||
if (!active || !canvasRef.value) return;
|
if (active && keyboardGrabbed.value && canvasRef.value) {
|
||||||
|
|
||||||
// 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(() => {
|
||||||
const wrapper = canvasWrapper.value;
|
canvasRef.value?.focus();
|
||||||
const canvas = canvasRef.value;
|
}, 0);
|
||||||
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>
|
||||||
@ -267,8 +196,9 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rdp-canvas {
|
.rdp-canvas {
|
||||||
width: 100%;
|
max-width: 100%;
|
||||||
height: 100%;
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
outline: none;
|
outline: none;
|
||||||
image-rendering: auto;
|
image-rendering: auto;
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -14,19 +14,6 @@
|
|||||||
/>
|
/>
|
||||||
</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"
|
||||||
@ -73,7 +60,6 @@ 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";
|
||||||
@ -91,17 +77,12 @@ 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" && s.active),
|
sessionStore.sessions.filter((s) => s.protocol === "ssh"),
|
||||||
);
|
|
||||||
|
|
||||||
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" && s.active),
|
sessionStore.sessions.filter((s) => s.protocol === "rdp"),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -31,21 +31,17 @@ 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" | "local";
|
protocol: "ssh" | "rdp";
|
||||||
/** 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. */
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
/** Connection status — drives the dot colour. */
|
|
||||||
status?: "connected" | "disconnected";
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
/** Green=connected SSH, blue=connected RDP, purple=local, red=disconnected. */
|
/** Green for SSH, blue for RDP. */
|
||||||
const protocolDotClass = computed(() => {
|
const protocolDotClass = computed(() =>
|
||||||
if (props.status === "disconnected") return "bg-[#f85149]";
|
props.protocol === "ssh" ? "bg-[#3fb950]" : "bg-[#1f6feb]",
|
||||||
if (props.protocol === "local") return "bg-[#bc8cff]";
|
);
|
||||||
return props.protocol === "ssh" ? "bg-[#3fb950]" : "bg-[#1f6feb]";
|
|
||||||
});
|
|
||||||
|
|
||||||
/** True when the session is running as root or Administrator. */
|
/** True when the session is running as root or Administrator. */
|
||||||
const isRoot = computed(() => {
|
const isRoot = computed(() => {
|
||||||
|
|||||||
@ -2,35 +2,23 @@
|
|||||||
<div class="flex items-center bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] h-9 shrink-0">
|
<div class="flex items-center bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] h-9 shrink-0">
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="flex items-center overflow-x-auto min-w-0">
|
<div class="flex items-center overflow-x-auto min-w-0">
|
||||||
<div
|
<button
|
||||||
v-for="(session, index) in sessionStore.sessions"
|
v-for="session in sessionStore.sessions"
|
||||||
:key="session.id"
|
:key="session.id"
|
||||||
draggable="true"
|
class="group flex items-center gap-1.5 px-3 h-9 text-xs whitespace-nowrap border-r border-[var(--wraith-border)] transition-all duration-500 cursor-pointer shrink-0"
|
||||||
role="tab"
|
|
||||||
class="group flex items-center gap-1.5 px-3 h-9 text-xs whitespace-nowrap border-r border-[var(--wraith-border)] transition-all duration-500 cursor-pointer shrink-0 select-none"
|
|
||||||
:class="[
|
:class="[
|
||||||
session.id === sessionStore.activeSessionId
|
session.id === sessionStore.activeSessionId
|
||||||
? 'bg-[var(--wraith-bg-primary)] text-[var(--wraith-text-primary)] border-b-2 border-b-[var(--wraith-accent-blue)]'
|
? 'bg-[var(--wraith-bg-primary)] text-[var(--wraith-text-primary)] border-b-2 border-b-[var(--wraith-accent-blue)]'
|
||||||
: '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)]' : '',
|
|
||||||
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)"
|
|
||||||
@dragover.prevent="onDragOver(index)"
|
|
||||||
@dragleave="dragOverIndex = -1"
|
|
||||||
@drop.prevent="onDrop(index)"
|
|
||||||
@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
|
||||||
:protocol="session.protocol"
|
:protocol="session.protocol"
|
||||||
:username="session.username"
|
:username="session.username"
|
||||||
:tags="getSessionTags(session)"
|
:tags="getSessionTags(session)"
|
||||||
:status="session.status"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span>{{ session.name }}</span>
|
<span>{{ session.name }}</span>
|
||||||
@ -42,54 +30,20 @@
|
|||||||
>
|
>
|
||||||
×
|
×
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New tab button with shell dropdown -->
|
<!-- New tab button -->
|
||||||
<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"
|
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"
|
||||||
title="New local terminal"
|
title="New session"
|
||||||
@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, 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";
|
||||||
@ -97,110 +51,6 @@ 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
|
|
||||||
const draggedIndex = ref(-1);
|
|
||||||
const dragOverIndex = ref(-1);
|
|
||||||
|
|
||||||
function onDragStart(index: number, event: DragEvent): void {
|
|
||||||
draggedIndex.value = index;
|
|
||||||
if (event.dataTransfer) {
|
|
||||||
event.dataTransfer.effectAllowed = "move";
|
|
||||||
event.dataTransfer.setData("text/plain", String(index));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragOver(index: number): void {
|
|
||||||
if (draggedIndex.value !== -1 && draggedIndex.value !== index) {
|
|
||||||
dragOverIndex.value = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDrop(toIndex: number): void {
|
|
||||||
if (draggedIndex.value !== -1 && draggedIndex.value !== toIndex) {
|
|
||||||
sessionStore.moveSession(draggedIndex.value, toIndex);
|
|
||||||
}
|
|
||||||
draggedIndex.value = -1;
|
|
||||||
dragOverIndex.value = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get tags for a session's underlying connection. */
|
/** Get tags for a session's underlying connection. */
|
||||||
function getSessionTags(session: Session): string[] {
|
function getSessionTags(session: Session): string[] {
|
||||||
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
|
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
|
||||||
|
|||||||
@ -98,7 +98,6 @@
|
|||||||
: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
|
||||||
@ -137,62 +136,6 @@
|
|||||||
</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">
|
||||||
@ -208,7 +151,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, toRef } from "vue";
|
import { ref } 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";
|
||||||
@ -221,41 +164,12 @@ const emit = defineEmits<{
|
|||||||
openFile: [entry: FileEntry];
|
openFile: [entry: FileEntry];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { currentPath, entries, isLoading, followTerminal, navigateTo, goUp, refresh } = useSftp(toRef(props, 'sessionId'));
|
const { currentPath, entries, isLoading, followTerminal, navigateTo, goUp, refresh } = useSftp(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);
|
||||||
|
|
||||||
@ -371,31 +285,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,15 +52,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { ref } 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
|
||||||
watch(() => transfers.value.length, (newLen, oldLen) => {
|
const { transfers } = useTransfers();
|
||||||
if (newLen > 0 && oldLen === 0) expanded.value = true;
|
|
||||||
if (newLen === 0) expanded.value = false;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -28,17 +28,10 @@
|
|||||||
<!-- 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 -->
|
||||||
<div
|
<button
|
||||||
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="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="{ '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
|
||||||
@ -65,23 +58,16 @@
|
|||||||
<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>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<!-- Connections in group -->
|
<!-- Connections in group -->
|
||||||
<div v-if="expandedGroups.has(group.id)">
|
<div v-if="expandedGroups.has(group.id)">
|
||||||
<div
|
<button
|
||||||
v-for="conn in connectionStore.connectionsByGroup(group.id)"
|
v-for="conn in connectionStore.connectionsByGroup(group.id)"
|
||||||
:key="conn.id"
|
:key="conn.id"
|
||||||
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"
|
||||||
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
|
||||||
@ -96,7 +82,7 @@
|
|||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -110,7 +96,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { ref } 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";
|
||||||
@ -132,107 +118,11 @@ 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);
|
||||||
|
|||||||
@ -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="
|
||||||
model === tab.id
|
modelValue === 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="model = tab.id"
|
@click="emit('update:modelValue', tab.id)"
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</button>
|
</button>
|
||||||
@ -24,5 +24,11 @@ const tabs = [
|
|||||||
{ id: "sftp" as const, label: "SFTP" },
|
{ id: "sftp" as const, label: "SFTP" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const model = defineModel<SidebarTab>();
|
defineProps<{
|
||||||
|
modelValue: SidebarTab;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [tab: SidebarTab];
|
||||||
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -52,19 +52,13 @@
|
|||||||
@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, onBeforeUnmount, watch } from "vue";
|
import { ref, nextTick, onMounted, 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<{
|
||||||
@ -75,11 +69,6 @@ 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);
|
||||||
@ -145,7 +134,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Track terminal dimensions in the session store
|
// Track terminal dimensions in the session store
|
||||||
resizeDisposable = terminal.onResize(({ cols, rows }) => {
|
terminal.onResize(({ cols, rows }) => {
|
||||||
sessionStore.setTerminalDimensions(props.sessionId, cols, rows);
|
sessionStore.setTerminalDimensions(props.sessionId, cols, rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -156,27 +145,15 @@ 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) {
|
||||||
// Double rAF ensures the container has been laid out by the browser
|
setTimeout(() => {
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
fit();
|
fit();
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
// Also notify the backend of the correct size
|
}, 0);
|
||||||
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(() => {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -189,10 +166,6 @@ 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,
|
||||||
@ -210,27 +183,14 @@ function applyTheme(): void {
|
|||||||
brightCyan: theme.brightCyan,
|
brightCyan: theme.brightCyan,
|
||||||
brightWhite: theme.brightWhite,
|
brightWhite: theme.brightWhite,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync the container background so areas outside the canvas match the theme
|
|
||||||
if (containerRef.value) {
|
|
||||||
containerRef.value.style.backgroundColor = theme.background;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force xterm.js to repaint all visible rows with the new theme colors
|
|
||||||
terminal.refresh(0, terminal.rows - 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for theme changes in the session store and apply to this terminal.
|
// Watch for theme changes in the session store and apply to this terminal
|
||||||
// Uses deep comparison because the theme is an object — a shallow watch may miss
|
|
||||||
// updates if Pinia returns the same reactive proxy wrapper after reassignment.
|
|
||||||
watch(() => sessionStore.activeTheme, (newTheme) => {
|
watch(() => sessionStore.activeTheme, (newTheme) => {
|
||||||
if (newTheme) applyTheme();
|
if (newTheme) applyTheme();
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (resizeDisposable) {
|
|
||||||
resizeDisposable.dispose();
|
|
||||||
resizeDisposable = null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleFocus(): void {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,219 +0,0 @@
|
|||||||
<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 → 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 → 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> → 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>
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from "vue";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
placeholder?: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const output = ref("");
|
|
||||||
const running = ref(false);
|
|
||||||
|
|
||||||
async function execute(fn: () => Promise<string>): Promise<void> {
|
|
||||||
running.value = true;
|
|
||||||
output.value = "";
|
|
||||||
try {
|
|
||||||
output.value = await fn();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
output.value = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
||||||
} finally {
|
|
||||||
running.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setOutput(value: string): void {
|
|
||||||
output.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ execute, setOutput, output, running });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col h-full p-4 gap-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<slot :running="running" />
|
|
||||||
</div>
|
|
||||||
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || placeholder || "Ready." }}</pre>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
import { onMounted, onBeforeUnmount } from "vue";
|
|
||||||
import type { Ref } from "vue";
|
|
||||||
import type { useSessionStore } from "@/stores/session.store";
|
|
||||||
|
|
||||||
interface KeyboardShortcutActions {
|
|
||||||
sessionStore: ReturnType<typeof useSessionStore>;
|
|
||||||
sidebarVisible: Ref<boolean>;
|
|
||||||
copilotVisible: Ref<boolean>;
|
|
||||||
openCommandPalette: () => void;
|
|
||||||
openActiveSearch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeyboardShortcuts(actions: KeyboardShortcutActions): void {
|
|
||||||
const { sessionStore, sidebarVisible, copilotVisible, openCommandPalette, openActiveSearch } = actions;
|
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent): void {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
const isInputFocused =
|
|
||||||
target.tagName === "INPUT" ||
|
|
||||||
target.tagName === "TEXTAREA" ||
|
|
||||||
target.tagName === "SELECT";
|
|
||||||
const ctrl = event.ctrlKey || event.metaKey;
|
|
||||||
|
|
||||||
// Ctrl+K — command palette (fires even when input is focused)
|
|
||||||
if (ctrl && event.key === "k") {
|
|
||||||
event.preventDefault();
|
|
||||||
openCommandPalette();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInputFocused) return;
|
|
||||||
|
|
||||||
// Ctrl+W — close active tab
|
|
||||||
if (ctrl && event.key === "w") {
|
|
||||||
event.preventDefault();
|
|
||||||
const active = sessionStore.activeSession;
|
|
||||||
if (active) sessionStore.closeSession(active.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Tab — next tab
|
|
||||||
if (ctrl && event.key === "Tab" && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
const sessions = sessionStore.sessions;
|
|
||||||
if (sessions.length < 2) return;
|
|
||||||
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
|
|
||||||
const next = sessions[(idx + 1) % sessions.length];
|
|
||||||
sessionStore.activateSession(next.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Shift+Tab — previous tab
|
|
||||||
if (ctrl && event.key === "Tab" && event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
const sessions = sessionStore.sessions;
|
|
||||||
if (sessions.length < 2) return;
|
|
||||||
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
|
|
||||||
const prev = sessions[(idx - 1 + sessions.length) % sessions.length];
|
|
||||||
sessionStore.activateSession(prev.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+1-9 — jump to tab by index
|
|
||||||
if (ctrl && event.key >= "1" && event.key <= "9") {
|
|
||||||
const tabIndex = parseInt(event.key, 10) - 1;
|
|
||||||
const sessions = sessionStore.sessions;
|
|
||||||
if (tabIndex < sessions.length) {
|
|
||||||
event.preventDefault();
|
|
||||||
sessionStore.activateSession(sessions[tabIndex].id);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+B — toggle sidebar
|
|
||||||
if (ctrl && event.key === "b") {
|
|
||||||
event.preventDefault();
|
|
||||||
sidebarVisible.value = !sidebarVisible.value;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Shift+G — toggle AI copilot
|
|
||||||
if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") {
|
|
||||||
event.preventDefault();
|
|
||||||
copilotVisible.value = !copilotVisible.value;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+F — terminal search (SSH sessions only)
|
|
||||||
if (ctrl && event.key === "f") {
|
|
||||||
const active = sessionStore.activeSession;
|
|
||||||
if (active?.protocol === "ssh") {
|
|
||||||
event.preventDefault();
|
|
||||||
openActiveSearch();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener("keydown", handleKeydown);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener("keydown", handleKeydown);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { ref, onBeforeUnmount } from "vue";
|
import { ref, onBeforeUnmount } from "vue";
|
||||||
import type { Ref } from "vue";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -153,13 +152,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: Ref<boolean>;
|
connected: ReturnType<typeof ref<boolean>>;
|
||||||
/** Whether keyboard capture is enabled */
|
/** Whether keyboard capture is enabled */
|
||||||
keyboardGrabbed: Ref<boolean>;
|
keyboardGrabbed: ReturnType<typeof ref<boolean>>;
|
||||||
/** Whether clipboard sync is enabled */
|
/** Whether clipboard sync is enabled */
|
||||||
clipboardSync: Ref<boolean>;
|
clipboardSync: ReturnType<typeof ref<boolean>>;
|
||||||
/** Fetch and render the dirty region directly to a canvas context */
|
/** Fetch the current frame as RGBA ImageData */
|
||||||
fetchAndRender: (sessionId: string, width: number, height: number, ctx: CanvasRenderingContext2D) => Promise<boolean>;
|
fetchFrame: (sessionId: string, width: number, height: number) => Promise<ImageData | null>;
|
||||||
/** 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 */
|
||||||
@ -185,7 +184,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 → raw RGBA ArrayBuffer (binary IPC)
|
* rdp_get_frame → base64 RGBA string
|
||||||
* 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
|
||||||
@ -196,53 +195,47 @@ export function useRdp(): UseRdpReturn {
|
|||||||
const clipboardSync = ref(false);
|
const clipboardSync = ref(false);
|
||||||
|
|
||||||
let animFrameId: number | null = null;
|
let animFrameId: number | null = null;
|
||||||
let unlistenFrame: (() => void) | null = null;
|
let frameCount = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the dirty region from the Rust RDP backend and apply it to the canvas.
|
* Fetch the current frame from the Rust RDP backend.
|
||||||
*
|
*
|
||||||
* Binary format from backend: 8-byte header + pixel data
|
* rdp_get_frame returns raw RGBA bytes (width*height*4) serialised as a
|
||||||
* Header: [x: u16, y: u16, w: u16, h: u16] (little-endian)
|
* base64 string over Tauri's IPC bridge. We decode it to Uint8ClampedArray
|
||||||
* If header is all zeros → full frame (width*height*4 bytes)
|
* and wrap in an ImageData for putImageData().
|
||||||
* If header is non-zero → dirty rectangle (w*h*4 bytes)
|
|
||||||
*
|
|
||||||
* Returns true if a frame was rendered, false if nothing changed.
|
|
||||||
*/
|
*/
|
||||||
async function fetchAndRender(
|
async function fetchFrame(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
ctx: CanvasRenderingContext2D,
|
): Promise<ImageData | null> {
|
||||||
): Promise<boolean> {
|
let raw: string;
|
||||||
let raw: ArrayBuffer;
|
|
||||||
try {
|
try {
|
||||||
raw = await invoke<ArrayBuffer>("rdp_get_frame", { sessionId });
|
raw = await invoke<string>("rdp_get_frame", { sessionId });
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
// Session may not be connected yet or backend returned an error — skip frame
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!raw || raw.byteLength <= 8) return false;
|
if (!raw || raw.length === 0) return null;
|
||||||
|
|
||||||
const view = new DataView(raw);
|
// Decode base64 → binary string → Uint8ClampedArray
|
||||||
const rx = view.getUint16(0, true);
|
const binaryStr = atob(raw);
|
||||||
const ry = view.getUint16(2, true);
|
const bytes = new Uint8ClampedArray(binaryStr.length);
|
||||||
const rw = view.getUint16(4, true);
|
for (let i = 0; i < binaryStr.length; i++) {
|
||||||
const rh = view.getUint16(6, true);
|
bytes[i] = binaryStr.charCodeAt(i);
|
||||||
const pixelData = new Uint8ClampedArray(raw, 8);
|
}
|
||||||
|
|
||||||
if (rx === 0 && ry === 0 && rw === 0 && rh === 0) {
|
// Validate: RGBA requires exactly width * height * 4 bytes
|
||||||
// Full frame
|
|
||||||
const expected = width * height * 4;
|
const expected = width * height * 4;
|
||||||
if (pixelData.length !== expected) return false;
|
if (bytes.length !== expected) {
|
||||||
ctx.putImageData(new ImageData(pixelData, width, height), 0, 0);
|
console.warn(
|
||||||
} else {
|
`[useRdp] Frame size mismatch: got ${bytes.length}, expected ${expected}`,
|
||||||
// Dirty rectangle — apply at offset
|
);
|
||||||
const expected = rw * rh * 4;
|
return null;
|
||||||
if (pixelData.length !== expected) return false;
|
|
||||||
ctx.putImageData(new ImageData(pixelData, rw, rh), rx, ry);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return new ImageData(bytes, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -310,36 +303,26 @@ export function useRdp(): UseRdpReturn {
|
|||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
|
|
||||||
let fetchPending = false;
|
function renderLoop(): void {
|
||||||
let rafScheduled = false;
|
frameCount++;
|
||||||
|
|
||||||
// Fetch and render dirty region when backend signals new frame data.
|
// Throttle to ~30fps by skipping odd-numbered rAF ticks
|
||||||
// Uses rAF to coalesce rapid events into one fetch per display frame.
|
if (frameCount % 2 === 0) {
|
||||||
function scheduleFrameFetch(): void {
|
fetchFrame(sessionId, width, height).then((imageData) => {
|
||||||
if (rafScheduled) return;
|
if (imageData && ctx) {
|
||||||
rafScheduled = true;
|
ctx.putImageData(imageData, 0, 0);
|
||||||
animFrameId = requestAnimationFrame(async () => {
|
// Mark connected on first successful frame
|
||||||
rafScheduled = false;
|
if (!connected.value) {
|
||||||
if (fetchPending) return;
|
connected.value = true;
|
||||||
fetchPending = true;
|
}
|
||||||
if (!ctx) return;
|
}
|
||||||
const rendered = await fetchAndRender(sessionId, width, height, ctx);
|
|
||||||
fetchPending = false;
|
|
||||||
if (rendered && !connected.value) connected.value = true;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for frame events from the backend (push model)
|
animFrameId = requestAnimationFrame(renderLoop);
|
||||||
import("@tauri-apps/api/event").then(({ listen }) => {
|
}
|
||||||
listen(`rdp:frame:${sessionId}`, () => {
|
|
||||||
scheduleFrameFetch();
|
|
||||||
}).then((unlisten) => {
|
|
||||||
unlistenFrame = unlisten;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial poll in case frames arrived before listener was set up
|
animFrameId = requestAnimationFrame(renderLoop);
|
||||||
scheduleFrameFetch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -350,11 +333,8 @@ 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 {
|
||||||
@ -373,7 +353,7 @@ export function useRdp(): UseRdpReturn {
|
|||||||
connected,
|
connected,
|
||||||
keyboardGrabbed,
|
keyboardGrabbed,
|
||||||
clipboardSync,
|
clipboardSync,
|
||||||
fetchAndRender,
|
fetchFrame,
|
||||||
sendMouse,
|
sendMouse,
|
||||||
sendKey,
|
sendKey,
|
||||||
sendClipboard,
|
sendClipboard,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ref, watch, onBeforeUnmount, type Ref } from "vue";
|
import { ref, 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,29 +21,20 @@ 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.
|
||||||
* Accepts a reactive session ID ref so it reinitializes on tab switch
|
* Calls the Rust SFTP commands via Tauri invoke.
|
||||||
* without destroying the component.
|
|
||||||
*/
|
*/
|
||||||
export function useSftp(sessionIdRef: Ref<string>): UseSftpReturn {
|
export function useSftp(sessionId: 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(sessionId: string, path: string): Promise<FileEntry[]> {
|
async function listDirectory(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 ?? [];
|
||||||
@ -54,12 +45,10 @@ export function useSftp(sessionIdRef: Ref<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;
|
||||||
sessionPaths[currentSessionId] = path;
|
entries.value = await listDirectory(path);
|
||||||
entries.value = await listDirectory(currentSessionId, path);
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
@ -79,63 +68,25 @@ export function useSftp(sessionIdRef: Ref<string>): UseSftpReturn {
|
|||||||
await navigateTo(currentPath.value);
|
await navigateTo(currentPath.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchToSession(sessionId: string): Promise<void> {
|
// Listen for CWD changes from the Rust backend (OSC 7 tracking).
|
||||||
if (!sessionId) {
|
// listen() returns Promise<UnlistenFn> — store it for cleanup.
|
||||||
entries.value = [];
|
listen<string>(`ssh:cwd:${sessionId}`, (event) => {
|
||||||
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,
|
||||||
|
|||||||
@ -5,7 +5,6 @@ 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 Classic–inspired terminal theme colors. */
|
/** MobaXTerm Classic–inspired terminal theme colors. */
|
||||||
@ -14,9 +13,8 @@ const defaultTheme = {
|
|||||||
foreground: "#e0e0e0",
|
foreground: "#e0e0e0",
|
||||||
cursor: "#58a6ff",
|
cursor: "#58a6ff",
|
||||||
cursorAccent: "#0d1117",
|
cursorAccent: "#0d1117",
|
||||||
selectionBackground: "#264f78",
|
selectionBackground: "rgba(88, 166, 255, 0.3)",
|
||||||
selectionForeground: "#ffffff",
|
selectionForeground: "#ffffff",
|
||||||
selectionInactiveBackground: "#264f78",
|
|
||||||
black: "#0d1117",
|
black: "#0d1117",
|
||||||
red: "#f85149",
|
red: "#f85149",
|
||||||
green: "#3fb950",
|
green: "#3fb950",
|
||||||
@ -53,11 +51,7 @@ export interface UseTerminalReturn {
|
|||||||
* - SSH stdout → xterm.js (via Tauri listen, base64 encoded)
|
* - SSH stdout → xterm.js (via Tauri listen, base64 encoded)
|
||||||
* - Terminal resize → ssh_resize (via Tauri invoke)
|
* - Terminal resize → ssh_resize (via Tauri invoke)
|
||||||
*/
|
*/
|
||||||
export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'): UseTerminalReturn {
|
export function useTerminal(sessionId: string): UseTerminalReturn {
|
||||||
const writeCmd = backend === 'ssh' ? 'ssh_write' : 'pty_write';
|
|
||||||
const resizeCmd = backend === 'ssh' ? 'ssh_resize' : 'pty_resize';
|
|
||||||
const dataEvent = backend === 'ssh' ? `ssh:data:${sessionId}` : `pty:data:${sessionId}`;
|
|
||||||
|
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
const searchAddon = new SearchAddon();
|
const searchAddon = new SearchAddon();
|
||||||
const webLinksAddon = new WebLinksAddon();
|
const webLinksAddon = new WebLinksAddon();
|
||||||
@ -71,9 +65,7 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
|
|||||||
cursorStyle: "block",
|
cursorStyle: "block",
|
||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
// SSH always needs EOL conversion. PTY needs it on Windows (ConPTY sends bare \n)
|
convertEol: true,
|
||||||
// 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -81,17 +73,17 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
|
|||||||
terminal.loadAddon(searchAddon);
|
terminal.loadAddon(searchAddon);
|
||||||
terminal.loadAddon(webLinksAddon);
|
terminal.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
// Forward typed data to the backend
|
// Forward typed data to the SSH backend
|
||||||
terminal.onData((data: string) => {
|
terminal.onData((data: string) => {
|
||||||
invoke(writeCmd, { sessionId, data }).catch((err: unknown) => {
|
invoke("ssh_write", { sessionId, data }).catch((err: unknown) => {
|
||||||
console.error("Write error:", err);
|
console.error("SSH write error:", err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Forward resize events to the backend
|
// Forward resize events to the SSH backend
|
||||||
terminal.onResize((size: { cols: number; rows: number }) => {
|
terminal.onResize((size: { cols: number; rows: number }) => {
|
||||||
invoke(resizeCmd, { sessionId, cols: size.cols, rows: size.rows }).catch((err: unknown) => {
|
invoke("ssh_resize", { sessionId, cols: size.cols, rows: size.rows }).catch((err: unknown) => {
|
||||||
console.error("Resize error:", err);
|
console.error("SSH resize error:", err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -108,7 +100,7 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigator.clipboard.readText().then((text) => {
|
navigator.clipboard.readText().then((text) => {
|
||||||
if (text) {
|
if (text) {
|
||||||
invoke(writeCmd, { sessionId, data: text }).catch(() => {});
|
invoke("ssh_write", { sessionId, data: text }).catch(() => {});
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
@ -156,7 +148,6 @@ 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
|
||||||
@ -165,17 +156,7 @@ 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
|
unlistenPromise = listen<string>(`ssh:data:${sessionId}`, (event) => {
|
||||||
let lastActivityMark = 0;
|
|
||||||
|
|
||||||
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 {
|
||||||
@ -202,12 +183,9 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
|
|||||||
unlistenFn = fn;
|
unlistenFn = fn;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-fit when the container resizes — but only if visible
|
// Auto-fit when the container resizes
|
||||||
resizeObserver = new ResizeObserver((entries) => {
|
resizeObserver = new ResizeObserver(() => {
|
||||||
const entry = entries[0];
|
|
||||||
if (entry && entry.contentRect.width > 50 && entry.contentRect.height > 50) {
|
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
resizeObserver.observe(container);
|
resizeObserver.observe(container);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,138 +50,6 @@
|
|||||||
</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 -->
|
||||||
@ -200,9 +68,9 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
|
class="hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
|
||||||
:class="{ 'text-[var(--wraith-accent-blue)]': copilotVisible }"
|
:class="{ 'text-[var(--wraith-accent-blue)]': geminiVisible }"
|
||||||
title="AI Copilot (Ctrl+Shift+G)"
|
title="Gemini XO (Ctrl+Shift+G)"
|
||||||
@click="copilotVisible = !copilotVisible"
|
@click="geminiVisible = !geminiVisible"
|
||||||
>
|
>
|
||||||
AI
|
AI
|
||||||
</button>
|
</button>
|
||||||
@ -283,12 +151,21 @@
|
|||||||
<!-- 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>
|
||||||
|
|
||||||
<!-- AI Copilot Panel -->
|
<!-- Gemini AI Panel -->
|
||||||
<CopilotPanel v-if="copilotVisible" />
|
<GeminiPanel v-if="geminiVisible" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status bar -->
|
<!-- Status bar -->
|
||||||
@ -308,7 +185,6 @@
|
|||||||
|
|
||||||
<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";
|
||||||
@ -325,7 +201,8 @@ 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 CopilotPanel from "@/components/ai/CopilotPanel.vue";
|
import EditorWindow from "@/components/editor/EditorWindow.vue";
|
||||||
|
import GeminiPanel from "@/components/ai/GeminiPanel.vue";
|
||||||
import type { FileEntry } from "@/composables/useSftp";
|
import type { FileEntry } from "@/composables/useSftp";
|
||||||
|
|
||||||
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
||||||
@ -339,7 +216,7 @@ const activeSessionId = computed(() => sessionStore.activeSessionId);
|
|||||||
const sidebarWidth = ref(240);
|
const sidebarWidth = ref(240);
|
||||||
const sidebarVisible = ref(true);
|
const sidebarVisible = ref(true);
|
||||||
const sidebarTab = ref<SidebarTab>("connections");
|
const sidebarTab = ref<SidebarTab>("connections");
|
||||||
const copilotVisible = ref(false);
|
const geminiVisible = ref(false);
|
||||||
const quickConnectInput = ref("");
|
const quickConnectInput = ref("");
|
||||||
|
|
||||||
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null);
|
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null);
|
||||||
@ -349,75 +226,14 @@ 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) {
|
||||||
@ -435,15 +251,9 @@ 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 fileName = entry.path.split("/").pop() || entry.path;
|
const content = await invoke<string>("sftp_read_file", { sessionId: activeSessionId.value, path: entry.path });
|
||||||
const sessionId = activeSessionId.value;
|
editorFile.value = { path: entry.path, content, sessionId: activeSessionId.value };
|
||||||
await invoke("open_child_window", {
|
} catch (err) { console.error("Failed to open SFTP file:", err); }
|
||||||
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> {
|
||||||
@ -469,81 +279,27 @@ async function handleQuickConnect(): Promise<void> {
|
|||||||
} catch (err) { console.error("Quick connect failed:", err); }
|
} catch (err) { console.error("Quick connect failed:", err); }
|
||||||
}
|
}
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
function handleKeydown(event: KeyboardEvent): void {
|
||||||
sessionStore,
|
const target = event.target as HTMLElement;
|
||||||
sidebarVisible,
|
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
|
||||||
copilotVisible,
|
const ctrl = event.ctrlKey || event.metaKey;
|
||||||
openCommandPalette: () => commandPalette.value?.toggle(),
|
if (ctrl && event.key === "k") { event.preventDefault(); commandPalette.value?.toggle(); return; }
|
||||||
openActiveSearch: () => sessionContainer.value?.openActiveSearch(),
|
if (isInputFocused) return;
|
||||||
});
|
if (ctrl && event.key === "w") { event.preventDefault(); const active = sessionStore.activeSession; if (active) sessionStore.closeSession(active.id); return; }
|
||||||
|
if (ctrl && event.key === "Tab" && !event.shiftKey) { event.preventDefault(); const sessions = sessionStore.sessions; if (sessions.length < 2) return; const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId); const next = sessions[(idx + 1) % sessions.length]; sessionStore.activateSession(next.id); return; }
|
||||||
let workspaceSaveInterval: ReturnType<typeof setInterval> | null = null;
|
if (ctrl && event.key === "Tab" && event.shiftKey) { event.preventDefault(); const sessions = sessionStore.sessions; if (sessions.length < 2) return; const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId); const prev = sessions[(idx - 1 + sessions.length) % sessions.length]; sessionStore.activateSession(prev.id); return; }
|
||||||
|
if (ctrl && event.key >= "1" && event.key <= "9") { const tabIndex = parseInt(event.key, 10) - 1; const sessions = sessionStore.sessions; if (tabIndex < sessions.length) { event.preventDefault(); sessionStore.activateSession(sessions[tabIndex].id); } return; }
|
||||||
function handleBeforeUnload(e: BeforeUnloadEvent): void {
|
if (ctrl && event.key === "b") { event.preventDefault(); sidebarVisible.value = !sidebarVisible.value; return; }
|
||||||
if (sessionStore.sessions.length > 0) {
|
if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") { event.preventDefault(); geminiVisible.value = !geminiVisible.value; return; }
|
||||||
e.preventDefault();
|
if (ctrl && event.key === "f") { const active = sessionStore.activeSession; if (active?.protocol === "ssh") { event.preventDefault(); sessionContainer.value?.openActiveSearch(); } return; }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Confirm before closing if sessions are active (synchronous — won't hang)
|
document.addEventListener("keydown", handleKeydown);
|
||||||
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(() => {
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
document.removeEventListener("keydown", handleKeydown);
|
||||||
if (workspaceSaveInterval !== null) {
|
|
||||||
clearInterval(workspaceSaveInterval);
|
|
||||||
workspaceSaveInterval = null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -50,25 +50,68 @@ const displayError = computed(() => localError.value ?? app.error);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full flex items-center justify-center bg-[var(--wraith-bg-primary)]">
|
<div
|
||||||
<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)]">
|
class="unlock-root"
|
||||||
|
style="
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--wraith-bg-primary);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="unlock-card"
|
||||||
|
style="
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
background-color: var(--wraith-bg-secondary);
|
||||||
|
border: 1px solid var(--wraith-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
"
|
||||||
|
>
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div style="text-align: center; margin-bottom: 2rem">
|
||||||
<span class="text-[2rem] font-extrabold tracking-[0.3em] text-[var(--wraith-accent-blue)] uppercase font-['Inter',monospace]">
|
<span
|
||||||
|
style="
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
color: var(--wraith-accent-blue);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: 'Inter', monospace;
|
||||||
|
"
|
||||||
|
>
|
||||||
WRAITH
|
WRAITH
|
||||||
</span>
|
</span>
|
||||||
<p class="mt-2 text-[0.8rem] text-[var(--wraith-text-muted)] tracking-[0.15em] uppercase">
|
<p
|
||||||
|
style="
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--wraith-text-muted);
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
"
|
||||||
|
>
|
||||||
{{ isFirstRun ? "Initialize Secure Vault" : "Secure Desktop" }}
|
{{ isFirstRun ? "Initialize Secure Vault" : "Secure Desktop" }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-4">
|
<form @submit.prevent="handleSubmit" style="display: flex; flex-direction: column; gap: 1rem">
|
||||||
<!-- Master password -->
|
<!-- Master password -->
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="master-password"
|
for="master-password"
|
||||||
class="block mb-[0.4rem] text-[0.8rem] text-[var(--wraith-text-secondary)] tracking-[0.05em]"
|
style="
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--wraith-text-secondary);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
"
|
||||||
>
|
>
|
||||||
MASTER PASSWORD
|
MASTER PASSWORD
|
||||||
</label>
|
</label>
|
||||||
@ -79,7 +122,20 @@ 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"
|
||||||
class="w-full px-[0.9rem] py-[0.65rem] bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded-[6px] text-[var(--wraith-text-primary)] text-[0.95rem] outline-none transition-colors duration-150 box-border focus:border-[var(--wraith-accent-blue)]"
|
style="
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
background-color: var(--wraith-bg-tertiary);
|
||||||
|
border: 1px solid var(--wraith-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--wraith-text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
"
|
||||||
|
@focus="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-accent-blue)'"
|
||||||
|
@blur="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-border)'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -87,7 +143,13 @@ const displayError = computed(() => localError.value ?? app.error);
|
|||||||
<div v-if="isFirstRun">
|
<div v-if="isFirstRun">
|
||||||
<label
|
<label
|
||||||
for="confirm-password"
|
for="confirm-password"
|
||||||
class="block mb-[0.4rem] text-[0.8rem] text-[var(--wraith-text-secondary)] tracking-[0.05em]"
|
style="
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--wraith-text-secondary);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
"
|
||||||
>
|
>
|
||||||
CONFIRM PASSWORD
|
CONFIRM PASSWORD
|
||||||
</label>
|
</label>
|
||||||
@ -98,9 +160,28 @@ 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"
|
||||||
class="w-full px-[0.9rem] py-[0.65rem] bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded-[6px] text-[var(--wraith-text-primary)] text-[0.95rem] outline-none transition-colors duration-150 box-border focus:border-[var(--wraith-accent-blue)]"
|
style="
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
background-color: var(--wraith-bg-tertiary);
|
||||||
|
border: 1px solid var(--wraith-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--wraith-text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
"
|
||||||
|
@focus="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-accent-blue)'"
|
||||||
|
@blur="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-border)'"
|
||||||
/>
|
/>
|
||||||
<p class="mt-[0.4rem] text-[0.75rem] text-[var(--wraith-text-muted)]">
|
<p
|
||||||
|
style="
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--wraith-text-muted);
|
||||||
|
"
|
||||||
|
>
|
||||||
Minimum 12 characters. This password cannot be recovered.
|
Minimum 12 characters. This password cannot be recovered.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -108,7 +189,14 @@ const displayError = computed(() => localError.value ?? app.error);
|
|||||||
<!-- Error message -->
|
<!-- Error message -->
|
||||||
<div
|
<div
|
||||||
v-if="displayError"
|
v-if="displayError"
|
||||||
class="px-[0.9rem] py-[0.6rem] bg-[rgba(248,81,73,0.1)] border border-[rgba(248,81,73,0.3)] rounded-[6px] text-[var(--wraith-accent-red)] text-[0.85rem]"
|
style="
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
background-color: rgba(248, 81, 73, 0.1);
|
||||||
|
border: 1px solid rgba(248, 81, 73, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--wraith-accent-red);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ displayError }}
|
{{ displayError }}
|
||||||
</div>
|
</div>
|
||||||
@ -117,8 +205,22 @@ const displayError = computed(() => localError.value ?? app.error);
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
class="w-full py-[0.7rem] mt-2 bg-[var(--wraith-accent-blue)] text-[#0d1117] font-bold text-[0.9rem] tracking-[0.08em] uppercase border-none rounded-[6px] transition-[opacity,background-color] duration-150"
|
style="
|
||||||
:class="loading ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'"
|
width: 100%;
|
||||||
|
padding: 0.7rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background-color: var(--wraith-accent-blue);
|
||||||
|
color: #0d1117;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||||
|
"
|
||||||
|
:style="{ opacity: loading ? '0.6' : '1', cursor: loading ? 'not-allowed' : 'pointer' }"
|
||||||
>
|
>
|
||||||
<span v-if="loading">
|
<span v-if="loading">
|
||||||
{{ isFirstRun ? "Creating vault..." : "Unlocking..." }}
|
{{ isFirstRun ? "Creating vault..." : "Unlocking..." }}
|
||||||
@ -130,7 +232,14 @@ const displayError = computed(() => localError.value ?? app.error);
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Footer hint -->
|
<!-- Footer hint -->
|
||||||
<p class="mt-6 text-center text-[0.75rem] text-[var(--wraith-text-muted)]">
|
<p
|
||||||
|
style="
|
||||||
|
margin: 1.5rem 0 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--wraith-text-muted);
|
||||||
|
"
|
||||||
|
>
|
||||||
<template v-if="isFirstRun">
|
<template v-if="isFirstRun">
|
||||||
Your vault will be encrypted with AES-256-GCM.
|
Your vault will be encrypted with AES-256-GCM.
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user