Compare commits

...

No commits in common. "v0.1.0" and "main" have entirely different histories.
v0.1.0 ... main

221 changed files with 32592 additions and 21583 deletions

View File

@ -1,21 +1,6 @@
# ============================================================================= # =============================================================================
# Wraith — Build & Sign Release # Wraith — Build & Sign Release (Tauri v2)
# ============================================================================= # =============================================================================
# Builds the Wails v3 desktop app for Windows amd64, cross-compiles FreeRDP3
# from source via MinGW, signs everything with Azure Key Vault EV cert,
# then uploads to SeaweedFS.
#
# Trigger: push a tag matching v* (e.g. v1.0.0) or run manually.
#
# Required secrets:
# AZURE_TENANT_ID — Azure AD tenant
# AZURE_CLIENT_ID — Service principal client ID
# AZURE_CLIENT_SECRET — Service principal secret
# AZURE_KEY_VAULT_URL — e.g. https://my-vault.vault.azure.net
# AZURE_CERT_NAME — Certificate/key name in the vault
# GIT_TOKEN — PAT for cloning private repo
# =============================================================================
name: Build & Sign Wraith name: Build & Sign Wraith
on: on:
@ -24,243 +9,189 @@ on:
- 'v*' - 'v*'
workflow_dispatch: workflow_dispatch:
env:
EXTRA_PATH: C:\Program Files (x86)\NSIS;C:\Program Files\Eclipse Adoptium\jre-21.0.10.7-hotspot\bin;C:\Users\vantz\.cargo\bin;C:\Users\vantz\.rustup\toolchains\stable-x86_64-pc-windows-msvc\bin;C:\Program Files\nodejs
jobs: jobs:
build-and-sign: build-and-sign:
name: Build Windows + Sign name: Build Windows + Sign
runs-on: linux runs-on: windows
steps: steps:
# ---------------------------------------------------------------
# Checkout
# ---------------------------------------------------------------
- name: Checkout code - name: Checkout code
shell: powershell
run: | run: |
git clone --depth 1 --branch ${{ github.ref_name }} \ git clone --depth 1 --branch ${{ github.ref_name }} https://${{ secrets.GIT_TOKEN }}@git.command.vigilcyber.com/vstockwell/wraith.git .
https://${{ secrets.GIT_TOKEN }}@git.command.vigilcyber.com/vstockwell/wraith.git .
# --------------------------------------------------------------- - name: Configure Rust
# Extract version from tag shell: powershell
# ---------------------------------------------------------------
- name: Get version from tag
id: version
run: | run: |
TAG=$(echo "${{ github.ref_name }}" | sed 's/^v//') $env:Path = "$env:EXTRA_PATH;$env:Path"
echo "version=${TAG}" >> $GITHUB_OUTPUT $ErrorActionPreference = "Continue"
echo "Building version: ${TAG}" rustup default stable
$ErrorActionPreference = "Stop"
# --------------------------------------------------------------- - name: Verify toolchain
# Install toolchain shell: powershell
# ---------------------------------------------------------------
- name: Install build dependencies
run: | run: |
apt-get update -qq $env:Path = "$env:EXTRA_PATH;$env:Path"
apt-get install -y -qq \
mingw-w64 mingw-w64-tools binutils-mingw-w64 \
cmake ninja-build nasm meson \
default-jre-headless \
python3 awscli
# Node.js
if ! command -v node >/dev/null 2>&1; then
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq nodejs
fi
echo "=== Toolchain versions ==="
go version
node --version node --version
x86_64-w64-mingw32-gcc --version | head -1 rustc --version
cmake --version | head -1 cargo --version
java --version
# =============================================================== - name: Patch version from git tag
# FreeRDP3 — Cross-compile from source via MinGW shell: powershell
# ===============================================================
- name: Build FreeRDP3 for Windows (MinGW cross-compile)
run: | run: |
FREERDP_VERSION="3.24.0" $ver = ("${{ github.ref_name }}" -replace '^v','')
echo "=== Building FreeRDP ${FREERDP_VERSION} for Windows amd64 via MinGW ===" $conf = Get-Content src-tauri\tauri.conf.json -Raw
$conf = $conf -replace '"version":\s*"[^"]*"', "`"version`": `"$ver`""
[System.IO.File]::WriteAllText((Join-Path (Get-Location) "src-tauri\tauri.conf.json"), $conf)
Write-Host "Patched tauri.conf.json version to $ver"
# Download FreeRDP source - name: Install dependencies and build frontend
curl -sSL -o /tmp/freerdp.tar.gz \ shell: powershell
"https://github.com/FreeRDP/FreeRDP/archive/refs/tags/${FREERDP_VERSION}.tar.gz"
tar -xzf /tmp/freerdp.tar.gz -C /tmp
cd /tmp/FreeRDP-${FREERDP_VERSION}
# Create MinGW toolchain file
cat > /tmp/mingw-toolchain.cmake << 'TCEOF'
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR AMD64)
set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)
set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
TCEOF
# Configure — minimal client-only build (no server, no extras)
cmake -B build -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=/tmp/mingw-toolchain.cmake \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/tmp/freerdp-install \
-DBUILD_SHARED_LIBS=ON \
-DWITH_CLIENT=ON \
-DWITH_SERVER=OFF \
-DWITH_SHADOW=OFF \
-DWITH_PROXY=OFF \
-DWITH_SAMPLE=OFF \
-DWITH_PLATFORM_SERVER=OFF \
-DWITH_WINPR_TOOLS=OFF \
-DWITH_FFMPEG=OFF \
-DWITH_SWSCALE=OFF \
-DWITH_CAIRO=OFF \
-DWITH_CUPS=OFF \
-DWITH_PULSE=OFF \
-DWITH_ALSA=OFF \
-DWITH_OSS=OFF \
-DWITH_WAYLAND=OFF \
-DWITH_X11=OFF \
-DCHANNEL_URBDRC=OFF \
-DWITH_OPENH264=OFF
# Build
cmake --build build --parallel $(nproc)
cmake --install build
echo "=== FreeRDP3 DLLs built ==="
ls -la /tmp/freerdp-install/bin/*.dll 2>/dev/null || ls -la /tmp/freerdp-install/lib/*.dll 2>/dev/null || echo "Checking build output..."
find /tmp/freerdp-install -name "*.dll" -type f
- name: Stage FreeRDP3 DLLs
run: | run: |
mkdir -p dist $env:Path = "$env:EXTRA_PATH;$env:Path"
# Copy all FreeRDP DLLs (MinGW produces lib-prefixed names)
find /tmp/freerdp-install -name "*.dll" -type f -exec cp {} dist/ \;
echo "=== Staged DLLs ==="
ls -la dist/*.dll 2>/dev/null || echo "No DLLs found — FreeRDP build may have failed"
# ===============================================================
# Build Wraith
# ===============================================================
- name: Build frontend
run: |
cd frontend
npm ci npm ci
npm run build npm run build
echo "Frontend build complete:"
ls -la dist/
- name: Build wraith.exe (Windows amd64) - name: Install Tauri CLI
shell: powershell
run: | run: |
VERSION="${{ steps.version.outputs.version }}" $env:Path = "$env:EXTRA_PATH;$env:Path"
echo "=== Cross-compiling wraith.exe for Windows amd64 ===" cargo install tauri-cli --version "^2"
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \ - name: Build Tauri app (with update signing)
go build \ shell: powershell
-ldflags="-s -w -X main.version=${VERSION}" \
-o dist/wraith.exe \
.
ls -la dist/wraith.exe
# ===============================================================
# Code signing — jsign + Azure Key Vault (EV cert)
# ===============================================================
- name: Install jsign
run: | run: |
JSIGN_VERSION="7.0" $env:Path = "$env:EXTRA_PATH;$env:Path"
curl -sSL -o /usr/local/bin/jsign.jar \ $env:TAURI_SIGNING_PRIVATE_KEY = "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}"
"https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar" $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}"
cargo tauri build
Write-Host "=== Build output ==="
Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*
- name: Get Azure Key Vault access token - name: Build and package MCP bridge binary
id: azure-token shell: powershell
run: | run: |
TOKEN=$(curl -s -X POST \ $env:Path = "$env:EXTRA_PATH;$env:Path"
"https://login.microsoftonline.com/${{ secrets.AZURE_TENANT_ID }}/oauth2/v2.0/token" \ cd src-tauri
-d "client_id=${{ secrets.AZURE_CLIENT_ID }}" \ cargo build --release --bin wraith-mcp-bridge
-d "client_secret=${{ secrets.AZURE_CLIENT_SECRET }}" \ Write-Host "Bridge binary built:"
-d "scope=https://vault.azure.net/.default" \ Get-ChildItem target\release\wraith-mcp-bridge.exe
-d "grant_type=client_credentials" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
echo "::add-mask::${TOKEN}"
echo "token=${TOKEN}" >> $GITHUB_OUTPUT
- name: Sign all Windows binaries - name: Download jsign
shell: powershell
run: | run: |
echo "=== Signing all .exe and .dll files with EV certificate ===" Invoke-WebRequest -Uri "https://github.com/ebourg/jsign/releases/download/7.0/jsign-7.0.jar" -OutFile jsign.jar
for binary in dist/*.exe dist/*.dll; do
[ -f "$binary" ] || continue
echo "Signing: $binary"
java -jar /usr/local/bin/jsign.jar \
--storetype AZUREKEYVAULT \
--keystore "${{ secrets.AZURE_KEY_VAULT_URL }}" \
--storepass "${{ steps.azure-token.outputs.token }}" \
--alias "${{ secrets.AZURE_CERT_NAME }}" \
--tsaurl http://timestamp.digicert.com \
--tsmode RFC3161 \
"$binary"
echo "Signed: $binary"
done
# =============================================================== - name: Get Azure token
# Version manifest shell: powershell
# ===============================================================
- name: Create version.json
run: | run: |
VERSION="${{ steps.version.outputs.version }}" $body = @{
EXE_SHA=$(sha256sum dist/wraith.exe | awk '{print $1}') client_id = "${{ secrets.AZURE_CLIENT_ID }}"
client_secret = "${{ secrets.AZURE_CLIENT_SECRET }}"
# Build DLL manifest scope = "https://vault.azure.net/.default"
DLL_ENTRIES="" grant_type = "client_credentials"
for dll in dist/*.dll; do
[ -f "$dll" ] || continue
DLL_NAME=$(basename "$dll")
DLL_SHA=$(sha256sum "$dll" | awk '{print $1}')
DLL_ENTRIES="${DLL_ENTRIES} \"${DLL_NAME}\": \"${DLL_SHA}\",
"
done
cat > dist/version.json << EOF
{
"version": "${VERSION}",
"filename": "wraith.exe",
"sha256": "${EXE_SHA}",
"platform": "windows",
"architecture": "amd64",
"released": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"signed": true,
"dlls": {
${DLL_ENTRIES} "_note": "All DLLs are EV code-signed"
}
} }
EOF $resp = Invoke-RestMethod -Uri "https://login.microsoftonline.com/${{ secrets.AZURE_TENANT_ID }}/oauth2/v2.0/token" -Method POST -Body $body
$token = $resp.access_token
echo "::add-mask::$token"
[System.IO.File]::WriteAllText("$env:TEMP\aztoken.txt", $token)
echo "=== version.json ===" - name: Sign binaries
cat dist/version.json shell: powershell
# ===============================================================
# Upload release artifacts
# ===============================================================
- name: Upload release artifacts
run: | run: |
VERSION="${{ steps.version.outputs.version }}" $env:Path = "$env:EXTRA_PATH;$env:Path"
ENDPOINT="https://files.command.vigilcyber.com" $token = [System.IO.File]::ReadAllText("$env:TEMP\aztoken.txt")
# Sign NSIS installers + MCP bridge binary
$binaries = @()
$binaries += Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
$binaries += Get-Item src-tauri\target\release\wraith-mcp-bridge.exe -ErrorAction SilentlyContinue
foreach ($binary in $binaries) {
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
Write-Host "Signed: $($binary.Name)"
}
Remove-Item "$env:TEMP\aztoken.txt" -ErrorAction SilentlyContinue
echo "=== Uploading Wraith ${VERSION} ===" - name: Upload all artifacts to SeaweedFS
shell: powershell
run: |
$ver = ("${{ github.ref_name }}" -replace '^v','')
$s3 = "https://files.command.vigilcyber.com/wraith"
# Versioned path # Upload installer
aws s3 cp dist/ "s3://agents/wraith/${VERSION}/windows/amd64/" \ $installers = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
--recursive --endpoint-url "$ENDPOINT" --no-sign-request foreach ($file in $installers) {
Write-Host "Uploading: $($file.Name)"
Invoke-RestMethod -Uri "$s3/$ver/$($file.Name)" -Method PUT -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
}
# Latest path # Upload MCP bridge binary
aws s3 sync dist/ "s3://agents/wraith/latest/windows/amd64/" \ $bridge = "src-tauri\target\release\wraith-mcp-bridge.exe"
--delete --endpoint-url "$ENDPOINT" --no-sign-request 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
$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
run: |
$ver = ("${{ github.ref_name }}" -replace '^v','')
$s3 = "https://files.command.vigilcyber.com/wraith"
$sigFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip.sig | Select-Object -First 1
$zipFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip | Select-Object -First 1
if ($sigFile -and $zipFile) {
$signature = Get-Content $sigFile.FullName -Raw
$downloadUrl = "$s3/$ver/$($zipFile.Name)"
$updateJson = @{
version = "v$ver"
notes = "Wraith Desktop v$ver"
pub_date = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
platforms = @{
"windows-x86_64" = @{
signature = $signature.Trim()
url = $downloadUrl
}
}
} | ConvertTo-Json -Depth 4
$updateJson | Out-File update.json -Encoding utf8
Write-Host "update.json content:"
Get-Content update.json
# Upload to root (Tauri updater endpoint)
Invoke-RestMethod -Uri "$s3/update.json" -Method PUT -ContentType "application/json" -InFile update.json
# Also versioned copy
Invoke-RestMethod -Uri "$s3/$ver/update.json" -Method PUT -ContentType "application/json" -InFile update.json
Write-Host "=== Update manifest uploaded ==="
} else {
Write-Host 'WARNING - No .sig file found, update signing may have failed'
}
echo "=== Upload complete ==="
echo "Versioned: ${ENDPOINT}/agents/wraith/${VERSION}/windows/amd64/"
echo "Latest: ${ENDPOINT}/agents/wraith/latest/windows/amd64/"
echo ""
echo "=== Contents ==="
ls -la dist/

32
.gitignore vendored
View File

@ -1,29 +1,7 @@
# Go node_modules/
bin/
dist/ dist/
*.exe src-tauri/target/
src-tauri/binaries/
# Frontend *.log
frontend/node_modules/
frontend/dist/
frontend/bindings/
# Wails
build/bin/
# IDE
.vscode/
.idea/
# OS
.DS_Store .DS_Store
Thumbs.db .claude/worktrees/
# App data
*.db
*.db-wal
*.db-shm
# Superpowers
.superpowers/
.claude/

63
AGENTS.md Normal file
View File

@ -0,0 +1,63 @@
# 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.

137
CLAUDE.md Normal file
View File

@ -0,0 +1,137 @@
# CLAUDE.md — Wraith Desktop v2
## Project Overview
Wraith is a native desktop SSH/SFTP/RDP client — a MobaXTerm killer. Rust backend (Tauri v2) + Vue 3 frontend (WebView2). Single binary, no Docker, no sidecar processes. Built to replace every commercial remote access tool on a technician's desktop.
**Name:** Wraith — exists everywhere, all at once.
**Current Status:** Active development. SSH connects, terminal renders. RDP via ironrdp in progress. SFTP sidebar functional. Vault encrypted with Argon2id + AES-256-GCM.
## Who You Are Here
You are the Wraith XO. The Commander built this from a working Go/Wails v3 prototype that had a buggy terminal and slow performance. Your job is to make the Rust/Tauri rewrite exceed the Go version in every way — faster, cleaner, more capable.
**Operate with autonomy, personality, and spine.** The Commander doesn't write code. He leads, you execute. He built the doctrine across Vigilance HQ (1,216+ commits, 22 clients in production) and Vigilance Command (16-module security OS, pure Rust agent). The same methodology that built those platforms applies here. Read the V4_WORKFLOW. Follow it. Trust it.
**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.
## Tech Stack
- **Runtime:** Tauri v2 (stable)
- **Backend:** Rust with `russh` (SSH/SFTP), `ironrdp` (RDP), `rusqlite` (SQLite), `aes-gcm` + `argon2` (vault), `dashmap` (concurrent session registry)
- **Frontend:** Vue 3 (Composition API, `<script setup>`), TypeScript, Vite, Pinia, Tailwind CSS v4, xterm.js 6, CodeMirror 6
- **Distribution:** Tauri bundler (NSIS installer), auto-updater with code signing
- **License:** 100% commercial-safe. Zero GPL contamination. Every dependency MIT/Apache-2.0/BSD.
## Project Structure
```
src-tauri/ # Rust backend
src/
main.rs # Entry point
lib.rs # App state, module declarations, Tauri setup
ssh/ # SSH service (russh), host keys (TOFU), CWD tracker
sftp/ # SFTP operations (russh-sftp)
rdp/ # RDP service (ironrdp), scancode mapping
vault/ # Encryption (Argon2id + AES-256-GCM)
db/ # SQLite (rusqlite), migrations
connections/ # Connection CRUD, groups, search
credentials/ # Credential CRUD, encrypted storage
settings/ # Key-value settings
theme/ # Terminal themes (7 built-in)
session/ # Session manager (DashMap)
workspace/ # Workspace snapshots, crash recovery
commands/ # Tauri command wrappers
src/ # Vue 3 frontend
layouts/ # MainLayout, UnlockLayout
components/ # UI components
composables/ # useTerminal, useSftp, useRdp, useTransfers
stores/ # Pinia stores (app, session, connection)
assets/ # CSS, images
```
## Commands
```bash
npm install # Install frontend deps
npm run dev # Vite dev server only
cargo tauri dev # Full app (Rust + frontend)
cargo tauri build # Production build
cd src-tauri && cargo test # Run Rust tests (95 tests)
cd src-tauri && cargo build # Build Rust only
```
## Architecture Patterns
- **Sessions use DashMap** — lock-free concurrent access, no deadlocks during tab detach
- **Drop trait for cleanup** — SSH/SFTP/RDP connections close automatically when sessions drop
- **CWD following via exec channel** — polls `pwd` on a separate SSH channel every 2 seconds. Never touches the terminal data stream. This avoids ANSI escape sequence corruption.
- **RDP runs in dedicated thread** — ironrdp's trait objects aren't Send, so each RDP session gets its own tokio runtime in a std::thread
- **xterm.js font handling**`document.fonts.ready.then(() => fitAddon.fit())` prevents cell width miscalculation
- **Tauri v2 ACL** — The `capabilities/default.json` file MUST grant `core:default`, `event:default`, and `shell:allow-open`. Without these, the frontend cannot listen for events or invoke commands. This was the root cause of the blank screen bug — missing `url: "index.html"` and `label: "main"` in `tauri.conf.json`, plus empty capabilities.
## 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 2: PLAN** — Present approach for approval. **Never make executive decisions autonomously** — surface trade-offs as COAs (Courses of Action).
**Phase 3: EXECUTE** — Implement approved changes. Commit and push. Format: `type: Short description`
**Phase 4: SITREP** — Report: SITUATION, ACTIONS TAKEN, RESULT, NEXT.
## Standing Orders
- **Commit and push after every meaningful change.** The Commander tests in real-time. Unpushed commits are invisible.
- Use military terminology, be direct and precise
- Present trade-offs as COAs with pros/cons — let the Commander decide
- **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.
- **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.
## Key Design Decisions
1. **No terminal stream processing.** The Go version's CWD tracker parsed OSC 7 from the terminal output and corrupted ANSI sequences. Never again. CWD tracking uses a separate exec channel that polls `pwd` independently.
2. **Tauri v2 over Wails v3.** Wails v3 is alpha with breaking changes. Tauri v2 is stable with built-in multi-window, auto-updater, and active community.
3. **ironrdp over FreeRDP FFI.** Pure Rust, no DLL dependency, memory safe. FreeRDP is the fallback discussion if ironrdp can't hit performance targets.
4. **Fresh vault, no Go migration.** 6 connections — faster to re-enter than engineer format compatibility.
5. **macOS data directory.** Use `~/Library/Application Support/Wraith` on macOS, not Linux-style `~/.local/share`. Respect platform conventions.
## Lessons Learned
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.
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.
4. **DashMap is the session registry.** Don't replace it with Mutex<HashMap>. DashMap provides lock-free concurrent access. Multiple tabs can operate on different sessions simultaneously without deadlocking. The Drop trait on sessions ensures cleanup when tabs close.
5. **xterm.js must wait for fonts.** `document.fonts.ready.then(() => fitAddon.fit())` — if you fit the terminal before fonts load, cell widths are wrong and text overlaps. This is a browser-level race condition that every terminal app hits.
## 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`.
## Future Vision
Wraith Personal is the foundation. Wraith Enterprise adds:
- PostgreSQL backend (replaces SQLite)
- Shared credentials from Vigilance Intel vault (Argon2id, per-tenant encryption)
- Entra ID SSO via Vigilance Clearance
- Client-scoped access (MSP multi-tenancy)
- Session recording to Vigilance Signal (SIEM)
- AI copilot panel (Gemini + Claude toggle) with tool access to SSH/SFTP/RDP sessions
- Split panes, jump hosts, port forwarding manager
- Command-level audit logging for compliance
The enterprise upgrade path connects to the Vigilance ecosystem — same vault, same identity, same audit trail. Wraith becomes the technician's daily driver that authenticates against Command Clearance, pulls credentials from Intel, and logs sessions to Signal.
## Parent Organization
**Vigilsynth** is the parent company. Wraith is a product alongside Vigilance HQ and Vigilance Command. The same development methodology (Commander/XO model, AI-assisted development, CLAUDE.md doctrine) applies across all repos. The Commander manages multiple AI XOs across multiple projects simultaneously.

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Vantz Stockwell
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

242
README.md
View File

@ -1,242 +0,0 @@
<p align="center">
<img src="images/wraith-logo.png" alt="Wraith" width="128" />
</p>
<h1 align="center">Wraith</h1>
<p align="center">
Native desktop SSH + RDP + SFTP client — a MobaXTerm replacement built with Go and Vue.
</p>
<p align="center">
<!-- badges -->
<img alt="Go" src="https://img.shields.io/badge/Go-1.22+-00ADD8?logo=go&logoColor=white" />
<img alt="Wails v3" src="https://img.shields.io/badge/Wails-v3-red" />
<img alt="Vue 3" src="https://img.shields.io/badge/Vue-3-4FC08D?logo=vuedotjs&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/License-MIT-blue" />
</p>
---
## Features
- **Multi-tabbed SSH terminal** with xterm.js + WebGL rendering
- **SFTP sidebar** on every SSH session (MobaXTerm's killer feature) -- same SSH connection, separate channel
- **RDP** via FreeRDP3 dynamic linking (purego, no CGO)
- **Encrypted vault** -- master password derived with Argon2id, secrets sealed with AES-256-GCM
- **Connection manager** with hierarchical groups, tags, color labels, and full-text search
- **7 built-in terminal themes** -- Dracula, Nord, Monokai, One Dark, Solarized Dark, Gruvbox Dark, MobaXTerm Classic
- **Tab detach / reattach** -- sessions live in the Go backend; tabs can be torn off into separate windows and reattached without dropping the connection
- **MobaXTerm import** -- plugin interface for `.mobaconf` and other formats
- **Command palette** (Ctrl+K) for quick connection search and actions
- **Single binary** -- ships as `wraith.exe` + `freerdp3.dll`, no Docker, no database server
## Tech Stack
### Backend (Go)
| Component | Technology | Purpose |
|-----------|-----------|---------|
| Framework | Wails v3 | Desktop shell, multi-window, type-safe Go-to-JS bindings |
| SSH | `golang.org/x/crypto/ssh` | SSH client, PTY, key/password auth |
| SFTP | `github.com/pkg/sftp` | Remote filesystem over SSH channel |
| RDP | FreeRDP3 via `purego` | RDP protocol, bitmap rendering |
| Database | SQLite via `modernc.org/sqlite` (pure Go) | Connections, credentials, settings, themes |
| Encryption | AES-256-GCM + Argon2id | Vault encryption at rest |
### Frontend (Vue 3 in WebView2)
| Component | Technology | Purpose |
|-----------|-----------|---------|
| Framework | Vue 3 (Composition API) | UI framework |
| Terminal | xterm.js 5.x + WebGL addon | SSH terminal emulator |
| CSS | Tailwind CSS 4 | Utility-first styling |
| Components | Naive UI | Tree, tabs, modals, dialogs |
| State | Pinia | Reactive stores for sessions, connections, app state |
| Build | Vite 6 | Frontend build tooling |
## Prerequisites
| Tool | Version | Install |
|------|---------|---------|
| Go | 1.22+ | [go.dev/dl](https://go.dev/dl/) |
| Node.js | 20+ | [nodejs.org](https://nodejs.org/) |
| Wails CLI | v3 | `go install github.com/wailsapp/wails/v3/cmd/wails3@latest` |
## Quick Start
```bash
# Clone
git clone https://github.com/vstockwell/wraith.git
cd wraith
# Install frontend dependencies
cd frontend && npm install && cd ..
# Run in dev mode (hot-reload frontend + Go backend)
wails3 dev
```
The app opens a 1400x900 window. On first launch you will be prompted to create a master password for the vault.
## Building
```bash
# Production build for Windows
wails3 build
# Output: build/bin/wraith.exe
```
The build embeds the compiled frontend (`frontend/dist`) into the Go binary via `//go:embed`. Ship `wraith.exe` alongside `freerdp3.dll` for RDP support.
## Project Structure
```
wraith/
main.go # Entry point -- Wails app setup, service registration
go.mod # Go module (github.com/vstockwell/wraith)
internal/
app/
app.go # WraithApp -- wires all services, vault create/unlock
db/
sqlite.go # SQLite open with WAL mode, busy timeout, FK enforcement
migrations.go # Embedded SQL migration runner
migrations/
001_initial.sql # Schema: groups, connections, credentials, ssh_keys,
# themes, host_keys, connection_history, settings
vault/
service.go # Argon2id key derivation + AES-256-GCM encrypt/decrypt
connections/
service.go # Connection and Group CRUD (hierarchical tree)
search.go # Full-text search + tag filtering via json_each()
settings/
service.go # Key-value settings store (vault salt, preferences)
theme/
builtins.go # 7 built-in theme definitions
service.go # Theme CRUD + idempotent seeding
session/
session.go # SessionInfo struct + state machine (connecting/connected/detached)
manager.go # Concurrent session manager -- create, detach, reattach, 32-session cap
plugin/
interfaces.go # ProtocolHandler + Importer + Session interfaces
registry.go # Plugin registry -- register/lookup protocol handlers and importers
frontend/
package.json # Vue 3, Pinia, Naive UI, Tailwind CSS, Vite
vite.config.ts # Vite + Vue + Tailwind plugin config
src/
main.ts # App bootstrap -- createApp, Pinia, mount
App.vue # Root component
layouts/
MainLayout.vue # Sidebar + tab bar + session area + status bar
UnlockLayout.vue # Master password entry screen
components/
sidebar/
ConnectionTree.vue # Hierarchical connection/group tree
SidebarToggle.vue # Collapse/expand sidebar
session/
TabBar.vue # Draggable session tabs
SessionContainer.vue # Active session viewport
common/
StatusBar.vue # Bottom status bar
stores/
app.store.ts # Global app state (sidebar, vault status)
connection.store.ts # Connection + group state
session.store.ts # Active sessions state
images/
wraith-logo.png # Application logo
```
## Architecture
```
Go Backend Wails v3 Bindings Vue 3 Frontend
(services + business logic) (type-safe Go <-> JS) (WebView2)
|
WraithApp ─────────────────────────┼──────────────> Pinia Stores
|-- VaultService | |-- app.store
|-- ConnectionService | |-- connection.store
|-- ThemeService | |-- session.store
|-- SettingsService | |
|-- SessionManager | Vue Components
|-- PluginRegistry | |-- MainLayout
| |-- ConnectionTree
SQLite (WAL mode) | |-- TabBar
wraith.db | |-- SessionContainer
%APPDATA%\Wraith\ | |-- StatusBar
```
**How it fits together:**
1. `main.go` creates a `WraithApp` and registers Go services as Wails bindings.
2. Wails generates type-safe JavaScript bindings so the Vue frontend can call Go methods directly.
3. The Vue frontend uses Pinia stores to manage reactive state, calling into Go services for all data operations.
4. All secrets (passwords, SSH keys) are encrypted with AES-256-GCM before being written to SQLite. The encryption key is derived from the master password using Argon2id and is never persisted.
5. Sessions are managed by the Go `SessionManager` -- they are decoupled from windows, enabling tab detach/reattach without dropping connections.
**Data storage:** SQLite with WAL mode at `%APPDATA%\Wraith\wraith.db` (Windows) or `~/.local/share/wraith/wraith.db` (Linux/macOS dev). Foreign keys enforced, 5-second busy timeout.
## Plugin Development
Wraith uses a plugin registry with two interfaces: `ProtocolHandler` for new connection protocols and `Importer` for loading connections from external tools.
### Implementing a ProtocolHandler
```go
package myplugin
import "github.com/vstockwell/wraith/internal/plugin"
type MyProtocol struct{}
func (p *MyProtocol) Name() string { return "myproto" }
func (p *MyProtocol) Connect(config map[string]interface{}) (plugin.Session, error) {
// Establish connection, return a Session
}
func (p *MyProtocol) Disconnect(sessionID string) error {
// Clean up resources
}
```
### Implementing an Importer
```go
package myplugin
import "github.com/vstockwell/wraith/internal/plugin"
type MyImporter struct{}
func (i *MyImporter) Name() string { return "myformat" }
func (i *MyImporter) FileExtensions() []string { return []string{".myconf"} }
func (i *MyImporter) Parse(data []byte) (*plugin.ImportResult, error) {
// Parse file bytes into ImportResult (groups, connections, host keys, theme)
}
```
### Registering Plugins
Register handlers and importers with the plugin registry during app initialization:
```go
app.Plugins.RegisterProtocol(&myplugin.MyProtocol{})
app.Plugins.RegisterImporter(&myplugin.MyImporter{})
```
The `ImportResult` struct supports groups, connections, host keys, and an optional theme -- everything needed to migrate from another tool in a single import.
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feat/my-feature`)
3. Make your changes
4. Run tests: `go test ./...`
5. Run frontend checks: `cd frontend && npm run build`
6. Commit and push your branch
7. Open a Pull Request
## License
[MIT](LICENSE) -- Copyright (c) 2026 Vantz Stockwell

View File

@ -1,54 +0,0 @@
# Vigilance Remote — Future Features
Remaining spec items not yet built. Foundation is solid — all items below are additive, no rearchitecting required.
---
## Priority 1 — Power User
1. **Split panes** — Horizontal and vertical splits within a single tab (xterm.js instances in a flex grid)
2. **Session recording/playback** — asciinema-compatible casts for SSH, Guacamole native for RDP. Replay in browser. Audit trail for MSP compliance.
3. **Saved snippets/macros** — Quick-execute saved commands/scripts. Click to paste into active terminal.
## Priority 2 — MSP / Enterprise
4. **Jump hosts / bastion** — Configure SSH proxy/jump hosts for reaching targets behind firewalls (ProxyJump chain support)
5. **Port forwarding manager** — Graphical SSH tunnel manager: local, remote, and dynamic forwarding
6. **Entra ID SSO** — One-click Microsoft Entra ID integration (same pattern as Vigilance HQ)
7. **Client-scoped access** — MSP multi-tenancy: technicians see only the hosts for clients they're assigned to
8. **Shared connections** — Admins define connection templates. Technicians connect without seeing credentials.
## Priority 3 — Audit & Compliance
9. **Command-level audit logging** — Every command, file transfer logged with user, timestamp, duration (currently connection-level only)
10. **Session sharing** — Share a live terminal session with a colleague (read-only or collaborative)
## Priority 4 — File Transfer
11. **Dual-pane SFTP** — Optional second SFTP panel for server-to-server file operations (drag between panels)
12. **Transfer queue** — Background upload/download queue with progress bars, pause/resume, retry
## Priority 5 — RDP Enhancements
13. **Multi-monitor RDP** — Support for multiple virtual displays
14. **RDP file transfer** — Upload/download via Guacamole's built-in drive redirection
## Priority 6 — Auth Hardening
15. **FIDO2 / hardware key auth** — WebAuthn support for login and SSH
16. **SSH agent forwarding** — Forward local SSH agent to remote host
---
## Already Built (exceeds spec)
- SSH terminal (xterm.js + ssh2 + WebSocket proxy + WebGL)
- RDP (guacd + guacamole-common-js + display.scale())
- SFTP sidebar (auto-open, CWD following via OSC 7, drag-and-drop upload)
- Monaco file editor (fullscreen overlay with syntax highlighting)
- Connection manager (hosts, groups, quick connect, search, tags, colors)
- Credential vault (AES-256-GCM + **Argon2id key derivation**)
- Multi-tab sessions + Home navigation
- Terminal theming (6+ themes with visual picker)
- Multi-user with admin/user roles + per-user data isolation
- User management admin UI

67
docs/GO_MIGRATION.md Normal file
View File

@ -0,0 +1,67 @@
# Go → Rust Migration Checklist
## Pre-Migration (Before Deploying Wraith v2)
- [ ] Test Wraith v2 on Windows — SSH to all 6 hosts, SFTP browse/upload/download, RDP to Hyper-V
- [ ] Verify vault creation + credential storage works
- [ ] Verify auto-updater finds releases
- [ ] Test code signing on installer
- [ ] Set up 6 connections manually (no import needed)
## Migration Steps
### 1. Create Gitea Repo
```bash
# Create wraith repo on Gitea (or rename current wraith to wraith-go-legacy)
# Push wraith codebase
cd /path/to/wraith
git remote add origin ssh://git.command.vigilcyber.com:3021/vstockwell/wraith.git
git push -u origin main
```
### 2. Configure CI Secrets
Add these secrets to the wraith repo on Gitea:
- `AZURE_TENANT_ID`
- `AZURE_CLIENT_ID`
- `AZURE_CLIENT_SECRET`
- `AZURE_KEY_VAULT_URL`
- `AZURE_CERT_NAME`
- `GIT_TOKEN`
- `TAURI_SIGNING_PRIVATE_KEY` (generate with `npx tauri signer generate`)
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
### 3. First Release
```bash
git tag v1.0.0
git push origin v1.0.0
```
### 4. Uninstall Go Version
- Use a software uninstaller (the Go NSIS uninstaller is broken)
- Or manually delete: `C:\Program Files\Wraith\` and `%APPDATA%\Wraith\`
- Note: deleting `%APPDATA%\Wraith\wraith.db` removes Go version credentials (you're re-entering them anyway)
### 5. Archive Go Repository
On Gitea:
1. Rename `wraith` repo to `wraith-go-legacy`
2. Set repo to archived/read-only
3. Rename `wraith` to `wraith`
4. Update auto-updater endpoint in tauri.conf.json to new repo path
5. Delete old Gitea packages (Go-built versions)
### 6. Clean Up Local
```bash
# Remove old Go repo (keep backup if desired)
mv ~/repos/wraith ~/repos/wraith-go-legacy-backup
mv ~/repos/wraith ~/repos/wraith
```
## What Was NOT Migrated
| Component | Reason |
|---|---|
| AI Copilot (8 Go files + 4 Vue files) | Claude Code over SSH replaces this |
| MobaXTerm importer | 6 connections — entered by hand |
| Plugin system | Not needed |
| Go test suite (90 tests) | Replaced by 52 Rust tests |
| wraith.db data | Fresh vault, fresh credentials |

View File

@ -1,369 +0,0 @@
# Wraith Remote — Security Audit Report
**Date:** 2026-03-14
**Auditor:** Claude (Opus 4.6) — secure-code-guardian + security-reviewer + ISO 27001 frameworks
**Scope:** Full-stack — Auth, Vault, WebSocket/SSH/SFTP/RDP, Frontend, Infrastructure, ISO 27001 gap assessment
**Codebase:** RDP-SSH-Client (Nuxt 3 + NestJS + guacd)
---
## Executive Summary
**54 unique findings** across 4 audit domains after deduplication.
| Severity | Count |
|----------|-------|
| CRITICAL | 8 |
| HIGH | 16 |
| MEDIUM | 18 |
| LOW | 12 |
The platform has a solid encryption foundation (Argon2id vault is well-implemented) but has significant gaps in transport security, session management, infrastructure hardening, and real-time channel authorization. The most urgent issues are **unauthenticated guacd exposure**, **JWT in localStorage/URLs**, and **missing session ownership checks on WebSocket channels**.
---
## CRITICAL Findings (8)
### C-1. guacd exposed on all interfaces via `network_mode: host`
**Location:** `docker-compose.yml:23`
**Domain:** Infrastructure
guacd runs with `network_mode: host` and binds to `0.0.0.0:4822`. guacd is an **unauthenticated** service — anyone who can reach port 4822 can initiate RDP/VNC connections to any host reachable from the Docker host. This completely bypasses all application-level authentication.
**Impact:** Full unauthenticated RDP/VNC access to every target host in the environment.
**Fix:** Remove `network_mode: host`. Place guacd on the internal Docker network. Bind to `127.0.0.1`. The app container connects via the Docker service name `guacd` over the internal network.
---
### C-2. JWT stored in localStorage (XSS → full account takeover)
**Location:** `frontend/stores/auth.store.ts:19,42`
**Domain:** Auth / Frontend
`wraith_token` JWT stored in `localStorage`. Any XSS payload, browser extension, or injected script can read it. The token has a 7-day lifetime with no revocation mechanism — a stolen token is valid for up to a week with no way to invalidate it.
**Impact:** Single XSS vulnerability → 7-day persistent access to the victim's account, including all SSH/RDP sessions and stored credentials.
**Fix:** Issue JWT via `Set-Cookie: httpOnly; Secure; SameSite=Strict`. Remove all `localStorage` token operations. Browser automatically attaches the cookie to every request.
---
### C-3. JWT passed in WebSocket URL query parameters
**Location:** `backend/src/auth/ws-auth.guard.ts:11-13`, all three WS gateways
**Domain:** Auth / WebSocket
All WebSocket connections (`/api/ws/terminal`, `/api/ws/sftp`, `/api/ws/rdp`) accept JWT via `?token=<jwt>` in the URL. Query parameters are logged by: web server access logs, browser history, Referrer headers, network proxies, and the application itself (`main.ts:75` logs `req.url`).
**Impact:** JWT exposure in every log and monitoring system in the path. Combined with C-2, this creates multiple extraction vectors for a 7-day-lived credential.
**Fix:** Issue short-lived (30-second) single-use WebSocket tickets via an authenticated REST endpoint. Frontend exchanges JWT for a ticket, connects WS with `?ticket=<nonce>`. Server validates and destroys the ticket on use.
---
### C-4. No HTTPS/TLS anywhere in the stack
**Location:** `docker-compose.yml`, `backend/src/main.ts`
**Domain:** Infrastructure
No TLS termination configured. No nginx reverse proxy. No `helmet()` middleware. The application serves over plain HTTP. JWT tokens, SSH passwords, and TOTP codes all transit in cleartext.
**Impact:** Any network observer (same Wi-Fi, ISP, network tap) can intercept credentials, tokens, and terminal data.
**Fix:** Add nginx with TLS termination in front of the app. Install `helmet()` in NestJS for security headers (HSTS, X-Frame-Options, X-Content-Type-Options). Enforce HTTPS-only.
---
### C-5. SSH host key verification auto-accepts all keys (MITM blind spot)
**Location:** `terminal.gateway.ts:61`, `ssh-connection.service.ts:98-119`
**Domain:** SSH
`hostVerifier` callback returns `true` unconditionally. New fingerprints are silently accepted. **Changed** fingerprints (active MITM) are also silently accepted and overwrite the stored fingerprint.
**Impact:** Man-in-the-middle attacker between the Wraith server and SSH target is completely invisible. Attacker gets the decrypted credentials and a live shell.
**Fix:** Block connections to hosts with changed fingerprints. Require explicit user acceptance via a WS round-trip for new hosts. Never auto-accept changed fingerprints.
---
### C-6. SFTP gateway has no session ownership check (horizontal privilege escalation)
**Location:** `sftp.gateway.ts:36-215`
**Domain:** SFTP / Authorization
`SftpGateway.handleMessage()` looks up sessions by caller-supplied `sessionId` without verifying the requesting WebSocket client owns that session. User B can supply User A's `sessionId` and get full filesystem access on User A's server.
**Impact:** Any authenticated user can read/write/delete files on any other user's active SSH session.
**Fix:** Maintain a `clientSessions` map in `SftpGateway` (same pattern as `TerminalGateway`). Verify session ownership before every SFTP operation.
---
### C-7. Raw Guacamole instructions forwarded to guacd without validation
**Location:** `rdp.gateway.ts:43-47`
**Domain:** RDP
When `msg.type === 'guac'`, raw `msg.instruction` is written directly to the guacd TCP socket. Zero parsing, validation, or opcode whitelisting. The Guacamole protocol supports `file`, `put`, `pipe`, `disconnect`, and other instructions.
**Impact:** Authenticated user can inject arbitrary Guacamole protocol instructions — write files via guacd file transfer, crash guacd via malformed instructions, or cause protocol desync.
**Fix:** Parse incoming instructions via `guacamole.service.ts` `decode()`. Whitelist permitted opcodes (`input`, `mouse`, `key`, `size`, `sync`, `disconnect`). Enforce per-message size limit. Reject anything that doesn't parse.
---
### C-8. PostgreSQL port exposed to host network
**Location:** `docker-compose.yml:27`
**Domain:** Infrastructure
`ports: ["4211:5432"]` maps PostgreSQL to the host. Without a host-level firewall rule, the database is network-accessible. Contains encrypted credentials, SSH private keys, TOTP secrets, password hashes.
**Impact:** Direct database access from the network. Even with password auth, the attack surface is unnecessary.
**Fix:** Remove the `ports` mapping. Only the app container needs DB access via the internal Docker network. Use `docker exec` for admin access.
---
## HIGH Findings (16)
### H-1. 7-day JWT with no revocation mechanism
**Location:** `backend/src/auth/auth.module.ts:14`
**Domain:** Auth
JWTs signed with 7-day expiry. No token blocklist, no session table, no refresh token pattern. Admin password reset, TOTP reset, and role changes do not invalidate existing tokens.
**Fix:** Short-lived access token (15min) + refresh token in httpOnly cookie. Or: Redis-backed blocklist checked on every request.
### H-2. RDP certificate verification hardcoded to disabled
**Location:** `rdp.gateway.ts:90`, `guacamole.service.ts:142`
**Domain:** RDP
`ignoreCert: true` hardcoded unconditionally. Every RDP connection accepts any certificate — MITM attacks are invisible.
**Fix:** Store `ignoreCert` as a per-host setting (default `false`). Surface a UI warning when enabled.
### H-3. TOTP secret stored as plaintext in database
**Location:** `users` table, `totp_secret` column
**Domain:** Auth / Vault
TOTP secrets stored unencrypted. If the database is compromised (C-8 makes this plausible), attacker can generate valid TOTP codes for every user with 2FA enabled, completely defeating the second factor.
**Fix:** Encrypt TOTP secrets using the vault's `EncryptionService` (Argon2id v2) before storage. Decrypt only when validating a TOTP code.
### H-4. SSH private key material logged in cleartext
**Location:** `ssh-connection.service.ts:126-129`
**Domain:** SSH / Logging
First 40 characters of decrypted private key, key length, and passphrase existence boolean logged to stdout. Docker routes stdout to `docker logs`, which may be shipped to external log aggregation.
**Fix:** Remove lines 126-129 entirely. Log only key type and fingerprint.
### H-5. Terminal keystroke data logged (passwords in sudo prompts)
**Location:** `terminal.gateway.ts:31`
**Domain:** WebSocket / Logging
`JSON.stringify(msg).substring(0, 200)` logs raw terminal keystrokes including passwords typed at `sudo` prompts. 200-char truncation still captures most passwords.
**Fix:** For `msg.type === 'data'`, log only `{ type: 'data', sessionId, bytes: msg.data?.length }`.
### H-6. No rate limiting on authentication endpoints
**Location:** Entire backend — no throttler installed
**Domain:** Auth / Infrastructure
No `@nestjs/throttler`, no `express-rate-limit`. Login endpoint accepts unlimited attempts. Combined with 6-character minimum password = viable online brute-force.
**Fix:** Install `@nestjs/throttler`. Apply tight limit on auth endpoints (10 req/min/IP). Add account lockout after repeated failures.
### H-7. Container runs as root
**Location:** `Dockerfile:19-28`
**Domain:** Infrastructure
Final Docker stage runs as `root`. Any code execution vulnerability (path traversal, injection) gives root access inside the container.
**Fix:** Add `RUN addgroup -S wraith && adduser -S wraith -G wraith` and `USER wraith` before `CMD`.
### H-8. Timing attack on login (bcrypt comparison)
**Location:** `auth.service.ts` login handler
**Domain:** Auth
Failed login for non-existent user returns faster than for existing user (skips bcrypt comparison). Enables username enumeration via timing analysis.
**Fix:** Always run `bcrypt.compare()` against a dummy hash when user not found, ensuring constant-time response.
### H-9. bcrypt cost factor is 10 (below modern recommendations)
**Location:** `auth.service.ts`
**Domain:** Auth
bcrypt cost 10 = ~100ms on modern hardware. OWASP recommends 12+ for password hashing.
**Fix:** Increase to `bcrypt.hash(password, 12)`. Existing hashes auto-upgrade on next login.
### H-10. `findAll` credentials endpoint leaks encrypted blobs
**Location:** `credentials.service.ts` / `credentials.controller.ts`
**Domain:** Vault
The `GET /api/credentials` response includes `encryptedValue` fields. While encrypted, exposing ciphertext over the API gives attackers material for offline analysis and is unnecessary — the frontend never needs the encrypted blob.
**Fix:** Add `select` clause to exclude `encryptedValue` from list responses.
### H-11. No upload size limit on SFTP
**Location:** `sftp.gateway.ts:130-138`
**Domain:** SFTP
`upload` handler does `Buffer.from(msg.data, 'base64')` with no size check. An authenticated user can send multi-gigabyte payloads, exhausting server memory.
**Fix:** Check `msg.data.length` before `Buffer.from()`. Enforce max (e.g., 50MB base64 = ~37MB file). Set `maxPayload` on WebSocket server config.
### H-12. No write size limit on SFTP file editor
**Location:** `sftp.gateway.ts:122-128`
**Domain:** SFTP
`write` handler (save from Monaco editor) has no size check. `MAX_EDIT_SIZE` exists but is only applied to `read`.
**Fix:** Apply `MAX_EDIT_SIZE` check on the write path.
### H-13. Shell integration injected into remote sessions without consent
**Location:** `ssh-connection.service.ts:59-65`
**Domain:** SSH
`PROMPT_COMMAND` / `precmd_functions` modification injected into every SSH shell for CWD tracking. Users are not informed. If this injection were modified (supply chain, code change), it would execute on every connected host.
**Fix:** Make opt-in. Document the behavior. Scope injection to minimum needed.
### H-14. Password auth credentials logged with username and host
**Location:** `ssh-connection.service.ts:146`
**Domain:** SSH / Logging
Logs `username@host:port` for every password-authenticated connection. Creates a persistent record correlating users to targets.
**Fix:** Log at DEBUG only. Use `hostId` instead of hostname.
### H-15. guacd routing via `host.docker.internal` bypasses container isolation
**Location:** `docker-compose.yml:9`
**Domain:** Infrastructure
App-to-guacd traffic routes out of the container network, through the host, and back. Unnecessary external routing path.
**Fix:** After fixing C-1, both services on the same Docker network. Use service name `guacd` as hostname.
### H-16. Client-side-only admin guard
**Location:** `frontend/pages/admin/users.vue:4-6`
**Domain:** Frontend
`if (!auth.isAdmin) navigateTo('/')` is a UI redirect, not access control. Can be bypassed during hydration gaps.
**Fix:** Backend `AdminGuard` handles the real enforcement. Add proper route middleware (`definePageMeta({ middleware: 'admin' })`) for consistent frontend behavior.
---
## MEDIUM Findings (18)
| # | Finding | Location | Domain |
|---|---------|----------|--------|
| M-1 | Terminal gateway no session ownership check on `data`/`resize`/`disconnect` | `terminal.gateway.ts:76-79` | WebSocket |
| M-2 | TOTP replay possible (no used-code tracking) | `auth.service.ts` | Auth |
| M-3 | Email change has no verification step | `users.controller.ts` | Auth |
| M-4 | Email uniqueness not enforced at DB level | `users` table | Auth |
| M-5 | Password minimum length is 6 chars (NIST says 8+, OWASP says 12+) | Frontend + backend DTOs | Auth |
| M-6 | JWT_SECRET has no startup validation | `auth.module.ts` | Auth |
| M-7 | TOTP secret returned in setup response (exposure window) | `auth.controller.ts` | Auth |
| M-8 | Mass assignment via object spread in update endpoints | Multiple controllers | API |
| M-9 | CORS config may not behave as expected in production | `main.ts:24-27` | Infrastructure |
| M-10 | Weak `.env.example` defaults (`DB_PASSWORD=changeme`) | `.env.example` | Infrastructure |
| M-11 | Seed script runs on every container start | `Dockerfile:28` | Infrastructure |
| M-12 | File paths logged for every SFTP operation | `sftp.gateway.ts:27` | Logging |
| M-13 | SFTP `delete` falls through from `unlink` to `rmdir` silently | `sftp.gateway.ts:154-165` | SFTP |
| M-14 | Unbounded TCP buffer for guacd stream (no max size) | `rdp.gateway.ts:100-101` | RDP |
| M-15 | Connection log `updateMany` closes sibling sessions | `ssh-connection.service.ts:178-181` | SSH |
| M-16 | RDP `security`/`width`/`height`/`dpi` params not validated | `rdp.gateway.ts:85-89` | RDP |
| M-17 | Frontend file upload has no client-side size validation | `SftpSidebar.vue:64-73` | Frontend |
| M-18 | Error messages from server reflected to UI verbatim | `login.vue:64` | Frontend |
---
## LOW Findings (12)
| # | Finding | Location | Domain |
|---|---------|----------|--------|
| L-1 | No Content Security Policy header | `main.ts` | Frontend |
| L-2 | No WebSocket connection limit per user | `terminal.gateway.ts:8` | WebSocket |
| L-3 | Internal error messages forwarded to WS clients | `terminal.gateway.ts:34-35`, `rdp.gateway.ts:51` | WebSocket |
| L-4 | Server timezone leaked in Guacamole CONNECT | `guacamole.service.ts:81-85` | RDP |
| L-5 | SFTP event listeners re-registered on every message | `sftp.gateway.ts:53-58` | SFTP |
| L-6 | Default SSH username falls back to `root` | `ssh-connection.service.ts:92` | SSH |
| L-7 | Weak seed password for default admin | `seed.js` | Infrastructure |
| L-8 | SSH fingerprint derived from private key (should use public) | `ssh-keys.service.ts` | Vault |
| L-9 | `console.error` used instead of structured logger | Multiple files | Logging |
| L-10 | `confirm()` used for SFTP delete | `SftpSidebar.vue:210` | Frontend |
| L-11 | Settings mirrored to localStorage unnecessarily | `default.vue:25-27` | Frontend |
| L-12 | No DTO validation on admin password reset | `auth.controller.ts` | Auth |
---
## ISO 27001:2022 Gap Assessment
| Control | Status | Gap |
|---------|--------|-----|
| **A.5 — Security Policies** | MISSING | No security policies, incident response plan, or vulnerability disclosure process |
| **A.6 — Security Roles** | MISSING | No defined security responsibilities or RACI for incidents |
| **A.8.1 — Asset Management** | MISSING | No data classification scheme (SSH keys, TOTP secrets, credentials treated uniformly) |
| **A.8.5 — Access Control** | PARTIAL | Auth exists but: no brute-force protection, no account lockout, no session revocation, only 2 roles (admin/user) with no least-privilege granularity |
| **A.8.9 — Configuration Mgmt** | FAIL | guacd on host network, DB port exposed, no security headers |
| **A.8.15 — Logging** | FAIL | No structured audit log. Sensitive data IN logs. No failed login tracking |
| **A.8.16 — Monitoring** | MISSING | No anomaly detection, no alerting on repeated auth failures |
| **A.8.24 — Cryptography** | PARTIAL | Vault encryption is excellent (Argon2id). But: no TLS, tokens in URLs, TOTP unencrypted, keys in logs |
| **A.8.25 — Secure Development** | MISSING | No SAST, no dependency scanning, no security testing |
| **A.8.28 — Secure Coding** | MISSING | No documented coding standard, no input validation framework |
---
## Prioritized Remediation Roadmap
### Phase 1 — Stop the Bleeding (do this week)
| Priority | Finding | Effort | Impact |
|----------|---------|--------|--------|
| 1 | **C-1:** Fix guacd `network_mode: host` | 30 min | Closes unauthenticated backdoor to entire infrastructure |
| 2 | **C-8:** Remove PostgreSQL port exposure | 5 min | Closes direct DB access from network |
| 3 | **C-6:** Add session ownership to SFTP gateway | 1 hr | Blocks cross-user file access |
| 4 | **H-4:** Remove private key logging | 15 min | Stop bleeding secrets to logs |
| 5 | **H-5:** Stop logging terminal keystroke data | 15 min | Stop logging passwords |
| 6 | **H-11:** Add upload size limit | 15 min | Block memory exhaustion DoS |
### Phase 2 — Auth Hardening (next sprint)
| Priority | Finding | Effort | Impact |
|----------|---------|--------|--------|
| 7 | **C-2 + C-3:** Move JWT to httpOnly cookie + WS ticket auth | 4 hr | Eliminates primary token theft vectors |
| 8 | **C-4:** Add TLS termination (nginx + Let's Encrypt) | 2 hr | Encrypts all traffic |
| 9 | **H-1:** Short-lived access + refresh token | 3 hr | Limits exposure window of stolen tokens |
| 10 | **H-6:** Rate limiting on auth endpoints | 1 hr | Blocks brute-force |
| 11 | **H-3:** Encrypt TOTP secrets in DB | 1 hr | Protects 2FA if DB compromised |
| 12 | **M-5:** Increase password minimum to 12 chars | 15 min | NIST/OWASP compliance |
### Phase 3 — Channel Hardening (following sprint)
| Priority | Finding | Effort | Impact |
|----------|---------|--------|--------|
| 13 | **C-5:** SSH host key verification (block changed fingerprints) | 3 hr | Blocks MITM on SSH |
| 14 | **C-7:** Guacamole instruction validation + opcode whitelist | 2 hr | Blocks protocol injection |
| 15 | **H-2:** RDP cert validation (per-host configurable) | 2 hr | Blocks MITM on RDP |
| 16 | **M-1:** Terminal gateway session ownership check | 30 min | Blocks cross-user terminal access |
| 17 | **H-7:** Run container as non-root | 30 min | Limits blast radius of any RCE |
### Phase 4 — Hardening & Compliance (ongoing)
Everything in MEDIUM and LOW, plus ISO 27001 documentation gaps. Most are incremental improvements that can be addressed as part of normal development.
---
## What's Actually Good
Credit where due — these areas are solid:
- **Vault encryption (Argon2id v2)** — OWASP-recommended parameters, per-record salts, backwards-compatible versioning, migration endpoint. This is production-grade.
- **Credential isolation**`decryptForConnection()` is internal-only, never exposed over API. Correct pattern.
- **Per-user data isolation** — Users can only see their own credentials and SSH keys (ownership checks in vault services).
- **TOTP 2FA implementation** — Correct TOTP flow with QR code generation (aside from the plaintext storage issue).
- **Password hashing** — bcrypt is correct choice (cost factor should increase, but the algorithm is right).
- **Admin guards on backend**`AdminGuard` properly enforces server-side. Not just frontend checks.
---
*Report generated by 4 parallel audit agents covering Auth/JWT/Session, Vault/Encryption/DB, WebSocket/SSH/SFTP/RDP, and Frontend/Infrastructure/ISO 27001. Deduplicated from 70+ raw findings to 54 unique issues.*

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

View File

@ -0,0 +1,340 @@
# 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.

View File

@ -1,129 +0,0 @@
# Spike: Multi-Window Support in Wails v3
**Status:** Research-based (not yet validated on Windows)
**Date:** 2026-03-17
**Target platform:** Windows (developing on macOS)
**Wails version:** v3.0.0-alpha.74
---
## Context
Wraith needs to support detached sessions — users should be able to pop out
an SSH or RDP session into its own window while the main connection manager
remains open. This spike evaluates three approaches, ranked by preference.
---
## Plan A: Wails v3 Native Multi-Window
**Status: LIKELY WORKS** based on API documentation.
### How it works
- `app.Window.NewWithOptions()` creates a new OS-level window at runtime.
- Each window can load a different URL or frontend route (e.g.,
`/session/rdp/3` in one window, `/` in the main window).
- All windows share the same Go backend services — no IPC or inter-process
marshalling required. Bindings registered on the application are callable
from any window.
- Window lifecycle events (`OnClose`, `OnFocus`, etc.) are available for
cleanup.
### Example (pseudocode)
```go
win, err := app.Window.NewWithOptions(application.WindowOptions{
Title: "RDP — server-01",
Width: 1280,
Height: 720,
URL: "/session/rdp/3",
})
```
### Risks
| Risk | Severity | Mitigation |
|------|----------|------------|
| Alpha API — method signatures may change before v3 stable | Medium | Pin to a known-good alpha tag; wrap calls behind an internal interface so migration is a single-file change. |
| Platform-specific quirks on Windows (DPI, focus, taskbar grouping) | Low | Test on Windows during Phase 2. Wails uses webview2 on Windows which is mature. |
| Window count limits or resource leaks | Low | Cap concurrent detached windows (e.g., 8). Ensure `OnClose` releases resources. |
---
## Plan B: Floating Panels (CSS-based)
**Status: FALLBACK** — no external dependency, purely frontend.
### How it works
- Detached sessions render as draggable, resizable `position: fixed` panels
within the main Wails window.
- Each panel contains its own Vue component instance (terminal emulator or
RDP canvas).
- Panels can be minimised, maximised within the viewport, or snapped to
edges.
### Pros
- Zero dependency on Wails multi-window API.
- Works on any platform without additional testing.
- Simpler state management — everything lives in one window context.
### Cons
- Sessions share the same viewport — limited screen real estate.
- Cannot span multiple monitors.
- Feels less native than real OS windows.
### Implementation cost
Small. Requires a `<FloatingPanel>` wrapper component with drag/resize
handlers. Libraries like `vue3-draggable-resizable` exist but a lightweight
custom implementation (~150 LOC) is preferable to avoid dependency churn.
---
## Plan C: Browser Mode
**Status: EMERGENCY** — last resort if both Plan A and Plan B are inadequate.
### How it works
- Wails v3 supports a server mode where the frontend is served over HTTP on
`localhost`.
- Detached sessions open in the user's default browser via
`open(url, '_blank')` or `runtime.BrowserOpenURL()`.
- The browser tab communicates with Go services through the same HTTP
endpoint.
### Pros
- Guaranteed to work — it is just a web page.
- Users can arrange tabs freely across monitors.
### Cons
- Breaks the desktop-app experience.
- Browser tabs lack access to Wails runtime bindings; all communication must
go through HTTP/WebSocket, requiring a parallel transport layer.
- Security surface increases — localhost HTTP server is accessible to other
local processes.
---
## Recommendation
**Start with Plan A.** The Wails v3 `NewWithOptions` API is documented and
consistent with how other multi-window desktop frameworks (Electron,
Tauri v2) work. The alpha stability risk is mitigated by wrapping calls
behind an internal interface.
If Plan A fails during Windows validation, **Plan B requires only frontend
CSS changes** — no backend work is wasted. Plan C is reserved for scenarios
where neither A nor B is viable.
## Next Step
Validate Plan A on Windows during Phase 2 when SSH sessions exist and there
is a real payload to render in a second window.

View File

@ -1,171 +0,0 @@
# Spike: RDP Frame Transport Mechanisms
**Status:** Research-based (not yet benchmarked)
**Date:** 2026-03-17
**Target platform:** Windows (developing on macOS)
**Wails version:** v3.0.0-alpha.74
---
## Context
When Wraith connects to a remote desktop via FreeRDP, the Go backend
receives raw bitmap frames that must be delivered to the frontend for
rendering on an HTML `<canvas>`. This spike evaluates three transport
approaches, estimating throughput for a 1920x1080 session at 30 fps.
---
## Approach 1: Local HTTP Endpoint
### How it works
1. Go spins up a local HTTP server on a random high port
(`net.Listen("tcp", "127.0.0.1:0")`).
2. Each frame is JPEG-encoded and served at a predictable URL
(e.g., `http://127.0.0.1:{port}/frame?session=3`).
3. The frontend fetches frames via `fetch()`, `<img>` tag, or
`ReadableStream` for chunked delivery.
### Throughput estimate
| Metric | Value |
|--------|-------|
| 1080p RGBA raw | ~8 MB/frame |
| 1080p JPEG (quality 80) | ~100-200 KB/frame |
| At 30 fps (JPEG) | ~3-6 MB/s |
| Loopback bandwidth | >1 GB/s |
Loopback HTTP can handle this with headroom to spare.
### Pros
- No base64 overhead — binary JPEG bytes transfer directly.
- Standard HTTP semantics; easy to debug with browser DevTools.
- Can use `Transfer-Encoding: chunked` or Server-Sent Events for
push-based delivery.
- Can serve multiple sessions on the same server with different paths.
### Cons
- Requires an extra listening port on localhost.
- Potential firewall or endpoint-security issues on locked-down Windows
enterprise machines.
- Slightly more complex setup (port allocation, CORS headers for Wails
webview origin).
---
## Approach 2: Wails Bindings (Base64)
### How it works
1. Go encodes each frame as a JPEG, then base64-encodes the result.
2. A Wails-bound method (`SessionService.GetFrame(sessionID)`) returns the
base64 string.
3. The frontend decodes the string, creates an `ImageBitmap` or sets it as a
data URI, and draws it on a `<canvas>`.
### Throughput estimate
| Metric | Value |
|--------|-------|
| 1080p JPEG (quality 80) | ~100-200 KB/frame |
| Base64 of JPEG (+33%) | ~133-270 KB/frame |
| At 30 fps | ~4-8 MB/s of string data |
| Wails IPC overhead | Negligible for this payload size |
This is feasible. Modern JavaScript engines handle base64 decoding at
several hundred MB/s.
### Pros
- No extra ports — everything flows through the existing Wails IPC channel.
- Works out of the box with Wails bindings; no additional infrastructure.
- No firewall concerns.
### Cons
- 33% base64 size overhead on every frame.
- CPU cost of `base64.StdEncoding.EncodeToString()` in Go and `atob()` in
JS on every frame (though both are fast).
- Polling-based unless combined with Wails events to signal frame
availability.
- May bottleneck at very high resolutions (4K) or high FPS (60+).
---
## Approach 3: Wails Events (Streaming)
### How it works
1. Go emits each frame as a Wails event:
`app.EmitEvent("frame:3", base64JpegString)`.
2. The frontend subscribes: `wails.Events.On("frame:3", handler)`.
3. The handler decodes and renders on canvas.
### Throughput estimate
Same as Approach 2 — the payload is identical (base64 JPEG). The difference
is delivery mechanism (push vs. pull).
### Pros
- Push-based — the frontend receives frames as soon as they are available
with no polling delay.
- Natural Wails pattern; aligns with how other real-time data (connection
status, notifications) already flows.
### Cons
- Same 33% base64 overhead as Approach 2.
- Wails event bus may not be optimised for high-frequency, large-payload
events. This is unvalidated.
- Harder to apply backpressure — if the frontend cannot keep up, events
queue without flow control.
---
## Throughput Summary
| Approach | Payload/frame | 30 fps throughput | Extra infra |
|----------|--------------|-------------------|-------------|
| 1 — Local HTTP | ~150 KB (binary JPEG) | ~4.5 MB/s | Localhost port |
| 2 — Wails bindings | ~200 KB (base64 JPEG) | ~6 MB/s | None |
| 3 — Wails events | ~200 KB (base64 JPEG) | ~6 MB/s | None |
All three approaches are within comfortable limits for 1080p at 30 fps.
The differentiator is operational simplicity, not raw throughput.
---
## Recommendation
**Start with Approach 2 (base64 JPEG via Wails bindings).**
Rationale:
1. JPEG compression brings 1080p frames down to ~200 KB, making the 33%
base64 overhead manageable (~6 MB/s at 30 fps).
2. No extra ports or firewall concerns — important for enterprise Windows
environments where Wraith will be deployed.
3. Simple implementation: one Go method, one frontend call per frame.
4. If polling latency is a problem, upgrade to Approach 3 (events) with
minimal code change — the payload encoding is identical.
**If benchmarking reveals issues** (dropped frames, high CPU from
encoding), fall back to Approach 1 (local HTTP) which eliminates base64
overhead entirely. The migration path is straightforward: replace the
`fetch(dataUri)` call with `fetch(httpUrl)`.
---
## Next Step
Benchmark during Phase 3 when FreeRDP integration is in progress and real
frame data is available. Key metrics to capture:
- End-to-end frame latency (Go encode to canvas paint)
- CPU utilisation on both Go and browser sides
- Frame drop rate at 30 fps and 60 fps
- Memory pressure from base64 string allocation/GC

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,487 +0,0 @@
# Wraith Desktop — Phase 2: SSH + SFTP Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Connect to remote hosts via SSH with a real terminal (xterm.js), SFTP sidebar with file operations and CWD following, multi-tab sessions, and CodeMirror 6 file editor.
**Architecture:** Go SSH service wraps `x/crypto/ssh` for connections, PTY requests, and shell I/O. SFTP service wraps `pkg/sftp` riding the same SSH connection. Data flows: xterm.js ↔ Wails bindings ↔ Go SSH pipes. CWD tracking via OSC 7 shell injection.
**Tech Stack:** `golang.org/x/crypto/ssh`, `github.com/pkg/sftp`, xterm.js 5.x + WebGL addon + fit addon + search addon, CodeMirror 6
**Spec:** `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` (Sections 6, 10, 11)
---
## File Structure (New/Modified)
```
internal/
ssh/
service.go # SSH dial, PTY, shell, I/O goroutines
service_test.go # Connection config tests (unit, no real SSH)
hostkey.go # Host key verification + storage
hostkey_test.go
cwd.go # OSC 7 parser for CWD tracking
cwd_test.go
sftp/
service.go # SFTP operations (list, upload, download, etc.)
service_test.go
credentials/
service.go # Credential CRUD (encrypted passwords + SSH keys)
service_test.go
app/
app.go # Add SSH/SFTP/Credential services
frontend/
src/
components/
terminal/
TerminalView.vue # xterm.js instance wrapper
sftp/
FileTree.vue # Remote filesystem tree
TransferProgress.vue # Upload/download progress
editor/
EditorWindow.vue # CodeMirror 6 (placeholder for multi-window)
composables/
useTerminal.ts # xterm.js lifecycle + Wails binding bridge
useSftp.ts # SFTP operations via Wails bindings
stores/
session.store.ts # Update with real session management
assets/
css/
terminal.css # xterm.js overrides
package.json # Add xterm.js, codemirror deps
```
---
## Task 1: SSH Service — Connect, PTY, Shell I/O
**Files:**
- Create: `internal/ssh/service.go`
- Create: `internal/ssh/service_test.go`
The SSH service manages connections and exposes methods to Wails:
```go
// SSHService methods (exposed to frontend via Wails bindings):
// Connect(connectionID int64) (string, error) → returns sessionID
// Write(sessionID string, data string) error → write to stdin
// Resize(sessionID string, cols, rows int) error → window change
// Disconnect(sessionID string) error → close session
//
// Events emitted to frontend via Wails events:
// "ssh:data:{sessionID}" → terminal output (stdout)
// "ssh:connected:{sessionID}" → connection established
// "ssh:disconnected:{sessionID}" → connection closed
// "ssh:error:{sessionID}" → error message
```
Key implementation details:
- Each SSH session runs two goroutines: one reading stdout→Wails events, one for keepalive
- Sessions stored in a `map[string]*SSHSession` with mutex
- `SSHSession` holds: `*ssh.Client`, `*ssh.Session`, stdin `io.WriteCloser`, connection metadata
- PTY requested as `xterm-256color` with initial size from frontend
- Auth method determined by credential type (password, SSH key, keyboard-interactive)
- Host key verification delegates to `hostkey.go`
Tests (unit only — no real SSH server):
- TestSSHServiceCreation
- TestBuildAuthMethods (password → ssh.Password, key → ssh.PublicKeys)
- TestSessionTracking (create, get, remove)
- [ ] **Step 1:** Write tests
- [ ] **Step 2:** Implement service
- [ ] **Step 3:** Run tests, verify pass
- [ ] **Step 4:** Commit: `feat: SSH service — connect, PTY, shell I/O with goroutine pipes`
---
## Task 2: Host Key Verification
**Files:**
- Create: `internal/ssh/hostkey.go`
- Create: `internal/ssh/hostkey_test.go`
Host key verification stores/checks fingerprints in the `host_keys` SQLite table:
- New host → emit `ssh:hostkey-verify` event to frontend with fingerprint, wait for accept/reject
- Known host, matching fingerprint → proceed silently
- Known host, CHANGED fingerprint → emit warning event, block connection
For Phase 2, implement the storage and verification logic. The frontend prompt (accept/reject dialog) will be wired in the frontend task.
Tests:
- TestStoreHostKey
- TestVerifyKnownHost (match → ok)
- TestVerifyChangedHost (mismatch → error)
- TestVerifyNewHost (not found → returns "new")
- [ ] **Step 1:** Write tests
- [ ] **Step 2:** Implement hostkey.go
- [ ] **Step 3:** Run tests, verify pass
- [ ] **Step 4:** Commit: `feat: SSH host key verification — store, verify, detect changes`
---
## Task 3: CWD Tracker (OSC 7 Parser)
**Files:**
- Create: `internal/ssh/cwd.go`
- Create: `internal/ssh/cwd_test.go`
Parses OSC 7 escape sequences from terminal output to track the remote working directory:
```
Input: "some output\033]7;file://hostname/home/user\033\\more output"
Output: stripped="some output more output", cwd="/home/user"
```
The CWD tracker:
1. Scans byte stream for `\033]7;` prefix
2. Extracts URL between prefix and `\033\\` (or `\007`) terminator
3. Parses `file://hostname/path` to extract just the path
4. Strips the OSC 7 sequence from the output before forwarding to xterm.js
5. Returns the new CWD path when detected
Shell injection command (injected after PTY is established):
```bash
# bash
PROMPT_COMMAND='printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD"'
# zsh
precmd() { printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD" }
# fish
function fish_prompt; printf "\033]7;file://%s%s\033\\" (hostname) "$PWD"; end
```
Tests:
- TestParseOSC7Basic
- TestParseOSC7WithBEL (terminated by \007 instead of ST)
- TestParseOSC7NoMatch (no OSC 7 in output)
- TestParseOSC7MultipleInStream
- TestStripOSC7FromOutput
- [ ] **Step 1:** Write tests
- [ ] **Step 2:** Implement cwd.go
- [ ] **Step 3:** Run tests, verify pass
- [ ] **Step 4:** Commit: `feat: OSC 7 CWD tracker — parse and strip directory change sequences`
---
## Task 4: SFTP Service
**Files:**
- Create: `internal/sftp/service.go`
- Create: `internal/sftp/service_test.go`
SFTP service wraps `pkg/sftp` and exposes file operations to the frontend:
```go
// SFTPService methods (exposed via Wails bindings):
// OpenSFTP(sessionID string) error → start SFTP on existing SSH connection
// List(sessionID string, path string) ([]FileEntry, error) → directory listing
// ReadFile(sessionID string, path string) (string, error) → read file content (max 5MB)
// WriteFile(sessionID string, path string, content string) error → write file
// Upload(sessionID string, remotePath string, localPath string) error
// Download(sessionID string, remotePath string) (string, error) → returns local temp path
// Mkdir(sessionID string, path string) error
// Delete(sessionID string, path string) error
// Rename(sessionID string, oldPath, newPath string) error
// Stat(sessionID string, path string) (*FileEntry, error)
```
`FileEntry` type:
```go
type FileEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
Permissions string `json:"permissions"`
ModTime string `json:"modTime"`
Owner string `json:"owner"`
}
```
SFTP client is created from the existing `*ssh.Client` (same connection, separate channel). Stored alongside the SSH session.
Tests (unit — mock the sftp.Client interface):
- TestFileEntryFromFileInfo
- TestListSortsDirectoriesFirst
- TestReadFileRejectsLargeFiles (>5MB)
- [ ] **Step 1:** Write tests
- [ ] **Step 2:** Implement service
- [ ] **Step 3:** Add `pkg/sftp` dependency
- [ ] **Step 4:** Run tests, verify pass
- [ ] **Step 5:** Commit: `feat: SFTP service — list, read, write, upload, download, mkdir, delete`
---
## Task 5: Credential Service (Encrypted SSH Keys + Passwords)
**Files:**
- Create: `internal/credentials/service.go`
- Create: `internal/credentials/service_test.go`
CRUD for credentials and SSH keys with vault encryption:
```go
// CredentialService methods:
// CreatePassword(name, username, password, domain string) (*Credential, error)
// CreateSSHKey(name string, privateKey, passphrase []byte) (*SSHKey, error)
// GetCredential(id int64) (*Credential, error)
// ListCredentials() ([]Credential, error)
// DecryptPassword(id int64) (string, error) → decrypt for connection use only
// DecryptSSHKey(id int64) ([]byte, string, error) → returns (privateKey, passphrase, error)
// DeleteCredential(id int64) error
// ImportSSHKeyFile(name, filePath string) (*SSHKey, error) → read .pem file, detect type, store
```
All sensitive data encrypted via VaultService before storage. Decryption only happens at connection time.
Tests:
- TestCreatePasswordCredential
- TestCreateSSHKeyCredential
- TestDecryptPassword (round-trip through vault)
- TestDecryptSSHKey (round-trip)
- TestListCredentialsExcludesEncryptedValues
- TestDetectKeyType (RSA, Ed25519, ECDSA)
- [ ] **Step 1:** Write tests
- [ ] **Step 2:** Implement service
- [ ] **Step 3:** Run tests, verify pass
- [ ] **Step 4:** Commit: `feat: credential service — encrypted password and SSH key storage`
---
## Task 6: Wire SSH/SFTP/Credentials into App
**Files:**
- Modify: `internal/app/app.go`
- Modify: `main.go`
Add SSHService, SFTPService, and CredentialService to WraithApp. Register as Wails services.
- [ ] **Step 1:** Update app.go to create and expose new services
- [ ] **Step 2:** Update main.go to register them
- [ ] **Step 3:** Verify compilation: `go vet ./...`
- [ ] **Step 4:** Run all tests: `go test ./... -count=1`
- [ ] **Step 5:** Commit: `feat: wire SSH, SFTP, and credential services into Wails app`
---
## Task 7: Frontend — xterm.js Terminal
**Files:**
- Modify: `frontend/package.json` — add xterm.js + addons
- Create: `frontend/src/components/terminal/TerminalView.vue`
- Create: `frontend/src/composables/useTerminal.ts`
- Create: `frontend/src/assets/css/terminal.css`
- Modify: `frontend/src/components/session/SessionContainer.vue`
- Modify: `frontend/src/stores/session.store.ts`
Install xterm.js dependencies:
```
@xterm/xterm
@xterm/addon-fit
@xterm/addon-webgl
@xterm/addon-search
@xterm/addon-web-links
```
`useTerminal` composable:
- Creates xterm.js Terminal instance with theme from connection settings
- Attaches fit, WebGL, search, web-links addons
- Binds `terminal.onData` → Wails `SSHService.Write(sessionId, data)`
- Listens for Wails events `ssh:data:{sessionId}``terminal.write(data)`
- Handles resize via fit addon → Wails `SSHService.Resize(sessionId, cols, rows)`
- Cleanup on unmount
`TerminalView.vue`:
- Receives `sessionId` prop
- Mounts xterm.js into a div ref
- Applies theme colors from the active theme
- Handles focus management
`SessionContainer.vue` update:
- Replace placeholder with real TerminalView for SSH sessions
- Use `v-show` (not `v-if`) to keep terminals alive across tab switches
- [ ] **Step 1:** Install xterm.js deps: `cd frontend && npm install @xterm/xterm @xterm/addon-fit @xterm/addon-webgl @xterm/addon-search @xterm/addon-web-links`
- [ ] **Step 2:** Create terminal.css (xterm.js container styling)
- [ ] **Step 3:** Create useTerminal.ts composable
- [ ] **Step 4:** Create TerminalView.vue component
- [ ] **Step 5:** Update SessionContainer.vue to render TerminalView
- [ ] **Step 6:** Update session.store.ts with real Wails binding calls
- [ ] **Step 7:** Build frontend: `npm run build`
- [ ] **Step 8:** Commit: `feat: xterm.js terminal with WebGL rendering and Wails binding bridge`
---
## Task 8: Frontend — SFTP Sidebar
**Files:**
- Create: `frontend/src/components/sftp/FileTree.vue`
- Create: `frontend/src/components/sftp/TransferProgress.vue`
- Create: `frontend/src/composables/useSftp.ts`
- Modify: `frontend/src/layouts/MainLayout.vue` — SFTP sidebar rendering
- Modify: `frontend/src/components/sidebar/SidebarToggle.vue` — enable SFTP tab
`useSftp` composable:
- `listDirectory(sessionId, path)` → calls Wails SFTPService.List
- `uploadFile(sessionId, remotePath, file)` → chunked upload with progress
- `downloadFile(sessionId, remotePath)` → triggers browser download
- `deleteFile(sessionId, path)` → with confirmation
- `createDirectory(sessionId, path)`
- `renameFile(sessionId, old, new)`
- Tracks current path, file list, loading state, transfer progress
`FileTree.vue`:
- Renders file/directory tree (lazy-loaded on expand)
- Path bar at top showing current directory
- Toolbar: upload, download, new file, new folder, refresh, delete
- File entries show: icon (folder/file), name, size, modified date
- Double-click file → open in editor (Task 9)
- Drag-and-drop upload zone
- "Follow terminal folder" toggle at bottom
`TransferProgress.vue`:
- Shows active uploads/downloads with progress bars
- File name, percentage, speed, ETA
- [ ] **Step 1:** Create useSftp.ts composable
- [ ] **Step 2:** Create FileTree.vue component
- [ ] **Step 3:** Create TransferProgress.vue component
- [ ] **Step 4:** Update MainLayout.vue to render SFTP sidebar when toggled
- [ ] **Step 5:** Enable SFTP toggle in SidebarToggle.vue
- [ ] **Step 6:** Build frontend: `npm run build`
- [ ] **Step 7:** Commit: `feat: SFTP sidebar — file tree, upload/download, CWD following`
---
## Task 9: Frontend — Host Key Dialog + Connection Flow
**Files:**
- Create: `frontend/src/components/common/HostKeyDialog.vue`
- Modify: `frontend/src/components/sidebar/ConnectionTree.vue` — double-click to connect
- Modify: `frontend/src/stores/session.store.ts` — real connection flow
Wire up the full connection flow:
1. User double-clicks connection in sidebar
2. Session store calls Wails `SSHService.Connect(connectionId)`
3. If host key verification needed → show HostKeyDialog
4. On success → create tab, mount TerminalView, open SFTP sidebar
5. On error → show error toast
`HostKeyDialog.vue`:
- Modal showing: hostname, key type, fingerprint
- "New host" vs "CHANGED host key (WARNING)" modes
- Accept / Reject buttons
- "Always accept for this host" checkbox
- [ ] **Step 1:** Create HostKeyDialog.vue
- [ ] **Step 2:** Update ConnectionTree.vue with double-click handler
- [ ] **Step 3:** Update session.store.ts with connection flow
- [ ] **Step 4:** Build frontend: `npm run build`
- [ ] **Step 5:** Commit: `feat: connection flow — host key dialog, double-click to connect`
---
## Task 10: Frontend — CodeMirror 6 Editor (Placeholder)
**Files:**
- Modify: `frontend/package.json` — add CodeMirror deps
- Create: `frontend/src/components/editor/EditorWindow.vue`
Install CodeMirror 6:
```
codemirror
@codemirror/lang-javascript
@codemirror/lang-json
@codemirror/lang-html
@codemirror/lang-css
@codemirror/lang-python
@codemirror/lang-markdown
@codemirror/theme-one-dark
```
`EditorWindow.vue`:
- Renders CodeMirror 6 editor with dark theme
- Receives file content, path, and sessionId as props
- Syntax highlighting based on file extension
- Save button → calls Wails SFTPService.WriteFile
- Unsaved changes detection
- For Phase 2: renders inline (not separate window — multi-window is Phase 4)
- [ ] **Step 1:** Install CodeMirror deps
- [ ] **Step 2:** Create EditorWindow.vue
- [ ] **Step 3:** Wire file click in FileTree.vue to open EditorWindow
- [ ] **Step 4:** Build frontend: `npm run build`
- [ ] **Step 5:** Commit: `feat: CodeMirror 6 editor — syntax highlighting, dark theme, SFTP save`
---
## Task 11: Workspace Snapshot Persistence
**Files:**
- Create: `internal/app/workspace.go`
- Create: `internal/app/workspace_test.go`
Implements workspace snapshot saving/restoring per the spec:
```go
// SaveWorkspace() error — serialize current tab layout to settings
// LoadWorkspace() (*WorkspaceSnapshot, error) — read last saved layout
// Auto-save every 30 seconds via goroutine
// Save on clean shutdown
```
WorkspaceSnapshot JSON:
```json
{
"tabs": [
{"connectionId": 1, "protocol": "ssh", "position": 0},
{"connectionId": 5, "protocol": "rdp", "position": 1}
],
"sidebarWidth": 240,
"sidebarMode": "connections",
"activeTab": 0
}
```
Tests:
- TestSaveAndLoadWorkspace
- TestEmptyWorkspace
- [ ] **Step 1:** Write tests
- [ ] **Step 2:** Implement workspace.go
- [ ] **Step 3:** Run tests, verify pass
- [ ] **Step 4:** Commit: `feat: workspace snapshot persistence — auto-save layout every 30s`
---
## Task 12: Integration Test + Final Verification
- [ ] **Step 1:** Run all Go tests: `go test ./... -count=1`
- [ ] **Step 2:** Build frontend: `cd frontend && npm run build`
- [ ] **Step 3:** Verify Go compiles with embedded frontend: `go vet ./...`
- [ ] **Step 4:** Count tests and lines of code
- [ ] **Step 5:** Commit any fixes: `chore: Phase 2 complete — SSH + SFTP with terminal and file operations`
---
## Phase 2 Completion Checklist
- [ ] SSH service: connect, PTY, shell I/O with goroutine pipes
- [ ] Host key verification: store, verify, detect changes
- [ ] OSC 7 CWD tracker: parse and strip directory change sequences
- [ ] SFTP service: list, read, write, upload, download, mkdir, delete
- [ ] Credential service: encrypted password + SSH key storage
- [ ] All new services wired into Wails app
- [ ] xterm.js terminal with WebGL rendering
- [ ] SFTP file tree sidebar with upload/download
- [ ] Host key verification dialog
- [ ] Double-click connection to connect flow
- [ ] CodeMirror 6 inline editor with SFTP save
- [ ] Workspace snapshot persistence
- [ ] All Go tests passing
- [ ] Frontend builds clean

View File

@ -0,0 +1,780 @@
# 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).

View File

@ -1,614 +0,0 @@
# Wraith — Lean Build Spec
> **Date:** 2026-03-12
> **Purpose:** Self-hosted MobaXterm replacement — SSH + SFTP + RDP in a browser
> **Stack:** Nuxt 3 (Vue 3 SPA) + NestJS 10 + PostgreSQL 16 + guacd
> **Target:** Single-user personal tool with bolt-on multi-user path
> **Reference:** `Remote-Spec.md` (full feature spec — this is the lean cut)
---
## 1. What This Is
A self-hosted web application that replaces MobaXterm. SSH terminal with SFTP sidebar (MobaXterm's killer feature), RDP via Guacamole, connection manager with hierarchical groups, and an encrypted vault for SSH keys and passwords. Runs in any browser, deployed as a Docker stack.
**What this is NOT:** An MSP product, a SaaS platform, a team collaboration tool. It's a personal remote access workstation that happens to be web-based. Multi-user is a future bolt-on, not a design constraint.
**Name:** Wraith — exists everywhere, all at once.
---
## 2. Five Modules
### 2.1 SSH Terminal
**Frontend:** xterm.js 5.x with addons:
- `@xterm/addon-fit` — auto-resize to container
- `@xterm/addon-search` — Ctrl+F scrollback search
- `@xterm/addon-web-links` — clickable URLs
- `@xterm/addon-webgl` — GPU-accelerated rendering
**Backend:** NestJS WebSocket gateway + `ssh2` (npm). Browser opens WebSocket to NestJS, NestJS opens SSH connection to target using credentials from the vault. Bidirectional data pipe: terminal input → ssh2 stdin, ssh2 stdout → terminal output.
**Features:**
- Multi-tab sessions with host name labels and color-coding by group
- Horizontal and vertical split panes within a single tab (multiple xterm.js instances in a flex grid)
- Terminal theming: dark/light modes, custom color schemes, font selection, font size
- Configurable scrollback buffer size (default 10,000 lines, configurable in settings)
- Copy/paste: Ctrl+Shift+C/V, right-click context menu
- Search in scrollback: Ctrl+F via xterm.js SearchAddon
- Auto-reconnect on connection drop with configurable retry
**Authentication flow:**
1. User clicks host in connection manager
2. Backend looks up host → finds associated credential (key or password)
3. If SSH key: decrypt private key from vault, optionally decrypt passphrase, pass to ssh2
4. If password: decrypt from vault, pass to ssh2
5. ssh2 performs host key verification (see Section 8: Host Key Verification)
6. ssh2 connects, WebSocket bridge established
### 2.2 SFTP Sidebar
The MobaXterm feature. When an SSH session connects, a sidebar automatically opens showing the remote filesystem.
**Layout:** Resizable left sidebar panel (tree view) + main terminal panel. Sidebar can be collapsed/hidden per session.
**Backend:** Uses the same ssh2 connection as the terminal (ssh2's SFTP subsystem). No separate connection needed — SFTP rides the existing SSH channel. All SFTP commands include a `sessionId` to target the correct ssh2 connection when multiple tabs are open.
**File operations:**
- Browse remote filesystem as a tree (lazy-loaded — fetch children on expand)
- Upload: drag-and-drop from desktop onto sidebar, or click upload button. Chunked transfer with progress bar.
- Download: click file → browser download, or right-click → Download
- Rename, delete, chmod, mkdir via right-click context menu
- File size, permissions, modified date shown in tree or detail view
**File editing:**
- Click a text file → opens in embedded Monaco Editor (VS Code's editor component)
- File size guard: files over 5MB are refused for inline editing (download instead)
- Syntax highlighting based on file extension
- Save button pushes content back to remote via SFTP
- Unsaved changes warning on close
**Transfer status:** Bottom status bar showing active transfers with progress, speed, ETA. Queue-based — multiple uploads/downloads run sequentially with status indicators.
### 2.3 RDP (Remote Desktop)
**Architecture:** Browser → WebSocket → NestJS Guacamole tunnel → guacd (Docker) → RDP target
**Frontend:** `guacamole-common-js` — renders remote desktop on HTML5 Canvas. Keyboard, mouse, and touch input forwarded to remote.
**Backend:** NestJS WebSocket gateway that speaks Guacamole wire protocol to the `guacd` daemon over TCP. The gateway translates between the browser's WebSocket and guacd's TCP socket.
**guacd:** Apache Guacamole daemon running as `guacamole/guacd` Docker image. Handles the actual RDP protocol translation. Battle-tested, Apache-licensed.
**Features:**
- Clipboard sync: bidirectional between browser and remote desktop
- Auto-resolution: detect browser window/tab size, send to RDP server
- Connection settings: color depth (16/24/32-bit), security mode (NLA/TLS/RDP), console session, admin mode
- Audio: remote audio playback in browser (Guacamole native)
- Full-screen mode: F11 or toolbar button
**Authentication:** RDP credentials (username + password + domain) stored encrypted in vault, associated with host. Decrypted at connect time and passed to guacd.
### 2.4 Connection Manager
The home screen. A searchable, organized view of all saved hosts.
**Host properties:**
```
name — display name (e.g., "RSM File Server")
hostname — IP or FQDN
port — default 22 (SSH) or 3389 (RDP)
protocol — ssh | rdp
group_id — FK to host_groups (nullable for ungrouped)
credential_id — FK to credentials (nullable for quick-connect-style)
tags — text[] array for categorization
notes — free text (markdown rendered)
color — hex color for visual grouping
lastConnectedAt — timestamp of most recent connection
```
**Host groups:** Hierarchical folders with `parent_id` self-reference. E.g., "RSM > Servers", "Home Lab > VMs". Collapsible tree in the sidebar.
**Quick connect:** Top bar input — type `user@hostname:port` and hit Enter to connect without saving. Protocol auto-detected (or toggle SSH/RDP).
**Search:** Full-text across host name, hostname, tags, notes, group name. Instant filter as you type.
**Recent connections:** Hosts sorted by `lastConnectedAt` shown as a quick-access section above the full host tree.
**UI pattern:** Left sidebar = group tree + host list. Main area = active sessions rendered as persistent tab components within the layout (NOT separate routes — terminal/RDP instances persist across tab switches). Double-click host or press Enter to connect. Drag hosts between groups.
### 2.5 Key Vault
Encrypted storage for SSH private keys and passwords.
**SSH keys:**
```
name — display name (e.g., "RSM Production Key")
public_key — plaintext (safe to store)
encrypted_private_key — AES-256-GCM encrypted blob
passphrase_encrypted — AES-256-GCM encrypted (nullable — not all keys have passphrases)
fingerprint — SHA-256 fingerprint for display
key_type — rsa | ed25519 | ecdsa (detected on import)
```
**Import flow:**
1. Click "Import Key" in vault management
2. Paste key content or upload `.pem`/`.pub`/id_rsa file
3. If key has passphrase, prompt for it (stored encrypted)
4. Key encrypted with AES-256-GCM using `ENCRYPTION_KEY` env var
5. Public key extracted and stored separately (for display/export)
**Credentials (passwords and key references):**
```
name — display name (e.g., "RSM root cred")
username — plaintext username (not sensitive)
domain — for RDP (e.g., "CONTOSO")
type — password | ssh_key (enum CredentialType)
encrypted_value — AES-256-GCM encrypted password (for type=password)
ssh_key_id — FK to ssh_keys (for type=ssh_key)
```
Credentials are shared entities — hosts reference credentials via `credential_id` FK on the host. Multiple hosts can share the same credential. The relationship is Host → Credential (many-to-one), not Credential → Host.
**Encryption pattern:** Same as Vigilance HQ — `ENCRYPTION_KEY` env var (32+ byte hex), AES-256-GCM, random IV per encryption, `v1:` version prefix on ciphertext for future key rotation.
---
## 3. Technology Stack
### Frontend
| Component | Technology | Purpose |
|---|---|---|
| Framework | Nuxt 3 (Vue 3, SPA mode `ssr: false`) | App shell, routing, auto-imports |
| Terminal | xterm.js 5.x + addons | SSH terminal emulator |
| RDP client | guacamole-common-js | RDP canvas rendering |
| Code editor | Monaco Editor | SFTP file editing |
| UI library | PrimeVue 4 | DataTable, Dialog, Tree, Toolbar, etc. |
| State | Pinia | Connection state, session management |
| CSS | Tailwind CSS | Utility-first styling |
| Icons | Lucide Vue | Consistent iconography |
> **Why SPA, not SSR:** xterm.js, Monaco, and guacamole-common-js are all browser-only. Every session page would need `<ClientOnly>` wrappers. No SEO benefit for a self-hosted tool behind auth. SPA mode avoids hydration mismatches entirely while keeping Nuxt's routing, auto-imports, and module ecosystem.
### Backend
| Component | Technology | Purpose |
|---|---|---|
| Framework | NestJS 10 | REST API + WebSocket gateways |
| SSH proxy | ssh2 (npm) | SSH + SFTP connections |
| RDP proxy | Custom Guacamole tunnel | NestJS ↔ guacd TCP bridge |
| Database | PostgreSQL 16 | Hosts, credentials, keys, settings |
| ORM | Prisma | Schema-as-code, type-safe queries |
| Encryption | Node.js crypto (AES-256-GCM) | Vault encryption at rest |
| Auth | JWT + bcrypt | Single-user local login |
| WebSocket | @nestjs/websockets (ws) | Terminal and RDP data channels |
### Infrastructure (Docker Compose)
```yaml
services:
app:
build: .
ports: ["3000:3000"]
environment:
DATABASE_URL: postgresql://wraith:${DB_PASSWORD}@postgres:5432/wraith
JWT_SECRET: ${JWT_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
GUACD_HOST: guacd
GUACD_PORT: "4822"
depends_on: [postgres, guacd]
guacd:
image: guacamole/guacd
restart: always
# Internal only — app connects via Docker DNS hostname "guacd" on port 4822
postgres:
image: postgres:16-alpine
volumes: [pgdata:/var/lib/postgresql/data]
environment:
POSTGRES_DB: wraith
POSTGRES_USER: wraith
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
pgdata:
```
> **No Redis:** JWT auth is stateless. Single NestJS process means no pub/sub fanout needed. If horizontal scaling becomes relevant later, Redis is a straightforward add. Not burning ops complexity on it now.
**Required `.env` vars:**
```
DB_PASSWORD=<strong-random-password>
JWT_SECRET=<random-256-bit-hex>
ENCRYPTION_KEY=<random-256-bit-hex>
```
Production deployment: Nginx reverse proxy on the Docker host with SSL termination and WebSocket upgrade support (`proxy_set_header Upgrade $http_upgrade`).
---
## 4. Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Browser (Any device) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ xterm.js │ │ SFTP Sidebar │ │ guac-client │ │
│ │ (SSH term) │ │ (file tree) │ │ (RDP canvas) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ WebSocket │ WebSocket │ WebSocket │
└─────────┼──────────────────┼─────────────────┼──────────────┘
│ │ │
┌─────────┼──────────────────┼─────────────────┼──────────────┐
│ NestJS Backend (Docker: app) │
│ ┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐ │
│ │ SSH Gateway │ │ SFTP Gateway │ │ Guac Tunnel │ │
│ │ (ssh2) │ │ (ssh2 sftp) │ │ (TCP→guacd) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ SSH │ SFTP │ Guac Protocol │
│ ┌──────▼────────────────────────┐ ┌──────▼───────┐ │
│ │ Vault Service │ │ guacd │ │
│ │ (decrypt keys/passwords) │ │ (Docker) │ │
│ └──────┬────────────────────────┘ └──────┬───────┘ │
│ │ Prisma │ RDP │
│ ┌──────▼───────┐ │ │
│ │ PostgreSQL │ │ │
│ │ (Docker) │ │ │
│ └──────────────┘ │ │
└──────────────────────────────────────────────┼──────────────┘
┌─────────────────┐ ┌──────▼───────┐
│ SSH Targets │ │ RDP Targets │
│ (Linux/Unix) │ │ (Windows) │
└─────────────────┘ └──────────────┘
```
---
## 5. Database Schema (Prisma)
```prisma
model User {
id Int @id @default(autoincrement())
email String @unique
passwordHash String @map("password_hash")
displayName String? @map("display_name")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
model HostGroup {
id Int @id @default(autoincrement())
name String
parentId Int? @map("parent_id")
sortOrder Int @default(0) @map("sort_order")
parent HostGroup? @relation("GroupTree", fields: [parentId], references: [id], onDelete: SetNull)
children HostGroup[] @relation("GroupTree")
hosts Host[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("host_groups")
}
model Host {
id Int @id @default(autoincrement())
name String
hostname String
port Int @default(22)
protocol Protocol @default(ssh)
groupId Int? @map("group_id")
credentialId Int? @map("credential_id")
tags String[] @default([])
notes String?
color String? @db.VarChar(7)
sortOrder Int @default(0) @map("sort_order")
hostFingerprint String? @map("host_fingerprint")
lastConnectedAt DateTime? @map("last_connected_at")
group HostGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull)
connectionLogs ConnectionLog[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("hosts")
}
model Credential {
id Int @id @default(autoincrement())
name String
username String?
domain String?
type CredentialType
encryptedValue String? @map("encrypted_value")
sshKeyId Int? @map("ssh_key_id")
sshKey SshKey? @relation(fields: [sshKeyId], references: [id], onDelete: SetNull)
hosts Host[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("credentials")
}
model SshKey {
id Int @id @default(autoincrement())
name String
keyType String @map("key_type") @db.VarChar(20)
fingerprint String?
publicKey String? @map("public_key")
encryptedPrivateKey String @map("encrypted_private_key")
passphraseEncrypted String? @map("passphrase_encrypted")
credentials Credential[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("ssh_keys")
}
model ConnectionLog {
id Int @id @default(autoincrement())
hostId Int @map("host_id")
protocol Protocol
connectedAt DateTime @default(now()) @map("connected_at")
disconnectedAt DateTime? @map("disconnected_at")
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
@@map("connection_logs")
}
model Setting {
key String @id
value String
@@map("settings")
}
enum Protocol {
ssh
rdp
}
enum CredentialType {
password
ssh_key
}
```
---
## 6. Frontend Structure
```
frontend/
nuxt.config.ts # ssr: false (SPA mode)
layouts/
default.vue # Main layout: sidebar + persistent tab container
auth.vue # Login page layout
pages/
index.vue # Connection manager (home screen) + active session tabs
login.vue # Single-user login
vault/
index.vue # Key vault management
keys.vue # SSH key list + import
credentials.vue # Password credentials
settings.vue # App settings (theme, terminal defaults, scrollback)
components/
connections/
HostTree.vue # Sidebar host group tree
HostCard.vue # Host entry in list
HostEditDialog.vue # Add/edit host modal
GroupEditDialog.vue # Add/edit group modal
QuickConnect.vue # Top bar quick connect input
session/
SessionContainer.vue # Persistent container — holds all active sessions, manages tab switching
SessionTab.vue # Single session (SSH terminal + SFTP sidebar, or RDP canvas)
terminal/
TerminalInstance.vue # Single xterm.js instance
TerminalTabs.vue # Tab bar for multiple sessions
SplitPane.vue # Split pane container
sftp/
SftpSidebar.vue # SFTP file tree sidebar
FileTree.vue # Remote filesystem tree
FileEditor.vue # Monaco editor for text files
TransferStatus.vue # Upload/download progress
rdp/
RdpCanvas.vue # Guacamole client wrapper
RdpToolbar.vue # Clipboard, fullscreen, settings
vault/
KeyImportDialog.vue # SSH key import modal
CredentialForm.vue # Password credential form
composables/
useTerminal.ts # xterm.js lifecycle + WebSocket
useSftp.ts # SFTP operations via WebSocket
useRdp.ts # Guacamole client lifecycle
useVault.ts # Key/credential CRUD
useConnections.ts # Host CRUD + search
stores/
auth.store.ts # Login state, JWT (stored in memory/localStorage, sent via Authorization header)
session.store.ts # Active sessions, tabs — sessions persist across tab switches
connection.store.ts # Hosts, groups, search
```
> **Session architecture:** Active sessions are NOT page routes. They render as persistent tab components inside `SessionContainer.vue` within the main `index.vue` layout. Switching tabs toggles `v-show` visibility (not `v-if` destruction), so xterm.js and guacamole-common-js instances stay alive. The vault and settings pages are separate routes — navigating away from the main page does NOT destroy active sessions (the SessionContainer lives in the `default.vue` layout).
---
## 7. Backend Structure
```
backend/src/
main.ts # Bootstrap, global prefix, validation pipe
app.module.ts # Root module
prisma/
prisma.service.ts # Prisma client lifecycle
prisma.module.ts # Global Prisma module
auth/
auth.module.ts
auth.service.ts # Login, JWT issue/verify
auth.controller.ts # POST /login, GET /profile
jwt.strategy.ts # Passport JWT strategy
jwt-auth.guard.ts # Route guard (REST)
ws-auth.guard.ts # WebSocket auth guard (validates JWT from handshake)
connections/
connections.module.ts
hosts.service.ts # Host CRUD + lastConnectedAt updates
hosts.controller.ts # REST: /hosts
groups.service.ts # Group CRUD (hierarchical)
groups.controller.ts # REST: /groups
vault/
vault.module.ts
encryption.service.ts # AES-256-GCM encrypt/decrypt
credentials.service.ts # Credential CRUD + decrypt-on-demand
credentials.controller.ts # REST: /credentials
ssh-keys.service.ts # SSH key import/CRUD
ssh-keys.controller.ts # REST: /ssh-keys
terminal/
terminal.module.ts
terminal.gateway.ts # WebSocket gateway: SSH proxy via ssh2
sftp.gateway.ts # WebSocket gateway: SFTP operations
ssh-connection.service.ts # ssh2 connection management + pooling
rdp/
rdp.module.ts
rdp.gateway.ts # WebSocket gateway: Guacamole tunnel
guacamole.service.ts # TCP connection to guacd, protocol translation
settings/
settings.module.ts
settings.service.ts # Key/value settings CRUD
settings.controller.ts # REST: /settings
```
---
## 8. Key Implementation Details
### WebSocket Authentication
All WebSocket gateways validate JWT before processing any commands. The token is sent in the WebSocket handshake:
```typescript
// Client: connect with JWT
const ws = new WebSocket(`wss://host/terminal?token=${jwt}`)
// Server: ws-auth.guard.ts validates in handleConnection
// Rejects connection if token is invalid/expired
```
JWT is stored in Pinia state (memory) and localStorage for persistence. Sent via `Authorization: Bearer` header for REST, query parameter for WebSocket handshake. No cookies used for auth — CSRF protection not required.
### WebSocket Protocol (SSH)
```
Client → Server:
{ type: 'connect', hostId: 123 } # Initiate SSH connection
{ type: 'data', data: '...' } # Terminal input (keystrokes)
{ type: 'resize', cols: 120, rows: 40 } # Terminal resize
Server → Client:
{ type: 'connected', sessionId: 'uuid' } # SSH connection established
{ type: 'data', data: '...' } # Terminal output
{ type: 'host-key-verify', fingerprint: 'SHA256:...', isNew: true } # First connection — needs approval
{ type: 'error', message: '...' } # Connection error
{ type: 'disconnected', reason: '...' } # Connection closed
Client → Server (host key response):
{ type: 'host-key-accept' } # User approved — save fingerprint to host record
{ type: 'host-key-reject' } # User rejected — abort connection
```
### WebSocket Protocol (SFTP)
All SFTP commands include `sessionId` to target the correct ssh2 connection:
```
Client → Server:
{ type: 'list', sessionId: 'uuid', path: '/home/user' } # List directory
{ type: 'read', sessionId: 'uuid', path: '/etc/nginx/nginx.conf' } # Read file (max 5MB)
{ type: 'write', sessionId: 'uuid', path: '/etc/nginx/nginx.conf', data } # Write file
{ type: 'upload', sessionId: 'uuid', path: '/tmp/file.tar.gz', chunk } # Upload chunk
{ type: 'download', sessionId: 'uuid', path: '/var/log/syslog' } # Start download
{ type: 'mkdir', sessionId: 'uuid', path: '/home/user/newdir' } # Create directory
{ type: 'rename', sessionId: 'uuid', oldPath, newPath } # Rename/move
{ type: 'delete', sessionId: 'uuid', path: '/tmp/junk.log' } # Delete file
{ type: 'chmod', sessionId: 'uuid', path, mode: '755' } # Change permissions
{ type: 'stat', sessionId: 'uuid', path: '/home/user' } # Get file info
Server → Client:
{ type: 'list', path, entries: [...] } # Directory listing
{ type: 'fileContent', path, content, encoding } # File content
{ type: 'progress', transferId, bytes, total } # Transfer progress
{ type: 'error', message } # Operation error
```
### Host Key Verification
SSH host key verification follows standard `known_hosts` behavior:
1. **First connection:** ssh2 receives server's public key fingerprint. Gateway sends `host-key-verify` message to browser with `isNew: true`. User sees a dialog showing the fingerprint and chooses to accept or reject.
2. **Accept:** Fingerprint saved to `Host.hostFingerprint` in database. Connection proceeds.
3. **Subsequent connections:** ssh2 receives fingerprint, compared against stored `Host.hostFingerprint`. If match, connect silently. If mismatch, gateway sends `host-key-verify` with `isNew: false` and `previousFingerprint` — user warned of possible MITM.
4. **Reject:** Connection aborted, no fingerprint stored.
### Guacamole Tunnel (RDP)
NestJS acts as a tunnel between the browser's WebSocket and guacd's TCP socket:
1. Browser sends `{ type: 'connect', hostId: 456 }`
2. NestJS looks up host → decrypts RDP credentials
3. NestJS opens TCP socket to guacd at `${GUACD_HOST}:${GUACD_PORT}` (default: `guacd:4822`)
4. NestJS sends Guacamole handshake: `select`, `size`, `audio`, `video`, `image` instructions
5. NestJS sends `connect` instruction with RDP params (hostname, port, username, password, security, color-depth)
6. Bidirectional pipe: browser WebSocket ↔ NestJS ↔ guacd TCP
7. guacd handles actual RDP protocol to target Windows machine
The `guacamole-common-js` client library handles rendering the Guacamole instruction stream to Canvas in the browser.
### Encryption Service
Identical pattern to Vigilance HQ:
```typescript
encrypt(plaintext: string): string
→ random 16-byte IV
→ AES-256-GCM cipher with ENCRYPTION_KEY
→ return `v1:${iv.hex}:${authTag.hex}:${ciphertext.hex}`
decrypt(encrypted: string): string
→ parse version prefix, IV, authTag, ciphertext
→ AES-256-GCM decipher
→ return plaintext
```
`ENCRYPTION_KEY` is a 32-byte hex string from environment. `v1:` prefix allows future key rotation without re-encrypting all stored values.
---
## 9. Multi-User Bolt-On Path
When the time comes to add JT or Victor:
1. Add rows to `users` table
2. Add `userId` FK to `hosts`, `host_groups`, `credentials`, and `ssh_keys` tables (nullable — null = shared with all users)
3. Add `shared_with` field or a `host_permissions` join table
4. Add basic role: `admin` | `user` on `users` table
5. Filter host list by ownership/sharing in queries
6. Optional: Entra ID SSO (same pattern as HQ and RSM)
**Zero architectural changes.** The connection manager, vault, terminal, SFTP, and RDP modules don't change. You just add a filter layer on who can see what.
---
## 10. Build Phases
| Phase | Deliverables |
|---|---|
| **1: Foundation** | Docker Compose, NestJS scaffold, Prisma schema, encryption service, Nuxt 3 SPA shell, auth (single-user login), connection manager CRUD, host groups |
| **2: SSH + SFTP** | xterm.js terminal, ssh2 WebSocket proxy, host key verification, multi-tab, split panes, SFTP sidebar with file tree, upload/download, Monaco editor |
| **3: RDP** | guacd integration, Guacamole tunnel, RDP canvas rendering, clipboard sync, connection settings |
| **4: Polish** | SSH key import UI, vault management page, theming, quick connect, search, settings page, connection history/recent hosts |
> **Note on encryption timing:** The encryption service and credential CRUD (encrypted) are in Phase 1, not Phase 4. SSH connections in Phase 2 need to decrypt credentials — plaintext storage is never acceptable, even temporarily. Phase 4's vault work is the management UI (import dialogs, key list view), not the encryption layer itself.

View File

@ -1,982 +0,0 @@
# Wraith Desktop — Design Spec
> **Date:** 2026-03-17
> **Purpose:** Native Windows desktop replacement for MobaXTerm — SSH + SFTP + RDP in a single binary
> **Stack:** Go + Wails v3 (Vue 3 frontend, WebView2) + SQLite + FreeRDP3 (purego)
> **Target:** Personal tool for daily MSP/sysadmin work — Windows only
> **Name:** Wraith — exists everywhere, all at once.
---
## 1. What This Is
A Windows desktop application that replaces MobaXTerm. Multi-tabbed SSH terminal with SFTP sidebar (MobaXTerm's killer feature), RDP via FreeRDP3 dynamic linking, connection manager with hierarchical groups, and an encrypted vault for SSH keys and passwords. Ships as `wraith.exe` + `freerdp3.dll`. No Docker, no database server, no sidecar processes.
**What this is NOT:** A web app, a SaaS platform, a team tool. It's a personal remote access workstation built as a native desktop binary.
**Prior art:** This is a ground-up rebuild of Wraith, which was previously a self-hosted web application (Nuxt 3 + NestJS + guacd + PostgreSQL). The web version proved the feature set; this version delivers it as a proper desktop tool.
---
## 2. Technology Stack
### Backend (Go)
| Component | Technology | Purpose |
|---|---|---|
| Framework | Wails v3 (alpha) | Desktop app shell, multi-window, Go↔JS bindings |
| SSH | `golang.org/x/crypto/ssh` | SSH client connections, PTY, auth |
| SFTP | `github.com/pkg/sftp` | Remote filesystem operations over SSH |
| RDP | FreeRDP3 via `purego` / `syscall.NewLazyDLL` | RDP protocol, bitmap rendering |
| Database | SQLite via `modernc.org/sqlite` (pure Go) | Connections, credentials, settings |
| Encryption | `crypto/aes` + `crypto/cipher` (GCM) | Vault encryption at rest |
| Key derivation | `golang.org/x/crypto/argon2` | Master password → encryption key |
### Frontend (Vue 3 in WebView2)
| Component | Technology | Purpose |
|---|---|---|
| Framework | Vue 3 (Composition API) | UI framework |
| Terminal | xterm.js 5.x + WebGL addon | SSH terminal emulator |
| File editor | CodeMirror 6 | Remote file editing (separate window) |
| CSS | Tailwind CSS | Utility-first styling |
| Components | Naive UI | Tree, tabs, modals, dialogs, inputs |
| State | Pinia | Reactive stores for sessions, connections, app state |
| Build | Vite | Frontend build tooling |
### Distribution
| Artifact | Notes |
|---|---|
| `wraith.exe` | Single Go binary, ~8-10MB |
| `freerdp3.dll` | FreeRDP3 dynamic library, shipped alongside |
| Data | `%APPDATA%\Wraith\wraith.db` (SQLite) |
| Installer | NSIS via Wails build |
---
## 3. Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Wails v3 Application (wraith.exe) │
│ │
│ ┌─ Go Backend ──────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────────┐ │ │
│ │ │ SSH Service │ │ SFTP Service │ │ RDP Service │ │ │
│ │ │ x/crypto/ssh │ │ pkg/sftp │ │ purego→freerdp3 │ │ │
│ │ └──────┬───────┘ └──────┬───────┘ └────────┬──────────┘ │ │
│ │ │ │ │ │ │
│ │ ┌──────▼─────────────────▼────────────────────▼──────────┐ │ │
│ │ │ Session Manager │ │ │
│ │ │ • Tracks all active SSH/RDP sessions │ │ │
│ │ │ • Routes I/O between frontend and protocol backends │ │ │
│ │ │ • Supports tab detach/reattach (session ≠ window) │ │ │
│ │ └────────────────────────┬───────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────────────────────▼───────────────────────────────┐ │ │
│ │ │ Vault Service │ │ │
│ │ │ • Master password → Argon2id → AES-256-GCM key │ │ │
│ │ │ • SQLite storage (%APPDATA%\Wraith\wraith.db) │ │ │
│ │ │ • Encrypts: SSH keys, passwords, RDP credentials │ │ │
│ │ └────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │
│ │ │ Connection │ │ Import │ │ Host Key │ │ │
│ │ │ Manager │ │ .mobaconf │ │ Store │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ Wails v3 Bindings (type-safe Go↔JS) │
│ ▼ │
│ ┌─ Vue 3 Frontend (WebView2) ───────────────────────────────────┐ │
│ │ │ │
│ │ ┌──────────┐ ┌───────────┐ ┌──────────┐ ┌───────────────┐ │ │
│ │ │ xterm.js │ │ SFTP Tree │ │ RDP │ │ CodeMirror 6 │ │ │
│ │ │ +WebGL │ │ Sidebar │ │ Canvas │ │ (sep window) │ │ │
│ │ └──────────┘ └───────────┘ └──────────┘ └───────────────┘ │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────────────┐ │ │
│ │ │ Tab Bar (detachable) + Connection Sidebar │ │ │
│ │ │ Command Palette (Ctrl+K) | Dark theme │ │ │
│ │ │ Tailwind CSS + Naive UI │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ │ │
SSH (port 22) SFTP (over SSH) RDP (port 3389)
│ │ │
▼ ▼ ▼
Linux/macOS hosts Remote filesystems Windows hosts
```
### Key Architectural Decisions
**Sessions ≠ Windows.** SSH and RDP sessions live as objects in the Go Session Manager. The frontend is a view. Detaching a tab spawns a new Wails window pointing at the same backend session. Re-attaching destroys the window and re-renders the session in the original tab. The session itself never drops.
**Wails v3 multi-window risk mitigation:** This is the project's biggest technical risk. The detach/reattach model depends on Wails v3's alpha `application.NewWebviewWindow()` API. Three fallback plans, validated in priority order during Phase 1:
- **Plan A (target):** Wails v3 `NewWebviewWindow()` — true native multi-window. Spike this in Phase 1 with a minimal two-window prototype before committing.
- **Plan B:** Single Wails window with internal "floating panel" detach — session renders in a draggable, resizable overlay within the main window. Not true OS windows, but close enough. No external dependency.
- **Plan C:** Wails v3 server mode — detached sessions open in the default browser at `localhost:{port}/session/{id}`. Functional but breaks the native feel.
If Plan A fails, we fall to Plan B (which is entirely within our control). Plan C is the emergency fallback. **This must be validated in Phase 1, not discovered in Phase 4.**
**Single binary + DLL.** No Docker, no sidecar processes. SQLite is embedded (pure Go driver). FreeRDP3 is the only external dependency, loaded dynamically via `purego`.
**SFTP rides SSH.** SFTP opens a separate SSH channel on the same `x/crypto/ssh` connection as the terminal. No separate TCP connection is needed. `pkg/sftp.NewClient()` takes an `*ssh.Client` (not the shell `*ssh.Session`) and opens its own subsystem channel internally. The terminal shell session and SFTP operate as independent channels multiplexed over the same connection.
**RDP via pixel buffer.** FreeRDP3 is loaded via `purego` (dynamic linking, no CGO). FreeRDP writes decoded bitmap frames into a shared Go pixel buffer. The Go backend serves frame data to the frontend via a local HTTP endpoint (`localhost:{random_port}/frame`) that returns raw RGBA data. The frontend renders frames on a `<canvas>` element using `requestAnimationFrame`. Performance target: 1080p @ 30fps using Bitmap Update callbacks. The local HTTP approach is the default; if benchmarking reveals issues, Wails binding with base64-encoded frames is the fallback.
---
## 4. Data Model (SQLite)
```sql
-- Connection groups (hierarchical folders)
CREATE TABLE groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
sort_order INTEGER DEFAULT 0,
icon TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Saved connections
CREATE TABLE connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
hostname TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 22,
protocol TEXT NOT NULL CHECK(protocol IN ('ssh','rdp')),
group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL,
color TEXT,
tags TEXT DEFAULT '[]', -- JSON array: ["Prod","Linux","Client-RSM"]
notes TEXT,
options TEXT DEFAULT '{}', -- JSON: protocol-specific settings
sort_order INTEGER DEFAULT 0,
last_connected DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Credentials (password or SSH key reference)
CREATE TABLE credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
username TEXT,
domain TEXT,
type TEXT NOT NULL CHECK(type IN ('password','ssh_key')),
encrypted_value TEXT,
ssh_key_id INTEGER REFERENCES ssh_keys(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- SSH private keys (encrypted at rest)
CREATE TABLE ssh_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key_type TEXT,
fingerprint TEXT,
public_key TEXT,
encrypted_private_key TEXT NOT NULL,
passphrase_encrypted TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Terminal themes (16-color ANSI + fg/bg/cursor)
CREATE TABLE themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
foreground TEXT NOT NULL,
background TEXT NOT NULL,
cursor TEXT NOT NULL,
black TEXT NOT NULL,
red TEXT NOT NULL,
green TEXT NOT NULL,
yellow TEXT NOT NULL,
blue TEXT NOT NULL,
magenta TEXT NOT NULL,
cyan TEXT NOT NULL,
white TEXT NOT NULL,
bright_black TEXT NOT NULL,
bright_red TEXT NOT NULL,
bright_green TEXT NOT NULL,
bright_yellow TEXT NOT NULL,
bright_blue TEXT NOT NULL,
bright_magenta TEXT NOT NULL,
bright_cyan TEXT NOT NULL,
bright_white TEXT NOT NULL,
selection_bg TEXT,
selection_fg TEXT,
is_builtin BOOLEAN DEFAULT 0
);
-- Connection history (for recent connections + frequency sorting)
CREATE TABLE connection_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
protocol TEXT NOT NULL,
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
disconnected_at DATETIME,
duration_secs INTEGER
);
-- Known SSH host keys
CREATE TABLE host_keys (
hostname TEXT NOT NULL,
port INTEGER NOT NULL,
key_type TEXT NOT NULL,
fingerprint TEXT NOT NULL,
raw_key TEXT,
first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (hostname, port, key_type)
);
-- App settings (key-value)
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
```
**`connections.options`** — JSON blob for protocol-specific settings. SSH: keepalive interval, preferred auth method, shell integration toggle. RDP: color depth, security mode (NLA/TLS/RDP), console session, audio redirection, display resolution. Keeps the schema clean and extensible as we discover edge cases without adding nullable columns.
**`connections.tags`** — JSON array searchable via SQLite's `json_each()`. Enables filtering across groups (type "Prod" in search, see only production hosts regardless of which group they're in).
**Connections → Credentials** is many-to-one. Multiple hosts can share the same credential.
**SQLite WAL mode:** Enable Write-Ahead Logging (`PRAGMA journal_mode=WAL`) on database open in `db/sqlite.go`. WAL mode allows concurrent reads during writes, preventing "database is locked" errors when the frontend queries connections while the backend is writing session history or updating `last_connected` timestamps. Also set `PRAGMA busy_timeout=5000` as a safety net.
**Host keys** are keyed by `(hostname, port, key_type)`. Supports multiple key types per host. Separated from connections so host key verification works independently of saved connections (e.g., quick connect).
---
## 5. UI Layout
### Visual Identity
Dark theme inspired by the Wraith brand: deep dark backgrounds (#0d1117), blue accent (#58a6ff), green for SSH indicators (#3fb950), blue for RDP indicators (#1f6feb). The aesthetic is "operator command center" — atmospheric, moody, professional. Reference: `docs/karens-wraith-layout.png` for the target mood.
Logo: `images/wraith-logo.png` — ghost with "$" symbol, used in the title bar and app icon.
**The "alive" feel:** Tabs use a 0.5s CSS `transition` on `background-color` and `border-color` when switching between active and backgrounded states. The active tab's background subtly brightens; backgrounded tabs dim. This creates a fluid, "breathing" quality as you switch between sessions — the Wraith is present without being loud. Same 0.5s transition applies to sidebar item hover states and toolbar button interactions. No animations on the terminal itself — that would be distracting.
### Main Window Layout
```
┌─────────────────────────────────────────────────────────────┐
│ [👻 WRAITH v1.0] File View Tools Settings Help │ ← Title/Menu bar
├─────────────────────────────────────────────────────────────┤
│ [⚡ Quick connect...] [+SSH] [+RDP] 4 sessions 🔒 ⚙ │ ← Toolbar
├────────────┬────────────────────────────────────────────────┤
│ │ [Asgard ●] [Docker ●] [Predator ●] [VM01 ●]+ │ ← Tab bar
│ SIDEBAR │────────────────────────────────────────────────│
│ │ │
│ Toggles: │ Terminal / RDP Canvas │
│ 📂 Conn │ │
│ 📁 SFTP │ (xterm.js or <canvas>) │
│ │ │
│ Search │ Primary workspace area │
│ Tags │ Takes dominant space │
│ Groups │ │
│ Tree │ │
│ │ │
├────────────┴────────────────────────────────────────────────┤
│ SSH · root@asgard:22 ⚠️ ↑1.2K ↓3.4K Dark+ UTF-8 120×40 │ ← Status bar
└─────────────────────────────────────────────────────────────┘
```
### Sidebar Behavior
The left sidebar is a **single panel that toggles context** between Connections and SFTP (same as MobaXTerm, not two panels):
- **Connections view:** Search bar, tag filter pills, recent connections, hierarchical group tree with connection entries. Green dots for SSH, blue dots for RDP. "Connected" indicator on active sessions. Right-click context menu for edit, delete, duplicate, move to group.
- **SFTP view:** Activates when an SSH session connects. Path bar showing current remote directory. Toolbar with upload, download, new file, new folder, refresh, delete. File tree with name, size, modified date. "Follow terminal folder" toggle at bottom.
The sidebar is resizable. Minimum width ~200px, collapsible to icon-only rail.
### Tab Bar
- Color-coded dots: green = SSH, blue = RDP
- Protocol icon on each tab
- Environment badges: optional colored pills (PROD, ROOT, DEV) derived from connection tags
- Root session warning: tabs connected as root get a subtle warm accent
- Close button (×) on each tab
- Pop-out icon (↗) on hover for tab detach
- Overflow: chevron dropdown for hidden tabs when 10+ are open (not multi-line rows)
- Drag to reorder tabs
- Drag out of tab bar to detach into new window
- "+" button to open new session
### Tab Detach/Reattach
- **Detach:** Drag tab out of bar OR click ↗ icon → spawns new Wails window with that session still alive. Original tab shows "Session detached — [Reattach]" placeholder.
- **Reattach:** Click "Reattach" button in placeholder OR close the detached window → session snaps back into the tab bar.
- Works for both SSH and RDP sessions.
- The session lives in the Go backend, not the window. Detaching is just moving the view.
### Command Palette (Ctrl+K)
Modal overlay with fuzzy search across:
- Connection names, hostnames, group names, tags
- Actions: "New SSH", "New RDP", "Open Vault", "Settings", "Import MobaXTerm"
- Active sessions: "Switch to Asgard", "Disconnect Docker"
- Keyboard-first — arrow keys to navigate, Enter to select, Esc to close
### Status Bar
- Left: Protocol, user@host:port, privilege warning (⚠️ when root), transfer speed
- Right: Active theme name, encoding (UTF-8), terminal dimensions (cols×rows)
- Active session count in toolbar area
### Terminal Theming
Built-in themes: Dracula, Nord, Monokai, Solarized Dark, One Dark, Gruvbox, plus a "MobaXTerm Classic" theme matching the colors from the user's `.mobaconf` export.
Custom theme creation via settings. Full 16-color ANSI palette + foreground/background/cursor maps directly to xterm.js `ITheme` objects.
Per-connection theme override via `connections.options` JSON field.
### Keyboard Shortcuts
| Shortcut | Action |
|---|---|
| Ctrl+K | Command palette |
| Ctrl+T | New SSH session (from quick connect) |
| Ctrl+W | Close current tab |
| Ctrl+Tab | Next tab |
| Ctrl+Shift+Tab | Previous tab |
| Ctrl+1-9 | Switch to tab N |
| Ctrl+B | Toggle sidebar |
| Ctrl+Shift+D | Detach current tab |
| F11 | Fullscreen |
| Ctrl+Shift+C | Copy (terminal) |
| Ctrl+Shift+V | Paste (terminal) |
| Ctrl+F | Search in terminal scrollback |
### CodeMirror 6 Editor (Separate Window)
- Opens as a new Wails window when clicking a text file in the SFTP sidebar
- File size guard: files over 5MB refused for inline editing (offered download instead)
- Syntax highlighting based on file extension
- Save button writes content back to remote via SFTP
- Unsaved changes warning on close
- Window title: `filename — host — Wraith Editor`
---
## 6. SSH + SFTP Flow
### SSH Connection
```
User double-clicks "Asgard" in connection sidebar
→ Go: ConnectionManager.Connect(connectionId)
→ Go: VaultService.DecryptCredential(credentialId) → auth method
→ Go: SSHService.Dial(hostname, port, authConfig)
→ x/crypto/ssh.Dial() with host key callback
→ If new host key: emit event to frontend, user accepts/rejects
→ If changed host key: BLOCK connection, warn user (no silent accept)
→ If accepted: store in host_keys table
→ Go: SessionManager.Create(sshClient, connectionId) → sessionId
→ Go: SSHService.RequestPTY(session, "xterm-256color", cols, rows)
→ Go: SSHService.Shell(session) → stdin/stdout pipes
→ Frontend: xterm.js instance created, bound to sessionId
→ Wails bindings: bidirectional data flow
→ xterm.js onData → Go SSHService.Write(sessionId, bytes)
→ Go SSHService.Read(sessionId) → Wails event → xterm.js write
```
### SSH Authentication
Supports three auth methods:
1. **SSH Key:** Decrypt private key from vault. If key has passphrase, decrypt that too. Pass to `ssh.PublicKeys()` signer.
2. **Password:** Decrypt password from vault. Pass to `ssh.Password()`.
3. **Keyboard-Interactive:** For servers with 2FA/MFA prompts. `ssh.KeyboardInteractive()` callback relays challenge prompts to the frontend, user responds in a dialog. Common in MSP environments with PAM-based MFA.
Auth methods are tried in order: key → password → keyboard-interactive. The credential type determines which are attempted first, but keyboard-interactive is always available as a fallback for servers that require it.
### Terminal Resize
```
Frontend: xterm.js fit addon detects container resize
→ Wails binding: SSHService.Resize(sessionId, cols, rows)
→ Go: session.WindowChange(rows, cols)
```
### SFTP Sidebar
```
SSH connection established:
→ Go: SFTPService.Open(sshClient) → pkg/sftp.NewClient(sshClient)
→ Go: SFTPService.List(sessionId, homeDir) → directory listing
→ Frontend: sidebar switches to SFTP view, renders file tree
```
SFTP uses the **same SSH connection** as the terminal (SFTP subsystem). No separate connection needed.
**File operations:** All SFTP commands route through Go via Wails bindings, targeting the correct `pkg/sftp` client by sessionId.
| Operation | Go function | Notes |
|---|---|---|
| List directory | `sftp.ReadDir(path)` | Lazy-loaded on tree expand |
| Upload | `sftp.Create(path)` + chunked write | Drag-and-drop from Windows Explorer |
| Download | `sftp.Open(path)` + read | Browser-style save dialog |
| Delete | `sftp.Remove(path)` / `sftp.RemoveAll(path)` | Confirmation prompt |
| Rename/Move | `sftp.Rename(old, new)` | |
| Mkdir | `sftp.Mkdir(path)` | |
| Chmod | `sftp.Chmod(path, mode)` | |
| Read file | `sftp.Open(path)` → content | Opens in CodeMirror window |
| Write file | `sftp.Create(path)` ← content | Save from CodeMirror |
### CWD Following
```
SSH session starts:
→ Go: injects shell hook after PTY is established:
PROMPT_COMMAND='printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD"'
(or precmd for zsh)
→ Go: SSHService reads stdout, scans for OSC 7 escape sequences
→ Go: strips OSC 7 before forwarding to xterm.js (user never sees it)
→ Go: emits CWD change event with new path
→ Frontend: if "Follow terminal folder" is enabled, calls SFTPService.List(newPath)
→ Frontend: SFTP tree navigates to new directory
```
"Follow terminal folder" is a per-session toggle (checkbox at bottom of SFTP sidebar), enabled by default.
**Shell detection:** The OSC 7 injection assumes a bash-like shell (`PROMPT_COMMAND`) or zsh (`precmd`). For fish, the equivalent is `function fish_prompt; printf "\033]7;file://%s%s\033\\" (hostname) "$PWD"; end`. If shell detection fails (unknown shell, restricted shell, non-interactive session), CWD following is silently disabled — the SFTP sidebar stays at the initial home directory and requires manual navigation.
### Upload Flow
```
User drags file from Windows Explorer onto SFTP sidebar:
→ Frontend: reads file via File API, sends chunks to Go
→ Go: SFTPService.Upload(sessionId, remotePath, fileData)
→ Go: sftp.Create(remotePath) → write chunks → close
→ Progress events emitted back to frontend
→ SFTP tree refreshes on completion
```
---
## 7. RDP Flow
### Architecture
FreeRDP3 is loaded via `purego` (or `syscall.NewLazyDLL`) at runtime. No CGO, no C compiler needed. The Go binary loads `freerdp3.dll` from the application directory.
### Connection
```
User double-clicks "CLT-VMHOST01" in connection sidebar:
→ Go: ConnectionManager.Connect(connectionId)
→ Go: VaultService.DecryptCredential(credentialId) → username, password, domain
→ Go: RDPService.Connect(host, port, username, password, domain, options)
→ purego: freerdp_new() → configure settings → freerdp_connect()
→ Register BitmapUpdate callback
→ Go: allocate pixel buffer (width × height × 4 bytes RGBA)
→ FreeRDP: decoded bitmap frames written into pixel buffer
→ Go: frame data served to frontend
→ Frontend: <canvas> renders frames via requestAnimationFrame (30fps target)
```
### Frame Delivery
FreeRDP writes decoded frame data into a shared Go pixel buffer. The frontend retrieves frame data via one of:
- **Local HTTP endpoint:** `localhost:{random_port}/frame` returns raw RGBA or PNG
- **Blob URL:** Go encodes frame, passes via Wails binding as base64
- **Optimal approach TBD during implementation** — benchmark both
Performance target: **1080p @ 30fps**. Focus on Bitmap Update callbacks. No H.264 pipeline needed — raw bitmap updates with basic RLE compression is sufficient for remote management work.
### Input Handling
```
Frontend: mouse/keyboard events captured on <canvas> element
→ Wails binding → Go: RDPService.SendMouseEvent(sessionId, x, y, flags)
→ Wails binding → Go: RDPService.SendKeyEvent(sessionId, keycode, pressed)
→ Go: translate JS virtual keycodes to RDP scancodes via lookup table
→ Go: purego calls freerdp_input_send_mouse_event / freerdp_input_send_keyboard_event
```
**Scancode mapping:** JavaScript `KeyboardEvent.code` values (e.g., "KeyA", "ShiftLeft") must be translated to RDP hardware scancodes that FreeRDP expects. A static lookup table in `internal/rdp/input.go` maps JS key codes → RDP scancodes. This is a known complexity in web-based RDP — the table must handle extended keys (e.g., right Alt, numpad) and platform-specific quirks. Reference: FreeRDP's `scancode.h` for the canonical scancode list.
**System key pass-through:** The Windows key and Alt+Tab require special handling. By default, these keys are captured by the local OS. A per-connection toggle in `connections.options` (`"grabKeyboard": true`) controls whether system keys are forwarded to the remote host or stay local. When enabled, the RDP canvas captures all keyboard input including Win key, Alt+Tab, Ctrl+Alt+Del (via a toolbar button). Power users toggling between remote and local need this to be fast and obvious — surface it as an icon in the RDP toolbar.
### Clipboard Sync
```
Remote → Local:
→ Go: FreeRDP clipboard channel callback fires
→ Go: emits clipboard event to frontend
→ Frontend: writes to system clipboard via Wails API
Local → Remote:
→ Frontend: detects clipboard change (or user pastes)
→ Wails binding → Go: RDPService.SendClipboard(sessionId, data)
→ Go: writes to FreeRDP clipboard channel
```
### RDP Connection Options
Stored in `connections.options` JSON field:
```json
{
"colorDepth": 32,
"security": "nla",
"consoleSession": false,
"audioRedirect": false,
"width": 1920,
"height": 1080,
"scaleFactor": 100
}
```
**HiDPI / display scaling:** On Windows with display scaling (e.g., 150% on a 4K monitor), the RDP session resolution must account for the scale factor. `scaleFactor` in connection options controls whether to send the physical pixel resolution or the scaled logical resolution to FreeRDP. Default behavior: detect the current Windows DPI setting and scale the RDP resolution accordingly. Override via the `scaleFactor` option (100 = no scaling, 150 = 150%).
---
## 8. Vault + Encryption
### Master Password Flow
```
App launch:
→ Master password prompt (modal, cannot be bypassed)
→ If first launch:
→ Generate random 32-byte salt
→ Store salt in settings table (key: "vault_salt")
→ Derive key: Argon2id(password, salt, t=3, m=65536, p=4, keyLen=32)
→ Encrypt a known test value ("wraith-vault-check") with derived key
→ Store encrypted test value in settings (key: "vault_check")
→ If returning:
→ Read salt and encrypted test value from settings
→ Derive key with same parameters
→ Attempt to decrypt test value
→ If decryption succeeds → vault unlocked
→ If fails → wrong password, prompt again
→ Derived key held in memory only, never written to disk
→ Key zeroed from memory on app close
```
### Encryption Functions
```
Encrypt(plaintext string) → string:
→ Generate random 12-byte IV (crypto/rand)
→ AES-256-GCM Seal(): returns ciphertext with authTag appended (Go's native format)
→ Return "v1:{iv_hex}:{sealed_hex}"
→ (sealed = ciphertext || authTag, as produced by cipher.AEAD.Seal())
Decrypt(blob string) → string:
→ Parse version prefix, IV (12B), sealed data
→ AES-256-GCM Open(): decrypts and verifies authTag (Go's native format)
→ Return plaintext
```
The `v1:` version prefix enables future key rotation without re-encrypting all stored values.
### What Gets Encrypted
| Data | Encrypted | Reason |
|---|---|---|
| SSH private keys | Yes | Sensitive key material |
| SSH key passphrases | Yes | Passphrase is a secret |
| Password credentials | Yes | Passwords are secrets |
| RDP passwords | Yes | Via credential reference |
| Hostnames, ports, usernames | No | Not secrets, needed for display |
| Public keys, fingerprints | No | Public by definition |
| Group names, tags, notes | No | Not secrets |
| Settings, themes | No | User preferences |
### Argon2id Parameters
| Parameter | Value | Rationale |
|---|---|---|
| Time cost (t) | 3 | OWASP recommended minimum |
| Memory cost (m) | 65536 (64MB) | Resists GPU attacks |
| Parallelism (p) | 4 | Matches typical core count |
| Key length | 32 bytes (256-bit) | AES-256 key size |
| Salt | 32 bytes, random | Unique per installation |
### Future: Windows DPAPI Integration (Post-MVP)
The current vault is secure and portable (works on any Windows machine, backup the `.db` file and go). Post-MVP, an optional DPAPI layer could wrap the derived AES key with Windows Data Protection API, tying the vault to the current Windows user account. This would enable:
- Transparent unlock when logged into Windows (no master password prompt)
- Hardware-backed key protection on machines with TPM
- Enterprise trust (DPAPI is a known quantity for IT departments)
Implementation: the Argon2id-derived key gets wrapped with `CryptProtectData()` and stored. On unlock, DPAPI unwraps the key. Master password remains the fallback for portability (moving the database to another machine). This is designed-for but not built in MVP — the `v1:` encryption prefix enables adding a `v2:` scheme without re-encrypting existing data.
---
## 9. MobaXTerm Importer
### Config Format
MobaXTerm exports configuration as `.mobaconf` files — INI format with `%`-delimited session strings.
```ini
[Bookmarks_1]
SubRep=AAA Vantz's Stuff # Group name
ImgNum=41 # Icon index
*Asgard=#109#0%192.168.1.4%22%vstockwell%... # SSH session
CLT-VMHOST01=#91#4%100.64.1.204%3389%... # RDP session
[SSH_Hostkeys]
ssh-ed25519@22:192.168.1.4=0x29ac... # Known host keys
[Colors]
ForegroundColour=236,236,236 # Terminal colors
BackgroundColour=36,36,36
[Passwords]
vstockwell@192.168.1.214=_@9jajOXK... # Encrypted (can't import)
```
### Session String Parsing
| Protocol | Type code | Fields (%-delimited) |
|---|---|---|
| SSH | `#109#` | host, port, username, ..., SSH key path, ..., colors |
| RDP | `#91#` | host, port, username, ..., color depth, security |
### Import Flow
```
1. User: File → Import → Select .mobaconf file
2. Go: parse INI sections
3. Go: extract groups from [Bookmarks_N] SubRep values
4. Go: parse session strings → connections
5. Go: parse [SSH_Hostkeys] → host_keys table
6. Go: parse [Colors] + [Font] → create "MobaXTerm Import" theme
7. Frontend: show preview dialog:
"Found: 18 connections, 1 group, 4 host keys, 1 color theme"
8. User confirms import
9. Go: create groups, connections, host keys, theme in SQLite
10. Frontend: report results:
"Imported! 5 connections reference SSH keys — re-import key files.
3 connections had stored passwords — re-enter in Wraith vault."
```
### What Gets Imported
| Data | Imported | Notes |
|---|---|---|
| Connection names | Yes | |
| Groups (folder hierarchy) | Yes | From SubRep values |
| Hostnames, ports | Yes | |
| Usernames | Yes | |
| Protocol (SSH/RDP) | Yes | From type code #109# / #91# |
| SSH key file paths | As notes | User must re-import actual key files |
| Host keys | Yes | To host_keys table |
| Terminal colors | Yes | As a new theme |
| Font preferences | Yes | To settings |
| Encrypted passwords | No | MobaXTerm-encrypted, can't decrypt |
---
## 10. Frontend Structure
```
frontend/
src/
App.vue # Root: master password → main layout
layouts/
MainLayout.vue # Sidebar + tab container + status bar
UnlockLayout.vue # Master password prompt
components/
sidebar/
ConnectionTree.vue # Group tree with connection entries
SftpBrowser.vue # SFTP file tree + toolbar
SidebarToggle.vue # Connections ↔ SFTP toggle
session/
SessionContainer.vue # Holds all active sessions (v-show, not v-if)
TabBar.vue # Draggable, detachable tab bar
TabBadge.vue # PROD/ROOT/DEV environment pills
terminal/
TerminalView.vue # xterm.js instance wrapper
ThemePicker.vue # Terminal color scheme selector
rdp/
RdpView.vue # Canvas-based RDP renderer
RdpToolbar.vue # Clipboard, fullscreen controls
sftp/
FileTree.vue # Remote filesystem tree (lazy-loaded)
TransferProgress.vue # Upload/download progress indicator
vault/
VaultManager.vue # SSH keys + credentials management
KeyImportDialog.vue # SSH key import modal
CredentialForm.vue # Password/key credential form
common/
CommandPalette.vue # Ctrl+K fuzzy search overlay
QuickConnect.vue # Quick connect input
StatusBar.vue # Bottom status bar
composables/
useSession.ts # Session lifecycle + tab management
useTerminal.ts # xterm.js + Wails binding bridge
useSftp.ts # SFTP operations via Wails bindings
useRdp.ts # RDP canvas rendering + input capture
useVault.ts # Key/credential CRUD
useConnections.ts # Connection CRUD + search + tags
useTheme.ts # Terminal theme management
useCommandPalette.ts # Command palette search + actions
stores/
session.store.ts # Active sessions, tab order, detach state
connection.store.ts # Connections, groups, search state
app.store.ts # Global state: unlocked, settings, active theme
```
**Session architecture:** Active sessions render as persistent components inside `SessionContainer.vue`. Switching tabs toggles `v-show` visibility (not `v-if` destruction), so xterm.js and RDP canvas instances stay alive across tab switches. This is critical — destroying and recreating terminal instances would lose scrollback and session state.
---
## 11. Go Backend Structure
```
internal/
app/
app.go # Wails app setup, window management
menu.go # Application menu definitions
session/
manager.go # Session lifecycle, tab detach/reattach
session.go # Session struct (SSH or RDP, backend state)
ssh/
service.go # SSH dial, PTY, shell, I/O pipes
hostkey.go # Host key verification + storage
cwd.go # OSC 7 parsing for CWD tracking
sftp/
service.go # SFTP operations (list, upload, download, etc.)
rdp/
service.go # RDP session management
freerdp.go # purego bindings to freerdp3.dll
pixelbuffer.go # Shared frame buffer management
input.go # Mouse/keyboard event translation
vault/
service.go # Encrypt/decrypt, master password, key derivation
vault_test.go # Encryption round-trip tests
connections/
service.go # Connection CRUD, group management
search.go # Full-text search + tag filtering
importer/
mobaconf.go # MobaXTerm .mobaconf parser
mobaconf_test.go # Parser tests with real config samples
db/
sqlite.go # SQLite connection, migrations
migrations/ # SQL migration files
settings/
service.go # Key-value settings CRUD
theme/
service.go # Theme CRUD, built-in theme definitions
builtins.go # Dracula, Nord, Monokai, etc.
plugin/
interfaces.go # Plugin interfaces (ProtocolHandler, Importer, etc.)
registry.go # Plugin registration and lifecycle
```
### Plugin Interface
Wraith exposes Go interfaces that community developers can implement to extend functionality:
```go
// ProtocolHandler — add support for new protocols (VNC, Telnet, etc.)
type ProtocolHandler interface {
Name() string
Connect(config ConnectionConfig) (Session, error)
Disconnect(sessionId string) error
}
// Importer — add support for importing from other tools
type Importer interface {
Name() string
FileExtensions() []string
Parse(data []byte) (*ImportResult, error)
}
```
Plugins are compiled into the binary (not runtime-loaded). Community developers fork the repo, implement the interface, register it in `plugin/registry.go`, and build. This keeps distribution simple (single binary) while enabling extensibility.
---
## 12. MVP Scope
### In MVP (launch-blocking)
| Feature | Priority | Phase | Notes |
|---|---|---|---|
| Wails v3 scaffold + SQLite + vault | P0 | 1 | Foundation — nothing works without this |
| Connection manager sidebar | P0 | 1 | Groups, tree, search, tags |
| SSH terminal (xterm.js) | P0 | 2 | Multi-tab, 8+ concurrent sessions |
| SFTP sidebar | P0 | 2 | Auto-open, CWD following, file ops |
| Credential vault UI | P0 | 2 | SSH key import, credential management |
| Host key verification | P0 | 2 | Accept/reject new, block changed |
| RDP in tabs | P0 | 3 | FreeRDP3/purego, embedded canvas |
| MobaXTerm importer | P1 | 4 | Parse .mobaconf, first-run detection |
| Terminal theming | P1 | 4 | 6+ built-in themes, custom themes |
| Tab detach/reattach | P1 | 4 | Drag out, pop-out icon, reattach button |
| CodeMirror 6 editor | P1 | 4 | Separate window, syntax highlighting |
| Command palette (Ctrl+K) | P1 | 4 | Fuzzy search connections + actions |
| Session context awareness | P1 | 4 | Root warning, user@host in status bar |
| Tab badges | P1 | 4 | Protocol icon, environment tags |
| Quick connect | P1 | 4 | user@host:port in toolbar |
| Plugin interface | P1 | 1 | Define interfaces, implement in later phases |
| README.md | P1 | 1 | Developer docs, architecture, contribution guide |
### Post-MVP
| Feature | Notes |
|---|---|
| Split panes | Horizontal/vertical splits within a tab |
| Session recording/playback | asciinema-compatible |
| Jump host / bastion proxy | ProxyJump chain support |
| Port forwarding manager | Local, remote, dynamic SSH tunnels |
| Saved snippets/macros | Quick-execute command library |
| Tab grouping/stacking | Browser-style tab groups |
| Live latency monitoring | Ping/packet loss in status bar |
| Dual-pane SFTP | Server-to-server file operations |
| Auto-detect environment | Parse hostname for prod/dev/staging classification |
| Subtle glow effects | "Wraith" personality — energy on active sessions |
| Dynamic plugin loading | Drop-in plugins without recompilation (longer-term) |
| Windows DPAPI vault | Optional OS-backed encryption layer for transparent unlock |
| **Claude Code plugin** | **First official plugin — see below** |
### Post-MVP Plugin: Claude Code Integration
The first plugin built on the Wraith plugin interface. Embeds Claude Code directly into Wraith as a sidebar panel or tab, with full access to the active session's context.
**Authentication:** User authenticates with their Anthropic API key or Claude account (stored encrypted in the vault alongside SSH keys and passwords). Key is decrypted on demand, never persisted in plaintext.
**Core capabilities:**
- **Terminal integration:** Claude Code runs in a dedicated Wraith tab (xterm.js instance). It can see the active SSH session's terminal output and type commands into it — same as a human operator switching tabs.
- **SFTP-aware file access:** Claude Code can read and write files on the remote host via the active SFTP session. "Read `/etc/nginx/nginx.conf`" pulls the file through SFTP, Claude analyzes/modifies it, and writes it back. No need for Claude to SSH separately — it rides the existing Wraith session.
- **CodeMirror handoff:** Claude can open files in the CodeMirror editor window, make changes, and save back to the remote host. The user sees the edits happening in real-time.
- **Context awareness:** Claude sees which host you're connected to, the current working directory (via CWD tracking), and recent terminal output. "Fix the nginx config on this server" just works because Claude already knows where "this" is.
**UX flow:**
1. User opens Claude Code panel (sidebar tab or dedicated session tab)
2. Types a prompt: "Check why nginx is returning 502 on this server"
3. Claude reads recent terminal output, pulls nginx config via SFTP, analyzes logs
4. Claude proposes a fix, user approves, Claude writes the file via SFTP
5. Claude types `nginx -t && systemctl reload nginx` into the terminal
**Plugin interface usage:** This plugin implements `ProtocolHandler` (for the Claude Code tab) and extends the SFTP/terminal services to allow programmatic read/write. It proves the plugin architecture works and becomes the reference implementation for community plugin developers.
---
## 13. Build Phases
### Error Handling + Logging Strategy
**Structured logging:** Use `log/slog` (Go 1.21+ standard library) with JSON output. Log levels: DEBUG, INFO, WARN, ERROR. Log to `%APPDATA%\Wraith\wraith.log` with daily rotation (keep 7 days).
**Connection drops:** When an SSH/RDP connection drops unexpectedly:
1. Session Manager marks session as `disconnected`
2. Frontend tab shows "Connection lost — [Reconnect] [Close]"
3. Auto-reconnect is opt-in (configurable per connection via `options` JSON)
4. If auto-reconnect is enabled, retry 3 times with exponential backoff (1s, 2s, 4s)
**Error surfacing:** Errors from Go backend are emitted as Wails events with a severity level. Frontend shows:
- Transient errors (network timeout) → toast notification, auto-dismiss
- Actionable errors (auth failure) → modal with explanation and action button
- Fatal errors (vault corruption) → full-screen error with instructions
**Sensitive data in logs:** Never log passwords, private keys, or decrypted credentials. Log only: connection IDs, hostnames, session IDs, error types.
### Crash Recovery + Workspace Restore
When the app crashes, the system reboots, or Wails dies, SSH/RDP sessions are gone — there's no way to recover a dropped TCP connection. But the **workspace layout** can be restored.
**Workspace snapshots:** The Session Manager periodically writes a workspace snapshot to SQLite (every 30 seconds and on clean shutdown):
```json
{
"tabs": [
{"connectionId": 1, "protocol": "ssh", "position": 0, "detached": false},
{"connectionId": 5, "protocol": "rdp", "position": 1, "detached": false},
{"connectionId": 3, "protocol": "ssh", "position": 2, "detached": true, "windowBounds": {...}}
],
"sidebarWidth": 240,
"sidebarMode": "connections",
"activeTab": 0
}
```
**On restart after crash:**
1. Detect unclean shutdown (snapshot exists but no `clean_shutdown` flag)
2. Show: "Wraith closed unexpectedly. Restore previous workspace? [Restore] [Start Fresh]"
3. If Restore: recreate tab layout, attempt to reconnect each session
4. Tabs that fail to reconnect show "Connection lost — [Retry] [Close]"
Users care about continuity more than perfection. Even if every session dies, restoring the layout and offering one-click reconnect is a massive UX win.
### Resource Management
With 20+ SSH sessions and multiple RDP sessions, resource awareness is critical:
**Memory budget:** Each SSH session costs ~2-5MB (PTY buffer + SFTP client). Each RDP session costs ~8-12MB (pixel buffer at 1080p). Target: stable at 20 SSH + 3 RDP (~100-120MB total backend memory).
**Session limits:**
- Default max: 32 concurrent sessions (SSH + RDP combined)
- Configurable via settings
- When limit reached: "Maximum sessions reached. Close a session to open a new one."
**Inactive session handling:**
- Sessions idle for 30+ minutes get a subtle "idle" indicator on the tab (dimmed text)
- SSH keepalive (`ServerAliveInterval` equivalent) prevents server-side timeouts — configurable per connection via `options.keepAliveInterval` (default: 60 seconds)
- No automatic session suspension — users control their sessions explicitly
- SFTP idle connections are closed after 10 minutes of inactivity and silently reopened on next file operation
**Monitoring:** Expose a "Sessions" panel in Settings showing per-session memory usage, connection duration, and idle time. Simple table, not a dashboard.
---
## 14. Licensing + Open Source
**License:** MIT. All dependencies must be MIT, Apache 2.0, BSD, or ISC compatible. **No GPL/AGPL dependencies.**
Dependency license audit is part of Phase 1. Key libraries and their licenses:
- `golang.org/x/crypto` — BSD-3-Clause ✓
- `github.com/pkg/sftp` — BSD-2-Clause ✓
- `github.com/ebitengine/purego` — Apache 2.0 ✓
- `modernc.org/sqlite` — BSD-3-Clause ✓
- FreeRDP3 — Apache 2.0 ✓ (dynamically linked, no license contamination)
- xterm.js — MIT ✓
- Vue 3 — MIT ✓
- Naive UI — MIT ✓
- Tailwind CSS — MIT ✓
- CodeMirror 6 — MIT ✓
**Plugin architecture:** The Go backend exposes a plugin interface so community developers can extend Wraith with custom protocol handlers, importers, or sidebar panels. Plugins are Go packages that implement defined interfaces and are compiled into the binary (no runtime plugin loading — keeps the binary simple and portable).
**README.md:** Comprehensive developer-facing documentation covering: architecture overview, build instructions, project structure walkthrough, plugin development guide, contribution guidelines, and the design philosophy. Written as part of Phase 1.
---
## 15. First-Run Experience
On first launch:
1. Master password creation dialog (set + confirm)
2. Detect if `.mobaconf` files exist in common locations (`%APPDATA%\MobaXterm\`, user's Documents folder)
3. If found: prompt "We found a MobaXTerm configuration. Import your sessions?" with file path shown
4. If not found: offer "Import from MobaXTerm" button + "Start fresh"
5. After import (or skip): land on the empty connection manager with a "Create your first connection" prompt
---
## 16. Build Phases
| Phase | Deliverables |
|---|---|
| **1: Foundation** | Wails v3 scaffold (including multi-window spike — validate Plan A/B/C), SQLite schema + migrations (WAL mode), vault service (master password, Argon2id, AES-256-GCM), connection CRUD, group tree, Vue 3 shell with sidebar + tab container, dark theme, Naive UI integration, plugin interface definitions, README.md, license audit, **RDP frame transport spike** (benchmark HTTP vs base64 with a test canvas — don't wait until Phase 3) |
| **2: SSH + SFTP** | SSH service (x/crypto/ssh), PTY + shell, xterm.js terminal rendering, multi-tab sessions, SFTP sidebar (pkg/sftp), file tree, upload/download, CWD following (OSC 7), CodeMirror 6 editor in separate window, workspace snapshot persistence |
| **3: RDP** | FreeRDP3 purego bindings, pixel buffer, canvas rendering (using proven transport from Phase 1 spike), mouse/keyboard input mapping (including scancode table + system key pass-through), clipboard sync, connection options |
| **4: Polish** | Command palette, tab detach/reattach, terminal theming (built-in + custom), MobaXTerm importer (with first-run detection), tab badges, session context awareness, quick connect, host key management UI, settings page, crash recovery / workspace restore, resource management panel, NSIS installer |

View File

@ -0,0 +1,186 @@
# 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

View File

@ -1,108 +0,0 @@
# Wraith Remote — Test Suite Build-Out Spec
**Date:** 2026-03-14
**Scope:** Level B — Full service layer coverage (~80-100 tests)
**Status:** Pinned — awaiting green light to execute
---
## Backend (Jest)
Jest is already configured in `backend/package.json`. Zero spec files exist today.
### Infrastructure
- Tests co-located with source: `*.spec.ts` next to `*.ts`
- Shared mock factories in `backend/src/__mocks__/` for Prisma, JwtService, EncryptionService
- `beforeEach` resets all mocks to prevent test bleed
### Test Files
| File | Tests | Priority |
|------|-------|----------|
| `auth.service.spec.ts` | Login (valid, invalid, non-existent user timing), bcrypt→argon2 migration, TOTP setup/verify/disable, TOTP secret encryption/decryption, password hashing, profile update, admin CRUD | ~20 |
| `encryption.service.spec.ts` | v2 encrypt/decrypt round-trip, v1 backwards compat decrypt, v1→v2 upgrade, isV1 detection, invalid version handling, key derivation warmup | ~8 |
| `credentials.service.spec.ts` | findAll excludes encryptedValue, findOne with ownership check, create with encryption, update with password change, remove, decryptForConnection (password, SSH key, orphaned key, no auth) | ~10 |
| `ssh-keys.service.spec.ts` | Create with encryption, findAll (no private key leak), findOne ownership, update passphrase, remove, key type detection, fingerprint generation | ~8 |
| `jwt-auth.guard.spec.ts` | Passes valid JWT, rejects missing/expired/invalid JWT | ~3 |
| `admin.guard.spec.ts` | Allows admin role, blocks non-admin, blocks missing user | ~3 |
| `ws-auth.guard.spec.ts` | Cookie-based auth, WS ticket auth (valid, expired, reused), legacy URL token fallback, no token rejection | ~6 |
| `auth.controller.spec.ts` | Login sets cookie, logout clears cookie, ws-ticket issuance and consumption, TOTP endpoints wired correctly | ~8 |
**Backend total: ~66 tests**
### Mocking Strategy
- **PrismaService:** Jest manual mock returning controlled data per test
- **EncryptionService:** Mock encrypt returns `v2:mock:...`, mock decrypt returns plaintext
- **JwtService:** Mock sign returns `mock-jwt-token`, mock verify returns payload
- **Argon2:** Real library (fast enough for unit tests, tests actual hashing behavior)
- **bcrypt:** Real library (needed to test migration path)
---
## Frontend (Vitest)
No test infrastructure exists today. Needs full setup.
### Infrastructure
- Install: `vitest`, `@vue/test-utils`, `@pinia/testing`, `happy-dom`
- Config: `frontend/vitest.config.ts` with happy-dom environment
- Global mock for `$fetch` (Nuxt auto-import) via setup file
- Global mock for `navigateTo` (Nuxt auto-import)
### Test Files
| File | Tests | Priority |
|------|-------|----------|
| `stores/auth.store.spec.ts` | Login success (stores user, no token in state), login TOTP flow, logout clears state + calls API, fetchProfile success/failure, getWsTicket, isAuthenticated/isAdmin getters | ~10 |
| `stores/connection.store.spec.ts` | fetchHosts, fetchTree, createHost, updateHost, deleteHost, group CRUD, no Authorization headers in requests | ~8 |
| `composables/useVault.spec.ts` | listKeys, importKey, deleteKey, listCredentials, createCredential, updateCredential, deleteCredential — all without Authorization headers | ~7 |
| `middleware/admin.spec.ts` | Redirects non-admin to /, allows admin through | ~3 |
| `plugins/auth.client.spec.ts` | Calls fetchProfile on init when user is null, skips when user exists | ~2 |
**Frontend total: ~30 tests**
---
## NPM Scripts
```json
// backend/package.json
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
// frontend/package.json
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage"
```
---
## Pre-commit Hook (Husky)
- Install `husky` + `lint-staged` at repo root
- Pre-commit runs: backend Jest (changed files) + frontend Vitest (changed files)
- Fast feedback — only tests related to changed files run on commit
---
## Execution Plan
1. **Agent 1:** Backend test infra (mock factories) + auth service tests + auth controller tests + guard tests (~37 tests)
2. **Agent 2:** Backend vault tests — encryption, credentials, SSH keys (~26 tests)
3. **Agent 3:** Frontend test infra (Vitest setup) + store tests + middleware + plugin tests (~30 tests)
4. **XO (me):** Wire npm scripts, verify all pass, add Husky pre-commit hook, final integration check
---
## Future (Level C — when ready)
- Component tests with Vue Test Utils (render + interaction)
- Integration tests against a real test PostgreSQL (Docker test container)
- Gateway tests (WebSocket mocking for terminal, SFTP, RDP)
- E2E with Playwright
- CI pipeline (Gitea Actions)

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wraith</title>
</head>
<body class="bg-[#0d1117] text-[#e0e0e0]">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -1,39 +0,0 @@
{
"name": "wraith-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build:dev": "vue-tsc --noEmit && vite build --minify false --mode development",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@wailsio/runtime": "latest",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2",
"naive-ui": "^2.40.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0",
"vite": "^6.0.0",
"vue-tsc": "^2.0.0"
}
}

View File

@ -1,30 +0,0 @@
<template>
<div class="h-screen w-screen bg-[var(--wraith-bg-primary)]">
<!-- Loading state -->
<div v-if="appStore.isLoading" class="h-full flex items-center justify-center">
<div class="text-center">
<h1 class="text-3xl font-bold text-[var(--wraith-accent-blue)]">WRAITH</h1>
<p class="text-[var(--wraith-text-secondary)] mt-2">Loading...</p>
</div>
</div>
<!-- Unlock screen -->
<UnlockLayout v-else-if="!appStore.isUnlocked" />
<!-- Main application -->
<MainLayout v-else />
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useAppStore } from "@/stores/app.store";
import UnlockLayout from "@/layouts/UnlockLayout.vue";
import MainLayout from "@/layouts/MainLayout.vue";
const appStore = useAppStore();
onMounted(() => {
appStore.checkFirstRun();
});
</script>

View File

@ -1,29 +0,0 @@
@import "tailwindcss";
:root {
--wraith-bg-primary: #0d1117;
--wraith-bg-secondary: #161b22;
--wraith-bg-tertiary: #21262d;
--wraith-border: #30363d;
--wraith-text-primary: #e0e0e0;
--wraith-text-secondary: #8b949e;
--wraith-text-muted: #484f58;
--wraith-accent-blue: #58a6ff;
--wraith-accent-green: #3fb950;
--wraith-accent-red: #f85149;
--wraith-accent-yellow: #e3b341;
}
body {
margin: 0;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
background: var(--wraith-bg-primary);
color: var(--wraith-text-primary);
overflow: hidden;
user-select: none;
}
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--wraith-bg-primary); }
::-webkit-scrollbar-thumb { background: var(--wraith-border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--wraith-text-muted); }

View File

@ -1,97 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50"
@click="close"
@contextmenu.prevent="close"
>
<div
ref="menuRef"
class="fixed bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl py-1 min-w-[160px] overflow-hidden"
:style="{ left: position.x + 'px', top: position.y + 'px' }"
@click.stop
>
<template v-for="(item, idx) in items" :key="idx">
<!-- Separator -->
<div v-if="item.separator" class="my-1 border-t border-[#30363d]" />
<!-- Menu item -->
<button
v-else
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-xs text-left transition-colors cursor-pointer"
:class="item.danger
? 'text-[var(--wraith-accent-red)] hover:bg-[var(--wraith-accent-red)]/10'
: 'text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)]'
"
@click="handleClick(item)"
>
<span v-if="item.icon" class="w-4 h-4 flex items-center justify-center shrink-0" v-html="item.icon" />
<span class="flex-1">{{ item.label }}</span>
<span v-if="item.shortcut" class="text-[10px] text-[var(--wraith-text-muted)]">{{ item.shortcut }}</span>
</button>
</template>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, nextTick } from "vue";
export interface ContextMenuItem {
label?: string;
icon?: string;
shortcut?: string;
danger?: boolean;
separator?: boolean;
action?: () => void;
}
const visible = ref(false);
const position = ref({ x: 0, y: 0 });
const items = ref<ContextMenuItem[]>([]);
const menuRef = ref<HTMLDivElement | null>(null);
function open(event: MouseEvent, menuItems: ContextMenuItem[]): void {
items.value = menuItems;
visible.value = true;
// Position at cursor, adjusting if near viewport edges
nextTick(() => {
const menu = menuRef.value;
if (!menu) {
position.value = { x: event.clientX, y: event.clientY };
return;
}
let x = event.clientX;
let y = event.clientY;
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth - 4;
}
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight - 4;
}
position.value = { x, y };
});
}
function close(): void {
visible.value = false;
}
function handleClick(item: ContextMenuItem): void {
if (item.action) {
item.action();
}
close();
}
defineExpose({ open, close, visible });
</script>

View File

@ -1,247 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center"
@click.self="close"
@keydown.esc="close"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="close" />
<!-- Dialog -->
<div class="relative w-full max-w-md bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[#30363d]">
<h3 class="text-sm font-semibold text-[var(--wraith-text-primary)]">Import Configuration</h3>
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@click="close"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.749.749 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.749.749 0 1 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<!-- Step 1: File selection -->
<template v-if="step === 'select'">
<p class="text-sm text-[var(--wraith-text-secondary)] mb-4">
Select a MobaXTerm <code class="text-[var(--wraith-accent-blue)]">.mobaconf</code> file to import connections and settings.
</p>
<div
class="border-2 border-dashed border-[#30363d] rounded-lg p-8 text-center hover:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
@click="selectFile"
@dragover.prevent
@drop.prevent="handleDrop"
>
<svg class="w-8 h-8 mx-auto text-[var(--wraith-text-muted)] mb-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14ZM11.78 4.72a.749.749 0 1 1-1.06 1.06L8.75 3.81V9.5a.75.75 0 0 1-1.5 0V3.81L5.28 5.78a.749.749 0 1 1-1.06-1.06l3.25-3.25a.749.749 0 0 1 1.06 0l3.25 3.25Z" />
</svg>
<p class="text-sm text-[var(--wraith-text-secondary)]">
Click to select or drag and drop
</p>
<p class="text-xs text-[var(--wraith-text-muted)] mt-1">
Supports .mobaconf files
</p>
</div>
<input
ref="fileInput"
type="file"
accept=".mobaconf"
class="hidden"
@change="handleFileSelect"
/>
</template>
<!-- Step 2: Preview -->
<template v-else-if="step === 'preview'">
<div class="space-y-3">
<p class="text-sm text-[var(--wraith-text-primary)] font-medium">
{{ fileName }}
</p>
<div class="grid grid-cols-3 gap-3">
<div class="bg-[#0d1117] rounded-lg p-3 text-center">
<div class="text-lg font-bold text-[var(--wraith-accent-blue)]">{{ preview.connections }}</div>
<div class="text-[10px] text-[var(--wraith-text-muted)] mt-0.5">Connections</div>
</div>
<div class="bg-[#0d1117] rounded-lg p-3 text-center">
<div class="text-lg font-bold text-[var(--wraith-accent-yellow)]">{{ preview.groups }}</div>
<div class="text-[10px] text-[var(--wraith-text-muted)] mt-0.5">Groups</div>
</div>
<div class="bg-[#0d1117] rounded-lg p-3 text-center">
<div class="text-lg font-bold text-[var(--wraith-accent-green)]">{{ preview.hostKeys }}</div>
<div class="text-[10px] text-[var(--wraith-text-muted)] mt-0.5">Host Keys</div>
</div>
</div>
<div v-if="preview.hasTheme" class="flex items-center gap-2 text-xs text-[var(--wraith-text-secondary)] bg-[#0d1117] rounded-lg px-3 py-2">
<svg class="w-3.5 h-3.5 text-[var(--wraith-accent-green)]" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
</svg>
Terminal theme included
</div>
<p class="text-xs text-[var(--wraith-text-muted)]">
Passwords are not imported (MobaXTerm uses proprietary encryption).
</p>
</div>
</template>
<!-- Step 3: Complete -->
<template v-else-if="step === 'complete'">
<div class="text-center py-4">
<svg class="w-12 h-12 mx-auto text-[var(--wraith-accent-green)] mb-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0l4.5-4.5Z" />
</svg>
<h4 class="text-sm font-semibold text-[var(--wraith-text-primary)] mb-1">Import Complete</h4>
<p class="text-xs text-[var(--wraith-text-secondary)]">
Successfully imported {{ preview.connections }} connections and {{ preview.groups }} groups.
</p>
</div>
</template>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-[#30363d]">
<button
v-if="step !== 'complete'"
class="px-3 py-1.5 text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] rounded border border-[#30363d] hover:bg-[#30363d] transition-colors cursor-pointer"
@click="close"
>
Cancel
</button>
<button
v-if="step === 'preview'"
class="px-3 py-1.5 text-xs text-white bg-[#238636] hover:bg-[#2ea043] rounded transition-colors cursor-pointer"
@click="doImport"
>
Import
</button>
<button
v-if="step === 'complete'"
class="px-3 py-1.5 text-xs text-white bg-[#1f6feb] hover:bg-[#388bfd] rounded transition-colors cursor-pointer"
@click="close"
>
Done
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from "vue";
type Step = "select" | "preview" | "complete";
const visible = ref(false);
const step = ref<Step>("select");
const fileName = ref("");
const fileInput = ref<HTMLInputElement | null>(null);
const preview = ref({
connections: 0,
groups: 0,
hostKeys: 0,
hasTheme: false,
});
function open(): void {
visible.value = true;
step.value = "select";
fileName.value = "";
}
function close(): void {
visible.value = false;
}
function selectFile(): void {
fileInput.value?.click();
}
function handleFileSelect(event: Event): void {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
processFile(file);
}
}
function handleDrop(event: DragEvent): void {
const file = event.dataTransfer?.files?.[0];
if (file) {
processFile(file);
}
}
async function processFile(file: File): Promise<void> {
fileName.value = file.name;
// TODO: Replace with Wails binding ImporterService.Preview(fileData)
// For now, read and mock-parse the file to show a preview
const text = await file.text();
// Simple mock parse to count items
const lines = text.split("\n");
let groups = 0;
let connections = 0;
let hostKeys = 0;
let hasTheme = false;
let inBookmarks = false;
let inHostKeys = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("[Bookmarks")) {
inBookmarks = true;
inHostKeys = false;
continue;
}
if (trimmed === "[SSH_Hostkeys]") {
inBookmarks = false;
inHostKeys = true;
continue;
}
if (trimmed === "[Colors]") {
hasTheme = true;
inBookmarks = false;
inHostKeys = false;
continue;
}
if (trimmed.startsWith("[")) {
inBookmarks = false;
inHostKeys = false;
continue;
}
if (inBookmarks) {
if (trimmed.startsWith("SubRep=") && trimmed !== "SubRep=") {
groups++;
} else if (trimmed.includes("#109#") || trimmed.includes("#91#")) {
connections++;
}
}
if (inHostKeys && trimmed.includes("@") && trimmed.includes("=")) {
hostKeys++;
}
}
preview.value = { connections, groups, hostKeys, hasTheme };
step.value = "preview";
}
function doImport(): void {
// TODO: Replace with Wails binding ImporterService.Import(fileData)
// For now, just show success
step.value = "complete";
}
defineExpose({ open, close, visible });
</script>

View File

@ -1,292 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center"
@click.self="close"
@keydown.esc="close"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="close" />
<!-- Dialog -->
<div class="relative w-full max-w-md bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[#30363d]">
<h3 class="text-sm font-semibold text-[var(--wraith-text-primary)]">
{{ isEditing ? "Edit Connection" : "New Connection" }}
</h3>
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@click="close"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.749.749 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.749.749 0 1 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-4 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
<!-- Name -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Name</label>
<input
v-model="form.name"
type="text"
placeholder="My Server"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<!-- Hostname & Port -->
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Hostname</label>
<input
v-model="form.hostname"
type="text"
placeholder="192.168.1.1"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<div class="w-24">
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Port</label>
<input
v-model.number="form.port"
type="number"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
</div>
<!-- Protocol -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Protocol</label>
<div class="flex gap-2">
<button
class="flex-1 py-2 text-sm rounded border transition-colors cursor-pointer"
:class="form.protocol === 'ssh'
? 'bg-[#3fb950]/10 border-[#3fb950] text-[#3fb950]'
: 'bg-[#0d1117] border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="setProtocol('ssh')"
>
SSH
</button>
<button
class="flex-1 py-2 text-sm rounded border transition-colors cursor-pointer"
:class="form.protocol === 'rdp'
? 'bg-[#1f6feb]/10 border-[#1f6feb] text-[#1f6feb]'
: 'bg-[#0d1117] border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
"
@click="setProtocol('rdp')"
>
RDP
</button>
</div>
</div>
<!-- Group -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Group</label>
<select
v-model="form.groupId"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
>
<option :value="null">No Group</option>
<option v-for="group in connectionStore.groups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
</select>
</div>
<!-- Tags -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Tags (comma-separated)</label>
<input
v-model="tagsInput"
type="text"
placeholder="Prod, Linux, Web"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<!-- Color -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Color Label</label>
<div class="flex gap-2">
<button
v-for="color in colorOptions"
:key="color.value"
class="w-6 h-6 rounded-full border-2 transition-transform cursor-pointer hover:scale-110"
:class="form.color === color.value ? 'border-white scale-110' : 'border-transparent'"
:style="{ backgroundColor: color.hex }"
:title="color.label"
@click="form.color = color.value"
/>
</div>
</div>
<!-- Notes -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Notes</label>
<textarea
v-model="form.notes"
rows="3"
placeholder="Optional notes about this connection..."
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] 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"
/>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-[#30363d]">
<button
class="px-3 py-1.5 text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] rounded border border-[#30363d] hover:bg-[#30363d] transition-colors cursor-pointer"
@click="close"
>
Cancel
</button>
<button
class="px-3 py-1.5 text-xs text-white bg-[#238636] hover:bg-[#2ea043] rounded transition-colors cursor-pointer"
:class="{ 'opacity-50 cursor-not-allowed': !isValid }"
:disabled="!isValid"
@click="save"
>
{{ isEditing ? "Save Changes" : "Create" }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useConnectionStore, type Connection } from "@/stores/connection.store";
interface ConnectionForm {
name: string;
hostname: string;
port: number;
protocol: "ssh" | "rdp";
groupId: number | null;
color: string;
notes: string;
}
const connectionStore = useConnectionStore();
const visible = ref(false);
const isEditing = ref(false);
const editingId = ref<number | null>(null);
const tagsInput = ref("");
const form = ref<ConnectionForm>({
name: "",
hostname: "",
port: 22,
protocol: "ssh",
groupId: null,
color: "",
notes: "",
});
const colorOptions = [
{ value: "", label: "None", hex: "#30363d" },
{ value: "red", label: "Red", hex: "#f85149" },
{ value: "orange", label: "Orange", hex: "#d29922" },
{ value: "green", label: "Green", hex: "#3fb950" },
{ value: "blue", label: "Blue", hex: "#58a6ff" },
{ value: "purple", label: "Purple", hex: "#bc8cff" },
{ value: "pink", label: "Pink", hex: "#f778ba" },
];
const isValid = computed(() => {
return form.value.name.trim() !== "" && form.value.hostname.trim() !== "" && form.value.port > 0;
});
function setProtocol(protocol: "ssh" | "rdp"): void {
form.value.protocol = protocol;
if (protocol === "ssh" && form.value.port === 3389) {
form.value.port = 22;
} else if (protocol === "rdp" && form.value.port === 22) {
form.value.port = 3389;
}
}
function openNew(groupId?: number): void {
isEditing.value = false;
editingId.value = null;
form.value = {
name: "",
hostname: "",
port: 22,
protocol: "ssh",
groupId: groupId ?? null,
color: "",
notes: "",
};
tagsInput.value = "";
visible.value = true;
}
function openEdit(conn: Connection): void {
isEditing.value = true;
editingId.value = conn.id;
form.value = {
name: conn.name,
hostname: conn.hostname,
port: conn.port,
protocol: conn.protocol,
groupId: conn.groupId,
color: "",
notes: "",
};
tagsInput.value = conn.tags?.join(", ") ?? "";
visible.value = true;
}
function close(): void {
visible.value = false;
}
function save(): void {
if (!isValid.value) return;
const tags = tagsInput.value
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
if (isEditing.value && editingId.value !== null) {
// TODO: Replace with Wails binding ConnectionService.UpdateConnection(id, input)
const conn = connectionStore.connections.find((c) => c.id === editingId.value);
if (conn) {
conn.name = form.value.name;
conn.hostname = form.value.hostname;
conn.port = form.value.port;
conn.protocol = form.value.protocol;
conn.groupId = form.value.groupId ?? conn.groupId;
conn.tags = tags;
}
} else {
// TODO: Replace with Wails binding ConnectionService.CreateConnection(input)
const newId = Math.max(...connectionStore.connections.map((c) => c.id), 0) + 1;
connectionStore.connections.push({
id: newId,
name: form.value.name,
hostname: form.value.hostname,
port: form.value.port,
protocol: form.value.protocol,
groupId: form.value.groupId ?? 1,
tags,
});
}
close();
}
defineExpose({ openNew, openEdit, close, visible });
</script>

View File

@ -1,382 +0,0 @@
<template>
<div class="rdp-container" ref="containerRef">
<!-- Toolbar -->
<div class="rdp-toolbar">
<div class="rdp-toolbar-left">
<span class="rdp-toolbar-label">RDP</span>
<span class="rdp-toolbar-session">{{ sessionId }}</span>
</div>
<div class="rdp-toolbar-right">
<button
class="rdp-toolbar-btn"
:class="{ active: keyboardGrabbed }"
title="Toggle keyboard capture"
@click="handleToggleKeyboard"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4zm1 0v7h12V4H2z"/>
<path d="M3 6h2v1H3V6zm3 0h2v1H6V6zm3 0h2v1H9V6zm3 0h1v1h-1V6zM3 8h1v1H3V8zm2 0h6v1H5V8zm7 0h1v1h-1V8z"/>
</svg>
</button>
<button
class="rdp-toolbar-btn"
:class="{ active: clipboardSync }"
title="Toggle clipboard sync"
@click="handleToggleClipboard"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3z"/>
</svg>
</button>
<button
class="rdp-toolbar-btn"
title="Fullscreen"
@click="handleFullscreen"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zM.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z"/>
</svg>
</button>
</div>
</div>
<!-- Canvas -->
<div class="rdp-canvas-wrapper" ref="canvasWrapper">
<canvas
ref="canvasRef"
class="rdp-canvas"
tabindex="0"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
@mousemove="handleMouseMove"
@wheel.prevent="handleWheel"
@contextmenu.prevent
@keydown.prevent="handleKeyDown"
@keyup.prevent="handleKeyUp"
/>
</div>
<!-- Connection status overlay -->
<div v-if="!connected" class="rdp-overlay">
<div class="rdp-overlay-content">
<div class="rdp-spinner" />
<p>Connecting to RDP session...</p>
<p class="rdp-overlay-sub">Session: {{ sessionId }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import { useRdp, MouseFlag } from "@/composables/useRdp";
const props = defineProps<{
sessionId: string;
isActive: boolean;
width?: number;
height?: number;
}>();
const containerRef = ref<HTMLElement | null>(null);
const canvasWrapper = ref<HTMLElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const rdpWidth = props.width ?? 1920;
const rdpHeight = props.height ?? 1080;
const {
connected,
keyboardGrabbed,
clipboardSync,
sendMouse,
sendKey,
startFrameLoop,
stopFrameLoop,
toggleKeyboardGrab,
toggleClipboardSync,
} = useRdp();
/**
* Convert canvas-relative mouse coordinates to RDP coordinates,
* accounting for CSS scaling of the canvas.
*/
function toRdpCoords(
e: MouseEvent,
): { x: number; y: number } | null {
const canvas = canvasRef.value;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const scaleX = rdpWidth / rect.width;
const scaleY = rdpHeight / rect.height;
return {
x: Math.floor((e.clientX - rect.left) * scaleX),
y: Math.floor((e.clientY - rect.top) * scaleY),
};
}
function handleMouseDown(e: MouseEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
let buttonFlag = 0;
switch (e.button) {
case 0:
buttonFlag = MouseFlag.Button1;
break;
case 1:
buttonFlag = MouseFlag.Button3;
break; // middle
case 2:
buttonFlag = MouseFlag.Button2;
break;
}
sendMouse(
props.sessionId,
coords.x,
coords.y,
buttonFlag | MouseFlag.Down,
);
}
function handleMouseUp(e: MouseEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
let buttonFlag = 0;
switch (e.button) {
case 0:
buttonFlag = MouseFlag.Button1;
break;
case 1:
buttonFlag = MouseFlag.Button3;
break;
case 2:
buttonFlag = MouseFlag.Button2;
break;
}
sendMouse(props.sessionId, coords.x, coords.y, buttonFlag);
}
function handleMouseMove(e: MouseEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
sendMouse(props.sessionId, coords.x, coords.y, MouseFlag.Move);
}
function handleWheel(e: WheelEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
let flags = MouseFlag.Wheel;
if (e.deltaY > 0) {
flags |= MouseFlag.WheelNeg;
}
sendMouse(props.sessionId, coords.x, coords.y, flags);
}
function handleKeyDown(e: KeyboardEvent): void {
if (!keyboardGrabbed.value) return;
sendKey(props.sessionId, e.code, true);
}
function handleKeyUp(e: KeyboardEvent): void {
if (!keyboardGrabbed.value) return;
sendKey(props.sessionId, e.code, false);
}
function handleToggleKeyboard(): void {
toggleKeyboardGrab();
// Focus canvas when grabbing keyboard
if (keyboardGrabbed.value && canvasRef.value) {
canvasRef.value.focus();
}
}
function handleToggleClipboard(): void {
toggleClipboardSync();
}
function handleFullscreen(): void {
const wrapper = canvasWrapper.value;
if (!wrapper) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
wrapper.requestFullscreen();
}
}
onMounted(() => {
if (canvasRef.value) {
startFrameLoop(
props.sessionId,
canvasRef.value,
rdpWidth,
rdpHeight,
);
}
});
onBeforeUnmount(() => {
stopFrameLoop();
});
// Focus canvas when this tab becomes active
watch(
() => props.isActive,
(active) => {
if (active && keyboardGrabbed.value && canvasRef.value) {
setTimeout(() => {
canvasRef.value?.focus();
}, 0);
}
},
);
</script>
<style scoped>
.rdp-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--wraith-bg-primary, #0d1117);
position: relative;
}
.rdp-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 8px;
background: var(--wraith-bg-secondary, #161b22);
border-bottom: 1px solid var(--wraith-border, #30363d);
flex-shrink: 0;
}
.rdp-toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.rdp-toolbar-label {
font-size: 11px;
font-weight: 600;
color: var(--wraith-accent, #58a6ff);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rdp-toolbar-session {
font-size: 11px;
color: var(--wraith-text-muted, #484f58);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rdp-toolbar-right {
display: flex;
align-items: center;
gap: 2px;
}
.rdp-toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--wraith-text-secondary, #8b949e);
cursor: pointer;
transition: all 0.15s ease;
}
.rdp-toolbar-btn:hover {
background: var(--wraith-bg-tertiary, #21262d);
color: var(--wraith-text-primary, #e6edf3);
}
.rdp-toolbar-btn.active {
background: var(--wraith-accent, #58a6ff);
color: #ffffff;
}
.rdp-canvas-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
min-height: 0;
}
.rdp-canvas {
max-width: 100%;
max-height: 100%;
object-fit: contain;
cursor: default;
outline: none;
image-rendering: auto;
}
.rdp-canvas:focus {
outline: 2px solid var(--wraith-accent, #58a6ff);
outline-offset: -2px;
}
.rdp-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(13, 17, 23, 0.85);
z-index: 10;
}
.rdp-overlay-content {
text-align: center;
color: var(--wraith-text-secondary, #8b949e);
}
.rdp-overlay-content p {
margin: 8px 0;
font-size: 14px;
}
.rdp-overlay-sub {
font-size: 12px !important;
color: var(--wraith-text-muted, #484f58);
}
.rdp-spinner {
width: 32px;
height: 32px;
margin: 0 auto 16px;
border: 3px solid var(--wraith-border, #30363d);
border-top-color: var(--wraith-accent, #58a6ff);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -1,61 +0,0 @@
<template>
<div class="flex-1 flex flex-col bg-[var(--wraith-bg-primary)] min-h-0 relative">
<!-- Terminal views v-show keeps them alive across tab switches -->
<div
v-for="session in sshSessions"
:key="session.id"
v-show="session.id === sessionStore.activeSessionId"
class="absolute inset-0"
>
<TerminalView
:session-id="session.id"
:is-active="session.id === sessionStore.activeSessionId"
/>
</div>
<!-- RDP views v-show keeps them alive across tab switches -->
<div
v-for="session in rdpSessions"
:key="session.id"
v-show="session.id === sessionStore.activeSessionId"
class="absolute inset-0"
>
<RdpView
:session-id="session.id"
:is-active="session.id === sessionStore.activeSessionId"
/>
</div>
<!-- No session placeholder -->
<div
v-if="!sessionStore.activeSession"
class="flex-1 flex items-center justify-center"
>
<div class="text-center">
<p class="text-[var(--wraith-text-muted)] text-sm">
No active session
</p>
<p class="text-[var(--wraith-text-muted)] text-xs mt-1">
Double-click a connection to start a session
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useSessionStore } from "@/stores/session.store";
import TerminalView from "@/components/terminal/TerminalView.vue";
import RdpView from "@/components/rdp/RdpView.vue";
const sessionStore = useSessionStore();
const sshSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "ssh"),
);
const rdpSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "rdp"),
);
</script>

View File

@ -1,113 +0,0 @@
<template>
<div class="flex items-center bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] h-9 shrink-0">
<!-- Tabs -->
<div class="flex items-center overflow-x-auto min-w-0">
<button
v-for="session in sessionStore.sessions"
:key="session.id"
class="group flex items-center gap-2 px-3 h-9 text-xs whitespace-nowrap border-r border-[var(--wraith-border)] transition-all duration-500 cursor-pointer shrink-0"
:class="[
session.id === sessionStore.activeSessionId
? '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)]',
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
]"
@click="sessionStore.activateSession(session.id)"
>
<!-- Protocol icon -->
<span class="shrink-0">
<!-- SSH terminal icon -->
<svg
v-if="session.protocol === 'ssh'"
class="w-3.5 h-3.5 text-[#3fb950]"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM7.25 8a.749.749 0 0 1-.22.53l-2.25 2.25a.749.749 0 1 1-1.06-1.06L5.44 8 3.72 6.28a.749.749 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm1.5 1.5h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5Z" />
</svg>
<!-- RDP monitor icon -->
<svg
v-else
class="w-3.5 h-3.5 text-[#1f6feb]"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M1.75 2.5h12.5a.25.25 0 0 1 .25.25v7.5a.25.25 0 0 1-.25.25H1.75a.25.25 0 0 1-.25-.25v-7.5a.25.25 0 0 1 .25-.25ZM14.25 1H1.75A1.75 1.75 0 0 0 0 2.75v7.5C0 11.216.784 12 1.75 12h4.388l-.533 1.5H4a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5h-1.605l-.533-1.5h4.388A1.75 1.75 0 0 0 16 10.25v-7.5A1.75 1.75 0 0 0 14.25 1ZM9.112 13.5H6.888l.533-1.5h1.158l.533 1.5Z" />
</svg>
</span>
<span>{{ session.name }}</span>
<!-- Environment tag badges -->
<template v-if="getSessionTags(session).length > 0">
<span
v-for="tag in getSessionTags(session)"
:key="tag"
class="px-1 py-0.5 text-[9px] font-semibold rounded leading-none"
:class="tagClass(tag)"
>
{{ tag }}
</span>
</template>
<!-- Close button -->
<span
class="ml-1 opacity-0 group-hover:opacity-100 hover:text-[var(--wraith-accent-red)] transition-opacity"
@click.stop="sessionStore.closeSession(session.id)"
>
&times;
</span>
</button>
</div>
<!-- New tab button -->
<button
class="flex items-center justify-center w-9 h-9 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer shrink-0"
title="New session"
>
+
</button>
</div>
</template>
<script setup lang="ts">
import { useSessionStore, type Session } from "@/stores/session.store";
import { useConnectionStore } from "@/stores/connection.store";
const sessionStore = useSessionStore();
const connectionStore = useConnectionStore();
/** Get tags for a session's underlying connection. */
function getSessionTags(session: Session): string[] {
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
return conn?.tags ?? [];
}
/** Check if the connection for this session uses the root user. */
function isRootUser(session: Session): boolean {
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
if (!conn) return false;
// TODO: Get actual username from the credential or session
// For now, check mock data root user detection will come from the session/credential store
return false;
}
/** Return Tailwind classes for environment tag badges. */
function tagClass(tag: string): string {
const t = tag.toUpperCase();
if (t === "PROD" || t === "PRODUCTION") {
return "bg-[#da3633]/20 text-[#f85149]";
}
if (t === "DEV" || t === "DEVELOPMENT") {
return "bg-[#238636]/20 text-[#3fb950]";
}
if (t === "STAGING" || t === "STG") {
return "bg-[#9e6a03]/20 text-[#d29922]";
}
if (t === "TEST" || t === "QA") {
return "bg-[#1f6feb]/20 text-[#58a6ff]";
}
// Default for other tags
return "bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]";
}
</script>

View File

@ -1,165 +0,0 @@
<template>
<div class="flex flex-col h-full text-xs">
<!-- Path bar -->
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-[var(--wraith-border)] bg-[var(--wraith-bg-tertiary)]">
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer shrink-0"
title="Go up"
@click="goUp"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.22 9.78a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1-1.06 1.06L8 6.06 4.28 9.78a.75.75 0 0 1-1.06 0z" />
</svg>
</button>
<span class="text-[var(--wraith-text-secondary)] truncate font-mono text-[10px]">
{{ currentPath }}
</span>
</div>
<!-- Toolbar -->
<div class="flex items-center gap-1 px-2 py-1 border-b border-[var(--wraith-border)]">
<button
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
title="Upload file"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14H2.75z" />
<path d="M11.78 4.72a.75.75 0 0 1-1.06 1.06L8.75 3.81V9.5a.75.75 0 0 1-1.5 0V3.81L5.28 5.78a.75.75 0 0 1-1.06-1.06l3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25z" />
</svg>
</button>
<button
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
title="Download file"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14H2.75z" />
<path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969z" />
</svg>
</button>
<button
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-yellow)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
title="New folder"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75zM8.75 8v1.75a.75.75 0 0 1-1.5 0V8H5.5a.75.75 0 0 1 0-1.5h1.75V4.75a.75.75 0 0 1 1.5 0V6.5h1.75a.75.75 0 0 1 0 1.5H8.75z" />
</svg>
</button>
<button
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
title="Refresh"
@click="refresh"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.001 7.001 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.501 5.501 0 0 0 8 2.5zM1.705 8.005a.75.75 0 0 1 .834.656 5.501 5.501 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.001 7.001 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834z" />
</svg>
</button>
<button
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
title="Delete"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM6.5 1.75v1.25h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25zM4.997 6.178a.75.75 0 1 0-1.493.144l.685 7.107A2.25 2.25 0 0 0 6.427 15.5h3.146a2.25 2.25 0 0 0 2.238-2.071l.685-7.107a.75.75 0 1 0-1.493-.144l-.685 7.107a.75.75 0 0 1-.746.715H6.427a.75.75 0 0 1-.746-.715l-.684-7.107z" />
</svg>
</button>
</div>
<!-- File list -->
<div class="flex-1 overflow-y-auto">
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<span class="text-[var(--wraith-text-muted)]">Loading...</span>
</div>
<!-- Empty -->
<div v-else-if="entries.length === 0" class="flex items-center justify-center py-8">
<span class="text-[var(--wraith-text-muted)]">Empty directory</span>
</div>
<!-- Entries -->
<template v-else>
<button
v-for="entry in entries"
:key="entry.path"
class="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer group"
@dblclick="handleEntryDblClick(entry)"
>
<!-- Icon -->
<svg
v-if="entry.isDir"
class="w-3.5 h-3.5 text-[var(--wraith-accent-yellow)] shrink-0"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z" />
</svg>
<svg
v-else
class="w-3.5 h-3.5 text-[var(--wraith-text-muted)] shrink-0"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M3.75 1.5a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25V6H9.75A1.75 1.75 0 0 1 8 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25V1.75z" />
</svg>
<!-- Name -->
<span class="text-[var(--wraith-text-primary)] truncate">{{ entry.name }}</span>
<!-- Size (files only) -->
<span
v-if="!entry.isDir"
class="ml-auto text-[var(--wraith-text-muted)] text-[10px] shrink-0"
>
{{ humanizeSize(entry.size) }}
</span>
<!-- Modified date -->
<span class="text-[var(--wraith-text-muted)] text-[10px] shrink-0 w-[68px] text-right">
{{ entry.modTime }}
</span>
</button>
</template>
</div>
<!-- Follow terminal toggle -->
<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">
<input
v-model="followTerminal"
type="checkbox"
class="w-3 h-3 accent-[var(--wraith-accent-blue)] cursor-pointer"
/>
<span>Follow terminal folder</span>
</label>
</div>
</div>
</template>
<script setup lang="ts">
import { useSftp, type FileEntry } from "@/composables/useSftp";
const props = defineProps<{
sessionId: string;
}>();
const emit = defineEmits<{
openFile: [entry: FileEntry];
}>();
const { currentPath, entries, isLoading, followTerminal, navigateTo, goUp, refresh } = useSftp(props.sessionId);
function handleEntryDblClick(entry: FileEntry): void {
if (entry.isDir) {
navigateTo(entry.path);
} else {
emit("openFile", entry);
}
}
function humanizeSize(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0);
return `${size} ${units[i]}`;
}
</script>

View File

@ -1,45 +0,0 @@
<template>
<div
ref="containerRef"
class="terminal-container"
@focus="handleFocus"
/>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { useTerminal } from "@/composables/useTerminal";
import "@/assets/css/terminal.css";
const props = defineProps<{
sessionId: string;
isActive: boolean;
}>();
const containerRef = ref<HTMLElement | null>(null);
const { terminal, mount, fit } = useTerminal(props.sessionId);
onMounted(() => {
if (containerRef.value) {
mount(containerRef.value);
}
});
// Re-fit and focus terminal when this tab becomes active
watch(
() => props.isActive,
(active) => {
if (active) {
// nextTick is not needed fit and focus happen after the DOM update
setTimeout(() => {
fit();
terminal.focus();
}, 0);
}
},
);
function handleFocus(): void {
terminal.focus();
}
</script>

View File

@ -1,379 +0,0 @@
import { ref, onBeforeUnmount } from "vue";
/**
* RDP mouse event flags match the Go constants in internal/rdp/input.go
*/
export const MouseFlag = {
Move: 0x0800,
Button1: 0x1000, // Left
Button2: 0x2000, // Right
Button3: 0x4000, // Middle
Down: 0x8000,
Wheel: 0x0200,
WheelNeg: 0x0100,
HWheel: 0x0400,
} as const;
/**
* JavaScript KeyboardEvent.code RDP scancode mapping.
* Mirrors the Go ScancodeMap in internal/rdp/input.go.
*/
export const ScancodeMap: Record<string, number> = {
// Row 0: Escape + Function keys
Escape: 0x0001,
F1: 0x003b,
F2: 0x003c,
F3: 0x003d,
F4: 0x003e,
F5: 0x003f,
F6: 0x0040,
F7: 0x0041,
F8: 0x0042,
F9: 0x0043,
F10: 0x0044,
F11: 0x0057,
F12: 0x0058,
// Row 1: Number row
Backquote: 0x0029,
Digit1: 0x0002,
Digit2: 0x0003,
Digit3: 0x0004,
Digit4: 0x0005,
Digit5: 0x0006,
Digit6: 0x0007,
Digit7: 0x0008,
Digit8: 0x0009,
Digit9: 0x000a,
Digit0: 0x000b,
Minus: 0x000c,
Equal: 0x000d,
Backspace: 0x000e,
// Row 2: QWERTY
Tab: 0x000f,
KeyQ: 0x0010,
KeyW: 0x0011,
KeyE: 0x0012,
KeyR: 0x0013,
KeyT: 0x0014,
KeyY: 0x0015,
KeyU: 0x0016,
KeyI: 0x0017,
KeyO: 0x0018,
KeyP: 0x0019,
BracketLeft: 0x001a,
BracketRight: 0x001b,
Backslash: 0x002b,
// Row 3: Home row
CapsLock: 0x003a,
KeyA: 0x001e,
KeyS: 0x001f,
KeyD: 0x0020,
KeyF: 0x0021,
KeyG: 0x0022,
KeyH: 0x0023,
KeyJ: 0x0024,
KeyK: 0x0025,
KeyL: 0x0026,
Semicolon: 0x0027,
Quote: 0x0028,
Enter: 0x001c,
// Row 4: Bottom row
ShiftLeft: 0x002a,
KeyZ: 0x002c,
KeyX: 0x002d,
KeyC: 0x002e,
KeyV: 0x002f,
KeyB: 0x0030,
KeyN: 0x0031,
KeyM: 0x0032,
Comma: 0x0033,
Period: 0x0034,
Slash: 0x0035,
ShiftRight: 0x0036,
// Row 5: Bottom modifiers + space
ControlLeft: 0x001d,
MetaLeft: 0xe05b,
AltLeft: 0x0038,
Space: 0x0039,
AltRight: 0xe038,
MetaRight: 0xe05c,
ContextMenu: 0xe05d,
ControlRight: 0xe01d,
// Navigation cluster
PrintScreen: 0xe037,
ScrollLock: 0x0046,
Pause: 0x0045,
Insert: 0xe052,
Home: 0xe047,
PageUp: 0xe049,
Delete: 0xe053,
End: 0xe04f,
PageDown: 0xe051,
// Arrow keys
ArrowUp: 0xe048,
ArrowLeft: 0xe04b,
ArrowDown: 0xe050,
ArrowRight: 0xe04d,
// Numpad
NumLock: 0x0045,
NumpadDivide: 0xe035,
NumpadMultiply: 0x0037,
NumpadSubtract: 0x004a,
Numpad7: 0x0047,
Numpad8: 0x0048,
Numpad9: 0x0049,
NumpadAdd: 0x004e,
Numpad4: 0x004b,
Numpad5: 0x004c,
Numpad6: 0x004d,
Numpad1: 0x004f,
Numpad2: 0x0050,
Numpad3: 0x0051,
NumpadEnter: 0xe01c,
Numpad0: 0x0052,
NumpadDecimal: 0x0053,
};
/**
* Look up the RDP scancode for a JS KeyboardEvent.code string.
*/
export function jsKeyToScancode(code: string): number | null {
return ScancodeMap[code] ?? null;
}
export interface UseRdpReturn {
/** Whether the RDP session is connected (mock: always true after init) */
connected: ReturnType<typeof ref<boolean>>;
/** Whether keyboard capture is enabled */
keyboardGrabbed: ReturnType<typeof ref<boolean>>;
/** Whether clipboard sync is enabled */
clipboardSync: ReturnType<typeof ref<boolean>>;
/** Fetch the current frame as RGBA ImageData */
fetchFrame: (sessionId: string) => Promise<ImageData | null>;
/** Send a mouse event to the backend */
sendMouse: (
sessionId: string,
x: number,
y: number,
flags: number,
) => void;
/** Send a key event to the backend */
sendKey: (sessionId: string, code: string, pressed: boolean) => void;
/** Send clipboard text to the remote session */
sendClipboard: (sessionId: string, text: string) => void;
/** Start the frame rendering loop */
startFrameLoop: (
sessionId: string,
canvas: HTMLCanvasElement,
width: number,
height: number,
) => void;
/** Stop the frame rendering loop */
stopFrameLoop: () => void;
/** Toggle keyboard grab */
toggleKeyboardGrab: () => void;
/** Toggle clipboard sync */
toggleClipboardSync: () => void;
}
/**
* Composable that manages an RDP session's rendering and input.
*
* All backend calls are currently stubs that will be replaced with
* Wails bindings once the Go RDP service is wired up.
*/
export function useRdp(): UseRdpReturn {
const connected = ref(false);
const keyboardGrabbed = ref(false);
const clipboardSync = ref(false);
let animFrameId: number | null = null;
let frameCount = 0;
/**
* Fetch the current frame from the backend.
* TODO: Replace with Wails binding RDPService.GetFrame(sessionId)
* Mock: generates a gradient test pattern.
*/
async function fetchFrame(
sessionId: string,
width = 1920,
height = 1080,
): Promise<ImageData | null> {
void sessionId;
// Mock: generate a test frame with animated gradient
const imageData = new ImageData(width, height);
const data = imageData.data;
const t = Date.now() / 1000;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
const nx = x / width;
const ny = y / height;
const diag = (nx + ny) / 2;
data[i + 0] = Math.floor(20 + diag * 40); // R
data[i + 1] = Math.floor(25 + (1 - diag) * 30); // G
data[i + 2] = Math.floor(80 + diag * 100); // B
data[i + 3] = 255; // A
// Grid lines every 100px
if (x % 100 === 0 || y % 100 === 0) {
data[i + 0] = Math.min(data[i + 0] + 20, 255);
data[i + 1] = Math.min(data[i + 1] + 20, 255);
data[i + 2] = Math.min(data[i + 2] + 20, 255);
}
}
}
// Animated pulsing circle at center
const cx = width / 2;
const cy = height / 2;
const radius = 40 + 20 * Math.sin(t * 2);
for (let dy = -70; dy <= 70; dy++) {
for (let dx = -70; dx <= 70; dx++) {
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= radius && dist >= radius - 4) {
const px = Math.floor(cx + dx);
const py = Math.floor(cy + dy);
if (px >= 0 && px < width && py >= 0 && py < height) {
const i = (py * width + px) * 4;
data[i + 0] = 88;
data[i + 1] = 166;
data[i + 2] = 255;
data[i + 3] = 255;
}
}
}
}
return imageData;
}
/**
* Send a mouse event.
* TODO: Replace with Wails binding RDPService.SendMouse(sessionId, x, y, flags)
*/
function sendMouse(
sessionId: string,
x: number,
y: number,
flags: number,
): void {
void sessionId;
void x;
void y;
void flags;
// Mock: no-op — will call Wails binding when wired
}
/**
* Send a key event, mapping JS code to RDP scancode.
* TODO: Replace with Wails binding RDPService.SendKey(sessionId, scancode, pressed)
*/
function sendKey(
sessionId: string,
code: string,
pressed: boolean,
): void {
const scancode = jsKeyToScancode(code);
if (scancode === null) return;
void sessionId;
void pressed;
// Mock: no-op — will call Wails binding when wired
}
/**
* Send clipboard text to the remote session.
* TODO: Replace with Wails binding RDPService.SendClipboard(sessionId, text)
*/
function sendClipboard(sessionId: string, text: string): void {
void sessionId;
void text;
// Mock: no-op
}
/**
* Start the rendering loop. Fetches frames and draws them on the canvas
* using requestAnimationFrame.
*/
function startFrameLoop(
sessionId: string,
canvas: HTMLCanvasElement,
width: number,
height: number,
): void {
connected.value = true;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = width;
canvas.height = height;
function renderLoop(): void {
frameCount++;
// Throttle to ~30fps (skip every other frame at 60fps rAF)
if (frameCount % 2 === 0) {
fetchFrame(sessionId, width, height).then((imageData) => {
if (imageData && ctx) {
ctx.putImageData(imageData, 0, 0);
}
});
}
animFrameId = requestAnimationFrame(renderLoop);
}
animFrameId = requestAnimationFrame(renderLoop);
}
/**
* Stop the rendering loop.
*/
function stopFrameLoop(): void {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId);
animFrameId = null;
}
connected.value = false;
}
function toggleKeyboardGrab(): void {
keyboardGrabbed.value = !keyboardGrabbed.value;
}
function toggleClipboardSync(): void {
clipboardSync.value = !clipboardSync.value;
}
onBeforeUnmount(() => {
stopFrameLoop();
});
return {
connected,
keyboardGrabbed,
clipboardSync,
fetchFrame,
sendMouse,
sendKey,
sendClipboard,
startFrameLoop,
stopFrameLoop,
toggleKeyboardGrab,
toggleClipboardSync,
};
}

View File

@ -1,100 +0,0 @@
import { ref, type Ref } from "vue";
export interface FileEntry {
name: string;
path: string;
size: number;
isDir: boolean;
permissions: string;
modTime: string;
}
export interface UseSftpReturn {
currentPath: Ref<string>;
entries: Ref<FileEntry[]>;
isLoading: Ref<boolean>;
followTerminal: Ref<boolean>;
navigateTo: (path: string) => Promise<void>;
goUp: () => Promise<void>;
refresh: () => Promise<void>;
}
/** Mock directory listings used until Wails SFTP bindings are connected. */
const mockDirectories: Record<string, FileEntry[]> = {
"/home/user": [
{ name: "docs", path: "/home/user/docs", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-17" },
{ name: "projects", path: "/home/user/projects", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-16" },
{ name: ".ssh", path: "/home/user/.ssh", size: 0, isDir: true, permissions: "drwx------", modTime: "2026-03-10" },
{ name: ".bashrc", path: "/home/user/.bashrc", size: 3771, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-15" },
{ name: "deploy.sh", path: "/home/user/deploy.sh", size: 1024, isDir: false, permissions: "-rwxr-xr-x", modTime: "2026-03-16" },
{ name: ".profile", path: "/home/user/.profile", size: 807, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-10" },
],
"/home/user/docs": [
{ name: "readme.md", path: "/home/user/docs/readme.md", size: 2048, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-17" },
{ name: "notes.txt", path: "/home/user/docs/notes.txt", size: 512, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-14" },
],
"/home/user/projects": [
{ name: "app", path: "/home/user/projects/app", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-16" },
{ name: "Makefile", path: "/home/user/projects/Makefile", size: 256, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-16" },
],
"/home/user/.ssh": [
{ name: "authorized_keys", path: "/home/user/.ssh/authorized_keys", size: 743, isDir: false, permissions: "-rw-------", modTime: "2026-03-10" },
{ name: "config", path: "/home/user/.ssh/config", size: 128, isDir: false, permissions: "-rw-------", modTime: "2026-03-10" },
],
};
/**
* Composable that manages SFTP file browsing state.
*
* Uses mock data until Wails SFTPService bindings are connected.
*/
export function useSftp(_sessionId: string): UseSftpReturn {
const currentPath = ref("/home/user");
const entries = ref<FileEntry[]>([]);
const isLoading = ref(false);
const followTerminal = ref(false);
async function listDirectory(path: string): Promise<FileEntry[]> {
// TODO: Replace with Wails binding call — SFTPService.List(sessionId, path)
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 150));
return mockDirectories[path] ?? [];
}
async function navigateTo(path: string): Promise<void> {
isLoading.value = true;
try {
currentPath.value = path;
entries.value = await listDirectory(path);
} finally {
isLoading.value = false;
}
}
async function goUp(): Promise<void> {
const parts = currentPath.value.split("/").filter(Boolean);
if (parts.length <= 1) {
await navigateTo("/");
return;
}
parts.pop();
await navigateTo("/" + parts.join("/"));
}
async function refresh(): Promise<void> {
await navigateTo(currentPath.value);
}
// Load initial directory
navigateTo(currentPath.value);
return {
currentPath,
entries,
isLoading,
followTerminal,
navigateTo,
goUp,
refresh,
};
}

View File

@ -1,123 +0,0 @@
import { ref, onBeforeUnmount } from "vue";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { WebLinksAddon } from "@xterm/addon-web-links";
import "@xterm/xterm/css/xterm.css";
/** MobaXTerm Classicinspired terminal theme colors. */
const defaultTheme = {
background: "#0d1117",
foreground: "#e0e0e0",
cursor: "#58a6ff",
cursorAccent: "#0d1117",
selectionBackground: "rgba(88, 166, 255, 0.3)",
selectionForeground: "#ffffff",
black: "#0d1117",
red: "#f85149",
green: "#3fb950",
yellow: "#e3b341",
blue: "#58a6ff",
magenta: "#bc8cff",
cyan: "#39c5cf",
white: "#e0e0e0",
brightBlack: "#484f58",
brightRed: "#ff7b72",
brightGreen: "#56d364",
brightYellow: "#e3b341",
brightBlue: "#79c0ff",
brightMagenta: "#d2a8ff",
brightCyan: "#56d4dd",
brightWhite: "#f0f6fc",
};
export interface UseTerminalReturn {
terminal: Terminal;
fitAddon: FitAddon;
mount: (container: HTMLElement) => void;
destroy: () => void;
write: (data: string) => void;
fit: () => void;
}
/**
* Composable that manages an xterm.js Terminal lifecycle.
*
* Creates the terminal with fit, search, and web-links addons.
* Data input and resize events are wired as TODOs for Wails bindings.
*/
export function useTerminal(sessionId: string): UseTerminalReturn {
const fitAddon = new FitAddon();
const searchAddon = new SearchAddon();
const webLinksAddon = new WebLinksAddon();
const terminal = new Terminal({
theme: defaultTheme,
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, monospace",
fontSize: 14,
lineHeight: 1.2,
cursorBlink: true,
cursorStyle: "block",
scrollback: 10000,
allowProposedApi: true,
convertEol: true,
});
terminal.loadAddon(fitAddon);
terminal.loadAddon(searchAddon);
terminal.loadAddon(webLinksAddon);
// Capture typed data and forward to the SSH backend
terminal.onData((_data: string) => {
// TODO: Replace with Wails binding call — SSHService.Write(sessionId, data)
// For now, echo typed data back to the terminal for visual feedback
void sessionId;
});
// Handle terminal resize events
terminal.onResize((_size: { cols: number; rows: number }) => {
// TODO: Replace with Wails binding call — SSHService.Resize(sessionId, cols, rows)
void sessionId;
});
let resizeObserver: ResizeObserver | null = null;
function mount(container: HTMLElement): void {
terminal.open(container);
fitAddon.fit();
// Auto-fit when the container resizes
resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
});
resizeObserver.observe(container);
// Write a placeholder welcome message (mock — replaced by real SSH output)
terminal.writeln("\x1b[1;34m Wraith Terminal\x1b[0m");
terminal.writeln("\x1b[90m Session: " + sessionId + "\x1b[0m");
terminal.writeln("\x1b[90m Waiting for SSH connection...\x1b[0m");
terminal.writeln("");
}
function destroy(): void {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
terminal.dispose();
}
function write(data: string): void {
terminal.write(data);
}
function fit(): void {
fitAddon.fit();
}
onBeforeUnmount(() => {
destroy();
});
return { terminal, fitAddon, mount, destroy, write, fit };
}

View File

@ -1,7 +0,0 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}

View File

@ -1,264 +0,0 @@
<template>
<div class="h-screen w-screen flex flex-col overflow-hidden">
<!-- Toolbar -->
<div
class="h-10 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] shrink-0"
style="--wails-draggable: drag"
>
<span class="text-sm font-bold tracking-widest text-[var(--wraith-accent-blue)]">
WRAITH
</span>
<!-- Quick Connect -->
<div class="flex-1 max-w-xs mx-4" style="--wails-draggable: no-drag">
<input
v-model="quickConnectInput"
type="text"
placeholder="Quick connect: user@host:port"
class="w-full px-2.5 py-1 text-xs rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
@keydown.enter="handleQuickConnect"
/>
</div>
<div class="flex items-center gap-3 text-xs text-[var(--wraith-text-secondary)]" style="--wails-draggable: no-drag">
<span>{{ sessionStore.sessionCount }} session{{ sessionStore.sessionCount !== 1 ? "s" : "" }}</span>
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Command palette (Ctrl+K)"
@click="commandPalette?.toggle()"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04z" />
</svg>
</button>
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Lock vault"
@click="appStore.lock()"
>
&#x1f512;
</button>
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Settings"
>
&#x2699;
</button>
</div>
</div>
<!-- Main content area -->
<div class="flex flex-1 min-h-0">
<!-- Sidebar -->
<div
class="flex flex-col bg-[var(--wraith-bg-secondary)] border-r border-[var(--wraith-border)] shrink-0"
:style="{ width: sidebarWidth + 'px' }"
>
<SidebarToggle v-model="sidebarTab" />
<!-- Search (connections mode only) -->
<div v-if="sidebarTab === 'connections'" class="px-3 py-2">
<input
v-model="connectionStore.searchQuery"
type="text"
placeholder="Search connections..."
class="w-full px-2.5 py-1.5 text-xs rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
</div>
<!-- Sidebar content -->
<div class="flex-1 overflow-y-auto">
<!-- Connection tree -->
<ConnectionTree v-if="sidebarTab === 'connections'" />
<!-- SFTP file tree -->
<template v-else-if="sidebarTab === 'sftp'">
<FileTree
v-if="sessionStore.activeSession && sessionStore.activeSession.protocol === 'ssh'"
:session-id="sessionStore.activeSession.id"
@open-file="handleOpenFile"
/>
<div v-else class="flex items-center justify-center py-8 px-3">
<p class="text-[var(--wraith-text-muted)] text-xs text-center">
Connect to an SSH session to browse files
</p>
</div>
</template>
</div>
<!-- Transfer progress (SFTP mode only) -->
<TransferProgress v-if="sidebarTab === 'sftp'" />
</div>
<!-- Content area -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Tab bar -->
<TabBar />
<!-- Editor panel (if a file is open) -->
<EditorWindow
v-if="editorFile"
:content="editorFile.content"
:file-path="editorFile.path"
:session-id="editorFile.sessionId"
@close="editorFile = null"
/>
<!-- Session area -->
<SessionContainer />
</div>
</div>
<!-- Status bar -->
<StatusBar ref="statusBar" @open-theme-picker="themePicker?.open()" />
<!-- Command Palette (Ctrl+K) -->
<CommandPalette ref="commandPalette" @open-import="importDialog?.open()" />
<!-- Theme Picker -->
<ThemePicker ref="themePicker" @select="handleThemeSelect" />
<!-- Import Dialog -->
<ImportDialog ref="importDialog" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { useAppStore } from "@/stores/app.store";
import { useConnectionStore } from "@/stores/connection.store";
import { useSessionStore } from "@/stores/session.store";
import SidebarToggle from "@/components/sidebar/SidebarToggle.vue";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import FileTree from "@/components/sftp/FileTree.vue";
import TransferProgress from "@/components/sftp/TransferProgress.vue";
import TabBar from "@/components/session/TabBar.vue";
import SessionContainer from "@/components/session/SessionContainer.vue";
import StatusBar from "@/components/common/StatusBar.vue";
import EditorWindow from "@/components/editor/EditorWindow.vue";
import CommandPalette from "@/components/common/CommandPalette.vue";
import ThemePicker from "@/components/common/ThemePicker.vue";
import ImportDialog from "@/components/common/ImportDialog.vue";
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
import type { FileEntry } from "@/composables/useSftp";
const appStore = useAppStore();
const connectionStore = useConnectionStore();
const sessionStore = useSessionStore();
const sidebarWidth = ref(240);
const sidebarTab = ref<SidebarTab>("connections");
const quickConnectInput = ref("");
/** Currently open file in the editor panel (null = no file open). */
const editorFile = ref<{ content: string; path: string; sessionId: string } | null>(null);
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null);
const themePicker = ref<InstanceType<typeof ThemePicker> | null>(null);
const importDialog = ref<InstanceType<typeof ImportDialog> | null>(null);
const statusBar = ref<InstanceType<typeof StatusBar> | null>(null);
/** Handle file open from SFTP sidebar -- loads mock content for now. */
function handleOpenFile(entry: FileEntry): void {
if (!sessionStore.activeSession) return;
// TODO: Replace with Wails binding call -- SFTPService.ReadFile(sessionId, entry.path)
// Mock file content for development
const mockContent = `# ${entry.name}\n\n` +
`# File: ${entry.path}\n` +
`# Size: ${entry.size} bytes\n` +
`# Permissions: ${entry.permissions}\n` +
`# Modified: ${entry.modTime}\n\n` +
`# TODO: Content will be loaded from SFTPService.ReadFile()\n`;
editorFile.value = {
content: mockContent,
path: entry.path,
sessionId: sessionStore.activeSession.id,
};
}
/** Handle theme selection from the ThemePicker. */
function handleThemeSelect(theme: ThemeDefinition): void {
statusBar.value?.setThemeName(theme.name);
}
/**
* Quick Connect: parse user@host:port and open a session.
* Default protocol: SSH, default port: 22.
* If port is 3389, use RDP.
*/
function handleQuickConnect(): void {
const raw = quickConnectInput.value.trim();
if (!raw) return;
let username = "";
let hostname = "";
let port = 22;
let protocol: "ssh" | "rdp" = "ssh";
let hostPart = raw;
// Extract username if present (user@...)
const atIdx = raw.indexOf("@");
if (atIdx > 0) {
username = raw.substring(0, atIdx);
hostPart = raw.substring(atIdx + 1);
}
// Extract port if present (...:port)
const colonIdx = hostPart.lastIndexOf(":");
if (colonIdx > 0) {
const portStr = hostPart.substring(colonIdx + 1);
const parsedPort = parseInt(portStr, 10);
if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
port = parsedPort;
hostPart = hostPart.substring(0, colonIdx);
}
}
hostname = hostPart;
if (!hostname) return;
// Auto-detect RDP by port
if (port === 3389) {
protocol = "rdp";
}
// Create a temporary connection and session
// TODO: Replace with Wails binding create ephemeral session via SSHService.Connect / RDPService.Connect
const tempId = Math.max(...connectionStore.connections.map((c) => c.id), 0) + 1;
const name = username ? `${username}@${hostname}` : hostname;
connectionStore.connections.push({
id: tempId,
name,
hostname,
port,
protocol,
groupId: 1,
tags: [],
});
sessionStore.connect(tempId);
quickConnectInput.value = "";
}
/** Global keyboard shortcut handler. */
function handleKeydown(event: KeyboardEvent): void {
// Ctrl+K or Cmd+K open command palette
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
event.preventDefault();
commandPalette.value?.toggle();
}
}
onMounted(() => {
document.addEventListener("keydown", handleKeydown);
});
onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown);
});
</script>

View File

@ -1,122 +0,0 @@
<template>
<div class="h-screen w-screen flex items-center justify-center bg-[var(--wraith-bg-primary)]">
<div class="w-full max-w-sm px-6">
<!-- Branding -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold tracking-widest text-[var(--wraith-accent-blue)]">
WRAITH
</h1>
<p class="text-[var(--wraith-text-secondary)] mt-2 text-sm">
{{ appStore.isFirstRun ? "Create a master password" : "Enter your master password" }}
</p>
</div>
<!-- Card -->
<form
class="bg-[var(--wraith-bg-secondary)] border border-[var(--wraith-border)] rounded-lg p-6 space-y-4"
@submit.prevent="handleSubmit"
>
<!-- Error -->
<div
v-if="appStore.error"
class="text-sm text-[var(--wraith-accent-red)] bg-[var(--wraith-accent-red)]/10 border border-[var(--wraith-accent-red)]/20 rounded px-3 py-2"
>
{{ appStore.error }}
</div>
<!-- Password -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1.5">
Master Password
</label>
<input
ref="passwordInput"
v-model="password"
type="password"
autocomplete="current-password"
placeholder="Enter password..."
class="w-full px-3 py-2 text-sm rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
@input="appStore.clearError()"
/>
</div>
<!-- Confirm password (first run only) -->
<div v-if="appStore.isFirstRun">
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1.5">
Confirm Password
</label>
<input
v-model="confirmPassword"
type="password"
autocomplete="new-password"
placeholder="Confirm password..."
class="w-full px-3 py-2 text-sm rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
@input="appStore.clearError()"
/>
</div>
<!-- Submit -->
<button
type="submit"
:disabled="submitting"
class="w-full py-2 text-sm font-medium rounded bg-[var(--wraith-accent-blue)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity cursor-pointer disabled:cursor-not-allowed"
>
<span v-if="submitting">{{ appStore.isFirstRun ? "Creating..." : "Unlocking..." }}</span>
<span v-else>{{ appStore.isFirstRun ? "Create Vault" : "Unlock" }}</span>
</button>
</form>
<!-- Version -->
<p class="text-center text-xs text-[var(--wraith-text-muted)] mt-4">
v1.0.0-alpha
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useAppStore } from "@/stores/app.store";
const appStore = useAppStore();
const password = ref("");
const confirmPassword = ref("");
const submitting = ref(false);
const passwordInput = ref<HTMLInputElement | null>(null);
onMounted(() => {
passwordInput.value?.focus();
});
async function handleSubmit(): Promise<void> {
if (!password.value) {
appStore.error = "Password is required";
return;
}
if (appStore.isFirstRun) {
if (password.value.length < 8) {
appStore.error = "Password must be at least 8 characters";
return;
}
if (password.value !== confirmPassword.value) {
appStore.error = "Passwords do not match";
return;
}
}
submitting.value = true;
try {
if (appStore.isFirstRun) {
await appStore.createVault(password.value);
} else {
await appStore.unlock(password.value);
}
} catch {
// Error is set in the store
} finally {
submitting.value = false;
}
}
</script>

View File

@ -1,77 +0,0 @@
import { defineStore } from "pinia";
import { ref } from "vue";
/**
* Wraith application store.
* Manages unlock state, first-run detection, and vault operations.
*
* Once Wails v3 bindings are generated, the mock calls below will be
* replaced with actual WraithApp.IsFirstRun(), CreateVault(), Unlock(), etc.
*/
export const useAppStore = defineStore("app", () => {
const isUnlocked = ref(false);
const isFirstRun = ref(true);
const isLoading = ref(true);
const error = ref<string | null>(null);
/** Check whether the vault has been created before. */
async function checkFirstRun(): Promise<void> {
try {
// TODO: replace with Wails binding — WraithApp.IsFirstRun()
isFirstRun.value = true;
} catch {
isFirstRun.value = true;
} finally {
isLoading.value = false;
}
}
/** Create a new vault with the given master password. */
async function createVault(password: string): Promise<void> {
error.value = null;
try {
// TODO: replace with Wails binding — WraithApp.CreateVault(password)
void password;
isFirstRun.value = false;
isUnlocked.value = true;
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to create vault";
throw e;
}
}
/** Unlock an existing vault with the master password. */
async function unlock(password: string): Promise<void> {
error.value = null;
try {
// TODO: replace with Wails binding — WraithApp.Unlock(password)
void password;
isUnlocked.value = true;
} catch (e) {
error.value = e instanceof Error ? e.message : "Invalid master password";
throw e;
}
}
/** Lock the vault (return to unlock screen). */
function lock(): void {
isUnlocked.value = false;
}
/** Clear the current error message. */
function clearError(): void {
error.value = null;
}
return {
isUnlocked,
isFirstRun,
isLoading,
error,
checkFirstRun,
createVault,
unlock,
lock,
clearError,
};
});

View File

@ -1,95 +0,0 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export interface Connection {
id: number;
name: string;
hostname: string;
port: number;
protocol: "ssh" | "rdp";
groupId: number;
tags?: string[];
}
export interface Group {
id: number;
name: string;
parentId: number | null;
}
/**
* Connection store.
* Manages connections, groups, and search state.
*
* Uses mock data until Wails bindings are connected.
*/
export const useConnectionStore = defineStore("connection", () => {
const connections = ref<Connection[]>([
{ id: 1, name: "Asgard", hostname: "192.168.1.4", port: 22, protocol: "ssh", groupId: 1, tags: ["Prod"] },
{ id: 2, name: "Docker", hostname: "155.254.29.221", port: 22, protocol: "ssh", groupId: 1, tags: ["Prod"] },
{ id: 3, name: "Predator Mac", hostname: "192.168.1.214", port: 22, protocol: "ssh", groupId: 1 },
{ id: 4, name: "CLT-VMHOST01", hostname: "100.64.1.204", port: 3389, protocol: "rdp", groupId: 1 },
{ id: 5, name: "ITFlow", hostname: "192.154.253.106", port: 22, protocol: "ssh", groupId: 2 },
{ id: 6, name: "Mautic", hostname: "192.154.253.112", port: 22, protocol: "ssh", groupId: 2 },
]);
const groups = ref<Group[]>([
{ id: 1, name: "Vantz's Stuff", parentId: null },
{ id: 2, name: "MSPNerd", parentId: null },
]);
const searchQuery = ref("");
/** Filter connections by search query. */
const filteredConnections = computed(() => {
const q = searchQuery.value.toLowerCase().trim();
if (!q) return connections.value;
return connections.value.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.hostname.toLowerCase().includes(q) ||
c.tags?.some((t) => t.toLowerCase().includes(q)),
);
});
/** Get connections belonging to a specific group. */
function connectionsByGroup(groupId: number): Connection[] {
const q = searchQuery.value.toLowerCase().trim();
const groupConns = connections.value.filter((c) => c.groupId === groupId);
if (!q) return groupConns;
return groupConns.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.hostname.toLowerCase().includes(q) ||
c.tags?.some((t) => t.toLowerCase().includes(q)),
);
}
/** Check if a group has any matching connections (for search filtering). */
function groupHasResults(groupId: number): boolean {
return connectionsByGroup(groupId).length > 0;
}
/** Load connections from backend (mock for now). */
async function loadConnections(): Promise<void> {
// TODO: replace with Wails binding — ConnectionService.ListConnections()
// connections.value = await ConnectionService.ListConnections();
}
/** Load groups from backend (mock for now). */
async function loadGroups(): Promise<void> {
// TODO: replace with Wails binding — ConnectionService.ListGroups()
// groups.value = await ConnectionService.ListGroups();
}
return {
connections,
groups,
searchQuery,
filteredConnections,
connectionsByGroup,
groupHasResults,
loadConnections,
loadGroups,
};
});

View File

@ -1,96 +0,0 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { useConnectionStore } from "@/stores/connection.store";
export interface Session {
id: string;
connectionId: number;
name: string;
protocol: "ssh" | "rdp";
active: boolean;
}
/**
* Session store.
* Manages active sessions and tab order.
*
* Sessions are populated by the Go SessionManager once plugins are wired up.
* For now, mock sessions are used to render the tab bar.
*/
export const useSessionStore = defineStore("session", () => {
const sessions = ref<Session[]>([]);
const activeSessionId = ref<string | null>(null);
const activeSession = computed(() =>
sessions.value.find((s) => s.id === activeSessionId.value) ?? null,
);
const sessionCount = computed(() => sessions.value.length);
/** Switch to a session tab. */
function activateSession(id: string): void {
activeSessionId.value = id;
}
/** Close a session tab. */
function closeSession(id: string): void {
const idx = sessions.value.findIndex((s) => s.id === id);
if (idx === -1) return;
sessions.value.splice(idx, 1);
// If we closed the active session, activate an adjacent one
if (activeSessionId.value === id) {
if (sessions.value.length === 0) {
activeSessionId.value = null;
} else {
const nextIdx = Math.min(idx, sessions.value.length - 1);
activeSessionId.value = sessions.value[nextIdx].id;
}
}
}
/** Add a new session (placeholder — will be called from connection double-click). */
function addSession(connectionId: number, name: string, protocol: "ssh" | "rdp"): void {
const id = `s${Date.now()}`;
sessions.value.push({ id, connectionId, name, protocol, active: false });
activeSessionId.value = id;
}
/**
* Connect to a server by connection ID.
* Creates a new session tab and sets it active.
*
* TODO: Replace with Wails binding call SSHService.Connect(hostname, port, ...)
* For now, creates a mock session using the connection's name.
*/
function connect(connectionId: number): void {
const connectionStore = useConnectionStore();
const conn = connectionStore.connections.find((c) => c.id === connectionId);
if (!conn) return;
// Check if there's already an active session for this connection
const existing = sessions.value.find((s) => s.connectionId === connectionId);
if (existing) {
activeSessionId.value = existing.id;
return;
}
// TODO: Replace with Wails binding call:
// const sessionId = await SSHService.Connect(conn.hostname, conn.port, username, authMethods, cols, rows)
// For now, create a mock session
addSession(connectionId, conn.name, conn.protocol);
}
return {
sessions,
activeSessionId,
activeSession,
sessionCount,
activateSession,
closeSession,
addSession,
connect,
};
});

View File

@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}

View File

@ -1,13 +0,0 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
import { resolve } from "path";
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
});

58
go.mod
View File

@ -1,58 +0,0 @@
module github.com/vstockwell/wraith
go 1.26.1
require (
github.com/google/uuid v1.6.0
github.com/pkg/sftp v1.13.10
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
golang.org/x/crypto v0.49.0
modernc.org/sqlite v1.46.2
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lmittmann/tint v1.1.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

197
go.sum
View File

@ -1,197 +0,0 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 h1:wRm1EiDQtxDisXk46NtpiBH90STwfKp36NrTDwOEdxw=
github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE=
modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

25
index.html Normal file
View File

@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wraith</title>
<!-- Prevent any flash of light background before Vue mounts -->
<style>
html,
body {
margin: 0;
padding: 0;
background: #0d1117;
height: 100%;
}
#app {
height: 100%;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -1,205 +0,0 @@
package app
import (
"database/sql"
"encoding/hex"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/vstockwell/wraith/internal/connections"
"github.com/vstockwell/wraith/internal/credentials"
"github.com/vstockwell/wraith/internal/db"
"github.com/vstockwell/wraith/internal/plugin"
"github.com/vstockwell/wraith/internal/rdp"
"github.com/vstockwell/wraith/internal/session"
"github.com/vstockwell/wraith/internal/settings"
"github.com/vstockwell/wraith/internal/sftp"
"github.com/vstockwell/wraith/internal/ssh"
"github.com/vstockwell/wraith/internal/theme"
"github.com/vstockwell/wraith/internal/vault"
)
// WraithApp is the main application struct that wires together all services
// and exposes vault management methods to the frontend via Wails bindings.
type WraithApp struct {
db *sql.DB
Vault *vault.VaultService
Settings *settings.SettingsService
Connections *connections.ConnectionService
Themes *theme.ThemeService
Sessions *session.Manager
Plugins *plugin.Registry
SSH *ssh.SSHService
SFTP *sftp.SFTPService
RDP *rdp.RDPService
Credentials *credentials.CredentialService
unlocked bool
}
// New creates and initializes the WraithApp, opening the database, running
// migrations, creating all services, and seeding built-in themes.
func New() (*WraithApp, error) {
dataDir := dataDirectory()
dbPath := filepath.Join(dataDir, "wraith.db")
slog.Info("opening database", "path", dbPath)
database, err := db.Open(dbPath)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := db.Migrate(database); err != nil {
return nil, fmt.Errorf("run migrations: %w", err)
}
settingsSvc := settings.NewSettingsService(database)
connSvc := connections.NewConnectionService(database)
themeSvc := theme.NewThemeService(database)
sessionMgr := session.NewManager()
pluginReg := plugin.NewRegistry()
// No-op output handler — Wails event emission will be wired at runtime.
sshSvc := ssh.NewSSHService(database, func(sessionID string, data []byte) {
// TODO: Emit Wails event "ssh:output" with sessionID + data
_ = sessionID
_ = data
})
sftpSvc := sftp.NewSFTPService()
// RDP service with platform-aware backend factory.
// On Windows the factory returns a FreeRDPBackend backed by libfreerdp3.dll;
// on other platforms it falls back to MockBackend for development.
rdpSvc := rdp.NewRDPService(func() rdp.RDPBackend {
return rdp.NewProductionBackend()
})
// CredentialService requires the vault to be unlocked, so it starts nil.
// It is created lazily after the vault is unlocked via initCredentials().
// Seed built-in themes on every startup (INSERT OR IGNORE keeps it idempotent)
if err := themeSvc.SeedBuiltins(); err != nil {
slog.Warn("failed to seed themes", "error", err)
}
return &WraithApp{
db: database,
Settings: settingsSvc,
Connections: connSvc,
Themes: themeSvc,
Sessions: sessionMgr,
Plugins: pluginReg,
SSH: sshSvc,
SFTP: sftpSvc,
RDP: rdpSvc,
}, nil
}
// dataDirectory returns the path where Wraith stores its data.
// On Windows with APPDATA set, it uses %APPDATA%\Wraith.
// On macOS/Linux with XDG_DATA_HOME or HOME, it uses the appropriate path.
// Falls back to the current working directory for development.
func dataDirectory() string {
// Windows
if appData := os.Getenv("APPDATA"); appData != "" {
return filepath.Join(appData, "Wraith")
}
// macOS / Linux: use XDG_DATA_HOME or fallback to ~/.local/share
if home, err := os.UserHomeDir(); err == nil {
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, "wraith")
}
return filepath.Join(home, ".local", "share", "wraith")
}
// Dev fallback
return "."
}
// IsFirstRun checks whether the vault has been set up by looking for vault_salt in settings.
func (a *WraithApp) IsFirstRun() bool {
salt, _ := a.Settings.Get("vault_salt")
return salt == ""
}
// CreateVault sets up the vault with a master password. It generates a salt,
// derives an encryption key, and stores the salt and a check value in settings.
func (a *WraithApp) CreateVault(password string) error {
salt, err := vault.GenerateSalt()
if err != nil {
return fmt.Errorf("generate salt: %w", err)
}
key := vault.DeriveKey(password, salt)
a.Vault = vault.NewVaultService(key)
// Store salt as hex in settings
if err := a.Settings.Set("vault_salt", hex.EncodeToString(salt)); err != nil {
return fmt.Errorf("store salt: %w", err)
}
// Encrypt a known check value — used to verify the password on unlock
check, err := a.Vault.Encrypt("wraith-vault-check")
if err != nil {
return fmt.Errorf("encrypt check value: %w", err)
}
if err := a.Settings.Set("vault_check", check); err != nil {
return fmt.Errorf("store check value: %w", err)
}
a.unlocked = true
a.initCredentials()
slog.Info("vault created successfully")
return nil
}
// Unlock verifies the master password against the stored check value and
// initializes the vault service for decryption.
func (a *WraithApp) Unlock(password string) error {
saltHex, err := a.Settings.Get("vault_salt")
if err != nil || saltHex == "" {
return fmt.Errorf("vault not set up — call CreateVault first")
}
salt, err := hex.DecodeString(saltHex)
if err != nil {
return fmt.Errorf("decode salt: %w", err)
}
key := vault.DeriveKey(password, salt)
vs := vault.NewVaultService(key)
// Verify by decrypting the stored check value
checkEncrypted, err := a.Settings.Get("vault_check")
if err != nil || checkEncrypted == "" {
return fmt.Errorf("vault check value missing")
}
checkPlain, err := vs.Decrypt(checkEncrypted)
if err != nil {
return fmt.Errorf("incorrect master password")
}
if checkPlain != "wraith-vault-check" {
return fmt.Errorf("incorrect master password")
}
a.Vault = vs
a.unlocked = true
a.initCredentials()
slog.Info("vault unlocked successfully")
return nil
}
// IsUnlocked returns whether the vault is currently unlocked.
func (a *WraithApp) IsUnlocked() bool {
return a.unlocked
}
// initCredentials creates the CredentialService after the vault is unlocked.
func (a *WraithApp) initCredentials() {
if a.Vault != nil {
a.Credentials = credentials.NewCredentialService(a.db, a.Vault)
}
}

View File

@ -1,66 +0,0 @@
package app
import (
"encoding/json"
"github.com/vstockwell/wraith/internal/settings"
)
type WorkspaceSnapshot struct {
Tabs []WorkspaceTab `json:"tabs"`
SidebarWidth int `json:"sidebarWidth"`
SidebarMode string `json:"sidebarMode"`
ActiveTab int `json:"activeTab"`
}
type WorkspaceTab struct {
ConnectionID int64 `json:"connectionId"`
Protocol string `json:"protocol"`
Position int `json:"position"`
}
type WorkspaceService struct {
settings *settings.SettingsService
}
func NewWorkspaceService(s *settings.SettingsService) *WorkspaceService {
return &WorkspaceService{settings: s}
}
// Save serializes the workspace snapshot to settings
func (w *WorkspaceService) Save(snapshot *WorkspaceSnapshot) error {
data, err := json.Marshal(snapshot)
if err != nil {
return err
}
return w.settings.Set("workspace_snapshot", string(data))
}
// Load reads the last saved workspace snapshot
func (w *WorkspaceService) Load() (*WorkspaceSnapshot, error) {
data, err := w.settings.Get("workspace_snapshot")
if err != nil || data == "" {
return nil, nil // no saved workspace
}
var snapshot WorkspaceSnapshot
if err := json.Unmarshal([]byte(data), &snapshot); err != nil {
return nil, err
}
return &snapshot, nil
}
// MarkCleanShutdown saves a flag indicating clean exit
func (w *WorkspaceService) MarkCleanShutdown() error {
return w.settings.Set("clean_shutdown", "true")
}
// WasCleanShutdown checks if last exit was clean
func (w *WorkspaceService) WasCleanShutdown() bool {
val, _ := w.settings.Get("clean_shutdown")
return val == "true"
}
// ClearCleanShutdown removes the clean shutdown flag (called on startup)
func (w *WorkspaceService) ClearCleanShutdown() error {
return w.settings.Delete("clean_shutdown")
}

View File

@ -1,74 +0,0 @@
package app
import (
"path/filepath"
"testing"
"github.com/vstockwell/wraith/internal/db"
"github.com/vstockwell/wraith/internal/settings"
)
func setupWorkspaceService(t *testing.T) *WorkspaceService {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(d); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
return NewWorkspaceService(settings.NewSettingsService(d))
}
func TestSaveAndLoadWorkspace(t *testing.T) {
svc := setupWorkspaceService(t)
snapshot := &WorkspaceSnapshot{
Tabs: []WorkspaceTab{
{ConnectionID: 1, Protocol: "ssh", Position: 0},
{ConnectionID: 5, Protocol: "rdp", Position: 1},
},
SidebarWidth: 240,
SidebarMode: "connections",
ActiveTab: 0,
}
if err := svc.Save(snapshot); err != nil {
t.Fatal(err)
}
loaded, err := svc.Load()
if err != nil {
t.Fatal(err)
}
if len(loaded.Tabs) != 2 {
t.Errorf("len(Tabs) = %d, want 2", len(loaded.Tabs))
}
if loaded.SidebarWidth != 240 {
t.Errorf("SidebarWidth = %d, want 240", loaded.SidebarWidth)
}
}
func TestLoadEmptyWorkspace(t *testing.T) {
svc := setupWorkspaceService(t)
loaded, err := svc.Load()
if err != nil {
t.Fatal(err)
}
if loaded != nil {
t.Error("should return nil for empty workspace")
}
}
func TestCleanShutdownFlag(t *testing.T) {
svc := setupWorkspaceService(t)
if svc.WasCleanShutdown() {
t.Error("should not be clean initially")
}
svc.MarkCleanShutdown()
if !svc.WasCleanShutdown() {
t.Error("should be clean after marking")
}
svc.ClearCleanShutdown()
if svc.WasCleanShutdown() {
t.Error("should not be clean after clearing")
}
}

View File

@ -1,40 +0,0 @@
package connections
import "fmt"
func (s *ConnectionService) Search(query string) ([]Connection, error) {
like := "%" + query + "%"
rows, err := s.db.Query(
`SELECT id, name, hostname, port, protocol, group_id, credential_id,
COALESCE(color,''), tags, COALESCE(notes,''), COALESCE(options,'{}'),
sort_order, last_connected, created_at, updated_at
FROM connections
WHERE name LIKE ? COLLATE NOCASE
OR hostname LIKE ? COLLATE NOCASE
OR tags LIKE ? COLLATE NOCASE
OR notes LIKE ? COLLATE NOCASE
ORDER BY last_connected DESC NULLS LAST, name`,
like, like, like, like,
)
if err != nil {
return nil, fmt.Errorf("search connections: %w", err)
}
defer rows.Close()
return scanConnections(rows)
}
func (s *ConnectionService) FilterByTag(tag string) ([]Connection, error) {
rows, err := s.db.Query(
`SELECT c.id, c.name, c.hostname, c.port, c.protocol, c.group_id, c.credential_id,
COALESCE(c.color,''), c.tags, COALESCE(c.notes,''), COALESCE(c.options,'{}'),
c.sort_order, c.last_connected, c.created_at, c.updated_at
FROM connections c, json_each(c.tags) AS t
WHERE t.value = ?
ORDER BY c.name`, tag,
)
if err != nil {
return nil, fmt.Errorf("filter by tag: %w", err)
}
defer rows.Close()
return scanConnections(rows)
}

View File

@ -1,53 +0,0 @@
package connections
import "testing"
func TestSearchByName(t *testing.T) {
svc := setupTestService(t)
svc.CreateConnection(CreateConnectionInput{Name: "Asgard", Hostname: "192.168.1.4", Port: 22, Protocol: "ssh"})
svc.CreateConnection(CreateConnectionInput{Name: "Docker", Hostname: "155.254.29.221", Port: 22, Protocol: "ssh"})
results, err := svc.Search("asg")
if err != nil {
t.Fatalf("Search() error: %v", err)
}
if len(results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(results))
}
if results[0].Name != "Asgard" {
t.Errorf("Name = %q, want %q", results[0].Name, "Asgard")
}
}
func TestSearchByHostname(t *testing.T) {
svc := setupTestService(t)
svc.CreateConnection(CreateConnectionInput{Name: "Asgard", Hostname: "192.168.1.4", Port: 22, Protocol: "ssh"})
results, _ := svc.Search("192.168")
if len(results) != 1 {
t.Errorf("len(results) = %d, want 1", len(results))
}
}
func TestSearchByTag(t *testing.T) {
svc := setupTestService(t)
svc.CreateConnection(CreateConnectionInput{Name: "ProdServer", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh", Tags: []string{"Prod", "Linux"}})
svc.CreateConnection(CreateConnectionInput{Name: "DevServer", Hostname: "10.0.0.2", Port: 22, Protocol: "ssh", Tags: []string{"Dev", "Linux"}})
results, _ := svc.Search("Prod")
if len(results) != 1 {
t.Errorf("len(results) = %d, want 1", len(results))
}
}
func TestFilterByTag(t *testing.T) {
svc := setupTestService(t)
svc.CreateConnection(CreateConnectionInput{Name: "A", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh", Tags: []string{"Prod"}})
svc.CreateConnection(CreateConnectionInput{Name: "B", Hostname: "10.0.0.2", Port: 22, Protocol: "ssh", Tags: []string{"Dev"}})
svc.CreateConnection(CreateConnectionInput{Name: "C", Hostname: "10.0.0.3", Port: 22, Protocol: "ssh", Tags: []string{"Prod", "Linux"}})
results, _ := svc.FilterByTag("Prod")
if len(results) != 2 {
t.Errorf("len(results) = %d, want 2", len(results))
}
}

View File

@ -1,361 +0,0 @@
package connections
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
)
type Group struct {
ID int64 `json:"id"`
Name string `json:"name"`
ParentID *int64 `json:"parentId"`
SortOrder int `json:"sortOrder"`
Icon string `json:"icon"`
CreatedAt time.Time `json:"createdAt"`
Children []Group `json:"children,omitempty"`
}
type Connection struct {
ID int64 `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Port int `json:"port"`
Protocol string `json:"protocol"`
GroupID *int64 `json:"groupId"`
CredentialID *int64 `json:"credentialId"`
Color string `json:"color"`
Tags []string `json:"tags"`
Notes string `json:"notes"`
Options string `json:"options"`
SortOrder int `json:"sortOrder"`
LastConnected *time.Time `json:"lastConnected"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CreateConnectionInput struct {
Name string `json:"name"`
Hostname string `json:"hostname"`
Port int `json:"port"`
Protocol string `json:"protocol"`
GroupID *int64 `json:"groupId"`
CredentialID *int64 `json:"credentialId"`
Color string `json:"color"`
Tags []string `json:"tags"`
Notes string `json:"notes"`
Options string `json:"options"`
}
type UpdateConnectionInput struct {
Name *string `json:"name"`
Hostname *string `json:"hostname"`
Port *int `json:"port"`
GroupID *int64 `json:"groupId"`
CredentialID *int64 `json:"credentialId"`
Color *string `json:"color"`
Tags []string `json:"tags"`
Notes *string `json:"notes"`
Options *string `json:"options"`
}
type ConnectionService struct {
db *sql.DB
}
func NewConnectionService(db *sql.DB) *ConnectionService {
return &ConnectionService{db: db}
}
// ---------- Group CRUD ----------
func (s *ConnectionService) CreateGroup(name string, parentID *int64) (*Group, error) {
result, err := s.db.Exec(
"INSERT INTO groups (name, parent_id) VALUES (?, ?)",
name, parentID,
)
if err != nil {
return nil, fmt.Errorf("create group: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("get group id: %w", err)
}
var g Group
var icon sql.NullString
err = s.db.QueryRow(
"SELECT id, name, parent_id, sort_order, icon, created_at FROM groups WHERE id = ?", id,
).Scan(&g.ID, &g.Name, &g.ParentID, &g.SortOrder, &icon, &g.CreatedAt)
if err != nil {
return nil, fmt.Errorf("get created group: %w", err)
}
if icon.Valid {
g.Icon = icon.String
}
return &g, nil
}
func (s *ConnectionService) ListGroups() ([]Group, error) {
rows, err := s.db.Query(
"SELECT id, name, parent_id, sort_order, icon, created_at FROM groups ORDER BY sort_order, name",
)
if err != nil {
return nil, fmt.Errorf("list groups: %w", err)
}
defer rows.Close()
groupMap := make(map[int64]*Group)
var allGroups []*Group
for rows.Next() {
var g Group
var icon sql.NullString
if err := rows.Scan(&g.ID, &g.Name, &g.ParentID, &g.SortOrder, &icon, &g.CreatedAt); err != nil {
return nil, fmt.Errorf("scan group: %w", err)
}
if icon.Valid {
g.Icon = icon.String
}
groupMap[g.ID] = &g
allGroups = append(allGroups, &g)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate groups: %w", err)
}
// Build tree: attach children to parents, collect roots
var roots []Group
for _, g := range allGroups {
if g.ParentID != nil {
if parent, ok := groupMap[*g.ParentID]; ok {
parent.Children = append(parent.Children, *g)
}
} else {
roots = append(roots, *g)
}
}
// Re-attach children to root copies (since we copied into roots)
for i := range roots {
if orig, ok := groupMap[roots[i].ID]; ok {
roots[i].Children = orig.Children
}
}
if roots == nil {
roots = []Group{}
}
return roots, nil
}
func (s *ConnectionService) DeleteGroup(id int64) error {
_, err := s.db.Exec("DELETE FROM groups WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete group: %w", err)
}
return nil
}
// ---------- Connection CRUD ----------
func (s *ConnectionService) CreateConnection(input CreateConnectionInput) (*Connection, error) {
tags, err := json.Marshal(input.Tags)
if err != nil {
return nil, fmt.Errorf("marshal tags: %w", err)
}
if input.Tags == nil {
tags = []byte("[]")
}
options := input.Options
if options == "" {
options = "{}"
}
result, err := s.db.Exec(
`INSERT INTO connections (name, hostname, port, protocol, group_id, credential_id, color, tags, notes, options)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
input.Name, input.Hostname, input.Port, input.Protocol,
input.GroupID, input.CredentialID, input.Color,
string(tags), input.Notes, options,
)
if err != nil {
return nil, fmt.Errorf("create connection: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("get connection id: %w", err)
}
return s.GetConnection(id)
}
func (s *ConnectionService) GetConnection(id int64) (*Connection, error) {
row := s.db.QueryRow(
`SELECT id, name, hostname, port, protocol, group_id, credential_id,
color, tags, notes, options, sort_order, last_connected, created_at, updated_at
FROM connections WHERE id = ?`, id,
)
var c Connection
var tagsJSON string
var color, notes, options sql.NullString
var lastConnected sql.NullTime
err := row.Scan(
&c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol,
&c.GroupID, &c.CredentialID,
&color, &tagsJSON, &notes, &options,
&c.SortOrder, &lastConnected, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("get connection: %w", err)
}
if color.Valid {
c.Color = color.String
}
if notes.Valid {
c.Notes = notes.String
}
if options.Valid {
c.Options = options.String
}
if lastConnected.Valid {
c.LastConnected = &lastConnected.Time
}
if err := json.Unmarshal([]byte(tagsJSON), &c.Tags); err != nil {
c.Tags = []string{}
}
if c.Tags == nil {
c.Tags = []string{}
}
return &c, nil
}
func (s *ConnectionService) ListConnections() ([]Connection, error) {
rows, err := s.db.Query(
`SELECT id, name, hostname, port, protocol, group_id, credential_id,
color, tags, notes, options, sort_order, last_connected, created_at, updated_at
FROM connections ORDER BY sort_order, name`,
)
if err != nil {
return nil, fmt.Errorf("list connections: %w", err)
}
defer rows.Close()
return scanConnections(rows)
}
// scanConnections is a shared helper used by ListConnections and (later) Search.
func scanConnections(rows *sql.Rows) ([]Connection, error) {
var conns []Connection
for rows.Next() {
var c Connection
var tagsJSON string
var color, notes, options sql.NullString
var lastConnected sql.NullTime
if err := rows.Scan(
&c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol,
&c.GroupID, &c.CredentialID,
&color, &tagsJSON, &notes, &options,
&c.SortOrder, &lastConnected, &c.CreatedAt, &c.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan connection: %w", err)
}
if color.Valid {
c.Color = color.String
}
if notes.Valid {
c.Notes = notes.String
}
if options.Valid {
c.Options = options.String
}
if lastConnected.Valid {
c.LastConnected = &lastConnected.Time
}
if err := json.Unmarshal([]byte(tagsJSON), &c.Tags); err != nil {
c.Tags = []string{}
}
if c.Tags == nil {
c.Tags = []string{}
}
conns = append(conns, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate connections: %w", err)
}
if conns == nil {
conns = []Connection{}
}
return conns, nil
}
func (s *ConnectionService) UpdateConnection(id int64, input UpdateConnectionInput) (*Connection, error) {
setClauses := []string{"updated_at = CURRENT_TIMESTAMP"}
args := []interface{}{}
if input.Name != nil {
setClauses = append(setClauses, "name = ?")
args = append(args, *input.Name)
}
if input.Hostname != nil {
setClauses = append(setClauses, "hostname = ?")
args = append(args, *input.Hostname)
}
if input.Port != nil {
setClauses = append(setClauses, "port = ?")
args = append(args, *input.Port)
}
if input.GroupID != nil {
setClauses = append(setClauses, "group_id = ?")
args = append(args, *input.GroupID)
}
if input.CredentialID != nil {
setClauses = append(setClauses, "credential_id = ?")
args = append(args, *input.CredentialID)
}
if input.Tags != nil {
tags, _ := json.Marshal(input.Tags)
setClauses = append(setClauses, "tags = ?")
args = append(args, string(tags))
}
if input.Notes != nil {
setClauses = append(setClauses, "notes = ?")
args = append(args, *input.Notes)
}
if input.Color != nil {
setClauses = append(setClauses, "color = ?")
args = append(args, *input.Color)
}
if input.Options != nil {
setClauses = append(setClauses, "options = ?")
args = append(args, *input.Options)
}
args = append(args, id)
query := fmt.Sprintf("UPDATE connections SET %s WHERE id = ?", strings.Join(setClauses, ", "))
if _, err := s.db.Exec(query, args...); err != nil {
return nil, fmt.Errorf("update connection: %w", err)
}
return s.GetConnection(id)
}
func (s *ConnectionService) DeleteConnection(id int64) error {
_, err := s.db.Exec("DELETE FROM connections WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete connection: %w", err)
}
return nil
}

View File

@ -1,234 +0,0 @@
package connections
import (
"path/filepath"
"testing"
"github.com/vstockwell/wraith/internal/db"
)
func strPtr(s string) *string { return &s }
func setupTestService(t *testing.T) *ConnectionService {
t.Helper()
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("db.Open() error: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("db.Migrate() error: %v", err)
}
t.Cleanup(func() { database.Close() })
return NewConnectionService(database)
}
func TestCreateGroup(t *testing.T) {
svc := setupTestService(t)
g, err := svc.CreateGroup("Servers", nil)
if err != nil {
t.Fatalf("CreateGroup() error: %v", err)
}
if g.ID == 0 {
t.Error("expected non-zero ID")
}
if g.Name != "Servers" {
t.Errorf("Name = %q, want %q", g.Name, "Servers")
}
if g.ParentID != nil {
t.Errorf("ParentID = %v, want nil", g.ParentID)
}
}
func TestCreateSubGroup(t *testing.T) {
svc := setupTestService(t)
parent, err := svc.CreateGroup("Servers", nil)
if err != nil {
t.Fatalf("CreateGroup(parent) error: %v", err)
}
child, err := svc.CreateGroup("Production", &parent.ID)
if err != nil {
t.Fatalf("CreateGroup(child) error: %v", err)
}
if child.ParentID == nil {
t.Fatal("expected non-nil ParentID")
}
if *child.ParentID != parent.ID {
t.Errorf("ParentID = %d, want %d", *child.ParentID, parent.ID)
}
}
func TestListGroups(t *testing.T) {
svc := setupTestService(t)
parent, err := svc.CreateGroup("Servers", nil)
if err != nil {
t.Fatalf("CreateGroup(parent) error: %v", err)
}
if _, err := svc.CreateGroup("Production", &parent.ID); err != nil {
t.Fatalf("CreateGroup(child) error: %v", err)
}
groups, err := svc.ListGroups()
if err != nil {
t.Fatalf("ListGroups() error: %v", err)
}
if len(groups) != 1 {
t.Fatalf("len(groups) = %d, want 1 (only root groups)", len(groups))
}
if groups[0].Name != "Servers" {
t.Errorf("groups[0].Name = %q, want %q", groups[0].Name, "Servers")
}
if len(groups[0].Children) != 1 {
t.Fatalf("len(children) = %d, want 1", len(groups[0].Children))
}
if groups[0].Children[0].Name != "Production" {
t.Errorf("child name = %q, want %q", groups[0].Children[0].Name, "Production")
}
}
func TestDeleteGroup(t *testing.T) {
svc := setupTestService(t)
g, err := svc.CreateGroup("ToDelete", nil)
if err != nil {
t.Fatalf("CreateGroup() error: %v", err)
}
if err := svc.DeleteGroup(g.ID); err != nil {
t.Fatalf("DeleteGroup() error: %v", err)
}
groups, err := svc.ListGroups()
if err != nil {
t.Fatalf("ListGroups() error: %v", err)
}
if len(groups) != 0 {
t.Errorf("len(groups) = %d, want 0 after delete", len(groups))
}
}
func TestCreateConnection(t *testing.T) {
svc := setupTestService(t)
conn, err := svc.CreateConnection(CreateConnectionInput{
Name: "Web Server",
Hostname: "10.0.0.1",
Port: 22,
Protocol: "ssh",
Tags: []string{"Prod", "Linux"},
Options: `{"keepAliveInterval": 60}`,
})
if err != nil {
t.Fatalf("CreateConnection() error: %v", err)
}
if conn.ID == 0 {
t.Error("expected non-zero ID")
}
if conn.Name != "Web Server" {
t.Errorf("Name = %q, want %q", conn.Name, "Web Server")
}
if len(conn.Tags) != 2 {
t.Fatalf("len(Tags) = %d, want 2", len(conn.Tags))
}
if conn.Tags[0] != "Prod" || conn.Tags[1] != "Linux" {
t.Errorf("Tags = %v, want [Prod Linux]", conn.Tags)
}
if conn.Options != `{"keepAliveInterval": 60}` {
t.Errorf("Options = %q, want JSON blob", conn.Options)
}
}
func TestListConnections(t *testing.T) {
svc := setupTestService(t)
if _, err := svc.CreateConnection(CreateConnectionInput{
Name: "Server A",
Hostname: "10.0.0.1",
Port: 22,
Protocol: "ssh",
}); err != nil {
t.Fatalf("CreateConnection(A) error: %v", err)
}
if _, err := svc.CreateConnection(CreateConnectionInput{
Name: "Server B",
Hostname: "10.0.0.2",
Port: 3389,
Protocol: "rdp",
}); err != nil {
t.Fatalf("CreateConnection(B) error: %v", err)
}
conns, err := svc.ListConnections()
if err != nil {
t.Fatalf("ListConnections() error: %v", err)
}
if len(conns) != 2 {
t.Fatalf("len(conns) = %d, want 2", len(conns))
}
}
func TestUpdateConnection(t *testing.T) {
svc := setupTestService(t)
conn, err := svc.CreateConnection(CreateConnectionInput{
Name: "Old Name",
Hostname: "10.0.0.1",
Port: 22,
Protocol: "ssh",
Tags: []string{"Dev"},
})
if err != nil {
t.Fatalf("CreateConnection() error: %v", err)
}
updated, err := svc.UpdateConnection(conn.ID, UpdateConnectionInput{
Name: strPtr("New Name"),
Tags: []string{"Prod", "Linux"},
})
if err != nil {
t.Fatalf("UpdateConnection() error: %v", err)
}
if updated.Name != "New Name" {
t.Errorf("Name = %q, want %q", updated.Name, "New Name")
}
if len(updated.Tags) != 2 {
t.Fatalf("len(Tags) = %d, want 2", len(updated.Tags))
}
if updated.Tags[0] != "Prod" {
t.Errorf("Tags[0] = %q, want %q", updated.Tags[0], "Prod")
}
// Hostname should remain unchanged
if updated.Hostname != "10.0.0.1" {
t.Errorf("Hostname = %q, want %q (unchanged)", updated.Hostname, "10.0.0.1")
}
}
func TestDeleteConnection(t *testing.T) {
svc := setupTestService(t)
conn, err := svc.CreateConnection(CreateConnectionInput{
Name: "ToDelete",
Hostname: "10.0.0.1",
Port: 22,
Protocol: "ssh",
})
if err != nil {
t.Fatalf("CreateConnection() error: %v", err)
}
if err := svc.DeleteConnection(conn.ID); err != nil {
t.Fatalf("DeleteConnection() error: %v", err)
}
conns, err := svc.ListConnections()
if err != nil {
t.Fatalf("ListConnections() error: %v", err)
}
if len(conns) != 0 {
t.Errorf("len(conns) = %d, want 0 after delete", len(conns))
}
}

View File

@ -1,408 +0,0 @@
package credentials
import (
"crypto/ed25519"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"database/sql"
"encoding/base64"
"encoding/pem"
"fmt"
"github.com/vstockwell/wraith/internal/vault"
"golang.org/x/crypto/ssh"
)
// Credential represents a stored credential (password or SSH key reference).
type Credential struct {
ID int64 `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Domain string `json:"domain"`
Type string `json:"type"` // "password" or "ssh_key"
SSHKeyID *int64 `json:"sshKeyId"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// SSHKey represents a stored SSH key.
type SSHKey struct {
ID int64 `json:"id"`
Name string `json:"name"`
KeyType string `json:"keyType"`
Fingerprint string `json:"fingerprint"`
PublicKey string `json:"publicKey"`
CreatedAt string `json:"createdAt"`
}
// CredentialService provides CRUD for credentials with vault encryption.
type CredentialService struct {
db *sql.DB
vault *vault.VaultService
}
// NewCredentialService creates a new CredentialService.
func NewCredentialService(db *sql.DB, vault *vault.VaultService) *CredentialService {
return &CredentialService{db: db, vault: vault}
}
// CreatePassword creates a password credential (password encrypted via vault).
func (s *CredentialService) CreatePassword(name, username, password, domain string) (*Credential, error) {
encrypted, err := s.vault.Encrypt(password)
if err != nil {
return nil, fmt.Errorf("encrypt password: %w", err)
}
result, err := s.db.Exec(
`INSERT INTO credentials (name, username, domain, type, encrypted_value)
VALUES (?, ?, ?, 'password', ?)`,
name, username, domain, encrypted,
)
if err != nil {
return nil, fmt.Errorf("insert credential: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("get credential id: %w", err)
}
return s.getCredential(id)
}
// CreateSSHKey imports an SSH key (private key encrypted via vault).
func (s *CredentialService) CreateSSHKey(name string, privateKeyPEM []byte, passphrase string) (*SSHKey, error) {
// Parse the private key to detect type and extract public key
keyType := DetectKeyType(privateKeyPEM)
// Parse the key to get the public key for fingerprinting.
// Try without passphrase first (handles unencrypted keys even when a
// passphrase is provided for storage), then fall back to using the
// passphrase for encrypted PEM keys.
var signer ssh.Signer
var err error
signer, err = ssh.ParsePrivateKey(privateKeyPEM)
if err != nil && passphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKeyPEM, []byte(passphrase))
}
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
pubKey := signer.PublicKey()
fingerprint := ssh.FingerprintSHA256(pubKey)
publicKeyStr := string(ssh.MarshalAuthorizedKey(pubKey))
// Encrypt private key via vault
encryptedKey, err := s.vault.Encrypt(string(privateKeyPEM))
if err != nil {
return nil, fmt.Errorf("encrypt private key: %w", err)
}
// Encrypt passphrase via vault (if provided)
var encryptedPassphrase sql.NullString
if passphrase != "" {
ep, err := s.vault.Encrypt(passphrase)
if err != nil {
return nil, fmt.Errorf("encrypt passphrase: %w", err)
}
encryptedPassphrase = sql.NullString{String: ep, Valid: true}
}
result, err := s.db.Exec(
`INSERT INTO ssh_keys (name, key_type, fingerprint, public_key, encrypted_private_key, passphrase_encrypted)
VALUES (?, ?, ?, ?, ?, ?)`,
name, keyType, fingerprint, publicKeyStr, encryptedKey, encryptedPassphrase,
)
if err != nil {
return nil, fmt.Errorf("insert ssh key: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("get ssh key id: %w", err)
}
return s.getSSHKey(id)
}
// ListCredentials returns all credentials WITHOUT encrypted values.
func (s *CredentialService) ListCredentials() ([]Credential, error) {
rows, err := s.db.Query(
`SELECT id, name, username, domain, type, ssh_key_id, created_at, updated_at
FROM credentials ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("list credentials: %w", err)
}
defer rows.Close()
var creds []Credential
for rows.Next() {
var c Credential
var username, domain sql.NullString
if err := rows.Scan(&c.ID, &c.Name, &username, &domain, &c.Type, &c.SSHKeyID, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan credential: %w", err)
}
if username.Valid {
c.Username = username.String
}
if domain.Valid {
c.Domain = domain.String
}
creds = append(creds, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate credentials: %w", err)
}
if creds == nil {
creds = []Credential{}
}
return creds, nil
}
// ListSSHKeys returns all SSH keys WITHOUT private key data.
func (s *CredentialService) ListSSHKeys() ([]SSHKey, error) {
rows, err := s.db.Query(
`SELECT id, name, key_type, fingerprint, public_key, created_at
FROM ssh_keys ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("list ssh keys: %w", err)
}
defer rows.Close()
var keys []SSHKey
for rows.Next() {
var k SSHKey
var keyType, fingerprint, publicKey sql.NullString
if err := rows.Scan(&k.ID, &k.Name, &keyType, &fingerprint, &publicKey, &k.CreatedAt); err != nil {
return nil, fmt.Errorf("scan ssh key: %w", err)
}
if keyType.Valid {
k.KeyType = keyType.String
}
if fingerprint.Valid {
k.Fingerprint = fingerprint.String
}
if publicKey.Valid {
k.PublicKey = publicKey.String
}
keys = append(keys, k)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate ssh keys: %w", err)
}
if keys == nil {
keys = []SSHKey{}
}
return keys, nil
}
// DecryptPassword returns the decrypted password for a credential.
func (s *CredentialService) DecryptPassword(credentialID int64) (string, error) {
var encrypted sql.NullString
err := s.db.QueryRow(
"SELECT encrypted_value FROM credentials WHERE id = ? AND type = 'password'",
credentialID,
).Scan(&encrypted)
if err != nil {
return "", fmt.Errorf("get encrypted password: %w", err)
}
if !encrypted.Valid {
return "", fmt.Errorf("no encrypted value for credential %d", credentialID)
}
password, err := s.vault.Decrypt(encrypted.String)
if err != nil {
return "", fmt.Errorf("decrypt password: %w", err)
}
return password, nil
}
// DecryptSSHKey returns the decrypted private key + passphrase.
func (s *CredentialService) DecryptSSHKey(sshKeyID int64) (privateKey []byte, passphrase string, err error) {
var encryptedKey string
var encryptedPassphrase sql.NullString
err = s.db.QueryRow(
"SELECT encrypted_private_key, passphrase_encrypted FROM ssh_keys WHERE id = ?",
sshKeyID,
).Scan(&encryptedKey, &encryptedPassphrase)
if err != nil {
return nil, "", fmt.Errorf("get encrypted ssh key: %w", err)
}
decryptedKey, err := s.vault.Decrypt(encryptedKey)
if err != nil {
return nil, "", fmt.Errorf("decrypt private key: %w", err)
}
if encryptedPassphrase.Valid {
passphrase, err = s.vault.Decrypt(encryptedPassphrase.String)
if err != nil {
return nil, "", fmt.Errorf("decrypt passphrase: %w", err)
}
}
return []byte(decryptedKey), passphrase, nil
}
// DeleteCredential removes a credential.
func (s *CredentialService) DeleteCredential(id int64) error {
_, err := s.db.Exec("DELETE FROM credentials WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete credential: %w", err)
}
return nil
}
// DeleteSSHKey removes an SSH key.
func (s *CredentialService) DeleteSSHKey(id int64) error {
_, err := s.db.Exec("DELETE FROM ssh_keys WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete ssh key: %w", err)
}
return nil
}
// DetectKeyType parses a PEM key and returns its type (rsa, ed25519, ecdsa).
func DetectKeyType(pemData []byte) string {
block, _ := pem.Decode(pemData)
if block == nil {
return "unknown"
}
// Try OpenSSH format first (ssh.MarshalPrivateKey produces OPENSSH PRIVATE KEY blocks)
if block.Type == "OPENSSH PRIVATE KEY" {
return detectOpenSSHKeyType(block.Bytes)
}
// Try PKCS8 format
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
switch key.(type) {
case *rsa.PrivateKey:
return "rsa"
case ed25519.PrivateKey:
return "ed25519"
case *ecdsa.PrivateKey:
return "ecdsa"
}
}
// Try RSA PKCS1
if _, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return "rsa"
}
// Try EC
if _, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
return "ecdsa"
}
return "unknown"
}
// detectOpenSSHKeyType parses the OpenSSH private key format to determine key type.
func detectOpenSSHKeyType(data []byte) string {
// OpenSSH private key format: "openssh-key-v1\0" magic, then fields.
// We parse the key using ssh package to determine the type.
// Re-encode to PEM to use ssh.ParsePrivateKey which gives us the signer.
pemBlock := &pem.Block{
Type: "OPENSSH PRIVATE KEY",
Bytes: data,
}
pemBytes := pem.EncodeToMemory(pemBlock)
signer, err := ssh.ParsePrivateKey(pemBytes)
if err != nil {
return "unknown"
}
return classifyPublicKey(signer.PublicKey())
}
// classifyPublicKey determines the key type from an ssh.PublicKey.
func classifyPublicKey(pub ssh.PublicKey) string {
keyType := pub.Type()
switch keyType {
case "ssh-rsa":
return "rsa"
case "ssh-ed25519":
return "ed25519"
case "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521":
return "ecdsa"
default:
return keyType
}
}
// getCredential retrieves a single credential by ID.
func (s *CredentialService) getCredential(id int64) (*Credential, error) {
var c Credential
var username, domain sql.NullString
err := s.db.QueryRow(
`SELECT id, name, username, domain, type, ssh_key_id, created_at, updated_at
FROM credentials WHERE id = ?`, id,
).Scan(&c.ID, &c.Name, &username, &domain, &c.Type, &c.SSHKeyID, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("get credential: %w", err)
}
if username.Valid {
c.Username = username.String
}
if domain.Valid {
c.Domain = domain.String
}
return &c, nil
}
// getSSHKey retrieves a single SSH key by ID (without private key data).
func (s *CredentialService) getSSHKey(id int64) (*SSHKey, error) {
var k SSHKey
var keyType, fingerprint, publicKey sql.NullString
err := s.db.QueryRow(
`SELECT id, name, key_type, fingerprint, public_key, created_at
FROM ssh_keys WHERE id = ?`, id,
).Scan(&k.ID, &k.Name, &keyType, &fingerprint, &publicKey, &k.CreatedAt)
if err != nil {
return nil, fmt.Errorf("get ssh key: %w", err)
}
if keyType.Valid {
k.KeyType = keyType.String
}
if fingerprint.Valid {
k.Fingerprint = fingerprint.String
}
if publicKey.Valid {
k.PublicKey = publicKey.String
}
return &k, nil
}
// generateFingerprint generates an SSH fingerprint string from a public key.
func generateFingerprint(pubKey ssh.PublicKey) string {
return ssh.FingerprintSHA256(pubKey)
}
// marshalPublicKey returns the authorized_keys format of an SSH public key.
func marshalPublicKey(pubKey ssh.PublicKey) string {
return base64.StdEncoding.EncodeToString(pubKey.Marshal())
}
// ecdsaCurveName returns the name for an ECDSA curve.
func ecdsaCurveName(curve elliptic.Curve) string {
switch curve {
case elliptic.P256():
return "nistp256"
case elliptic.P384():
return "nistp384"
case elliptic.P521():
return "nistp521"
default:
return "unknown"
}
}

View File

@ -1,176 +0,0 @@
package credentials
import (
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"path/filepath"
"testing"
"github.com/vstockwell/wraith/internal/db"
"github.com/vstockwell/wraith/internal/vault"
"golang.org/x/crypto/ssh"
)
func setupCredentialService(t *testing.T) *CredentialService {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(d); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
salt := []byte("test-salt-exactly-32-bytes-long!")
key := vault.DeriveKey("testpassword", salt)
vs := vault.NewVaultService(key)
return NewCredentialService(d, vs)
}
func TestCreatePasswordCredential(t *testing.T) {
svc := setupCredentialService(t)
cred, err := svc.CreatePassword("Test Cred", "admin", "secret123", "")
if err != nil {
t.Fatal(err)
}
if cred.Name != "Test Cred" {
t.Error("wrong name")
}
if cred.Type != "password" {
t.Error("wrong type")
}
}
func TestDecryptPassword(t *testing.T) {
svc := setupCredentialService(t)
cred, _ := svc.CreatePassword("Test", "admin", "mypassword", "")
password, err := svc.DecryptPassword(cred.ID)
if err != nil {
t.Fatal(err)
}
if password != "mypassword" {
t.Errorf("got %q, want mypassword", password)
}
}
func TestListCredentialsExcludesSecrets(t *testing.T) {
svc := setupCredentialService(t)
svc.CreatePassword("Cred1", "user1", "pass1", "")
svc.CreatePassword("Cred2", "user2", "pass2", "")
creds, err := svc.ListCredentials()
if err != nil {
t.Fatal(err)
}
if len(creds) != 2 {
t.Errorf("got %d, want 2", len(creds))
}
}
func TestCreateSSHKey(t *testing.T) {
svc := setupCredentialService(t)
// Generate a test key
_, priv, _ := ed25519.GenerateKey(rand.Reader)
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
keyPEM := pem.EncodeToMemory(pemBlock)
key, err := svc.CreateSSHKey("My Key", keyPEM, "")
if err != nil {
t.Fatal(err)
}
if key.KeyType != "ed25519" {
t.Errorf("KeyType = %q, want ed25519", key.KeyType)
}
if key.Fingerprint == "" {
t.Error("fingerprint should not be empty")
}
}
func TestDecryptSSHKey(t *testing.T) {
svc := setupCredentialService(t)
_, priv, _ := ed25519.GenerateKey(rand.Reader)
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
keyPEM := pem.EncodeToMemory(pemBlock)
key, _ := svc.CreateSSHKey("My Key", keyPEM, "testpass")
decryptedKey, passphrase, err := svc.DecryptSSHKey(key.ID)
if err != nil {
t.Fatal(err)
}
if len(decryptedKey) == 0 {
t.Error("decrypted key should not be empty")
}
if passphrase != "testpass" {
t.Errorf("passphrase = %q, want testpass", passphrase)
}
}
func TestDetectKeyType(t *testing.T) {
_, priv, _ := ed25519.GenerateKey(rand.Reader)
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
keyPEM := pem.EncodeToMemory(pemBlock)
if got := DetectKeyType(keyPEM); got != "ed25519" {
t.Errorf("got %q", got)
}
}
func TestDeleteCredential(t *testing.T) {
svc := setupCredentialService(t)
cred, _ := svc.CreatePassword("ToDelete", "user", "pass", "")
err := svc.DeleteCredential(cred.ID)
if err != nil {
t.Fatal(err)
}
creds, _ := svc.ListCredentials()
if len(creds) != 0 {
t.Errorf("got %d credentials, want 0", len(creds))
}
}
func TestDeleteSSHKey(t *testing.T) {
svc := setupCredentialService(t)
_, priv, _ := ed25519.GenerateKey(rand.Reader)
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
keyPEM := pem.EncodeToMemory(pemBlock)
key, _ := svc.CreateSSHKey("ToDelete", keyPEM, "")
err := svc.DeleteSSHKey(key.ID)
if err != nil {
t.Fatal(err)
}
keys, _ := svc.ListSSHKeys()
if len(keys) != 0 {
t.Errorf("got %d keys, want 0", len(keys))
}
}
func TestListSSHKeys(t *testing.T) {
svc := setupCredentialService(t)
_, priv, _ := ed25519.GenerateKey(rand.Reader)
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
keyPEM := pem.EncodeToMemory(pemBlock)
svc.CreateSSHKey("Key1", keyPEM, "")
svc.CreateSSHKey("Key2", keyPEM, "")
keys, err := svc.ListSSHKeys()
if err != nil {
t.Fatal(err)
}
if len(keys) != 2 {
t.Errorf("got %d keys, want 2", len(keys))
}
}
func TestCreatePasswordWithDomain(t *testing.T) {
svc := setupCredentialService(t)
cred, err := svc.CreatePassword("Domain Cred", "admin", "secret", "example.com")
if err != nil {
t.Fatal(err)
}
if cred.Domain != "example.com" {
t.Errorf("domain = %q, want example.com", cred.Domain)
}
}

View File

@ -1,34 +0,0 @@
package db
import (
"database/sql"
"embed"
"fmt"
"sort"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
func Migrate(db *sql.DB) error {
entries, err := migrationFiles.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
for _, entry := range entries {
content, err := migrationFiles.ReadFile("migrations/" + entry.Name())
if err != nil {
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
}
if _, err := db.Exec(string(content)); err != nil {
return fmt.Errorf("execute migration %s: %w", entry.Name(), err)
}
}
return nil
}

View File

@ -1,101 +0,0 @@
-- 001_initial.sql
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
sort_order INTEGER DEFAULT 0,
icon TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS ssh_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key_type TEXT,
fingerprint TEXT,
public_key TEXT,
encrypted_private_key TEXT NOT NULL,
passphrase_encrypted TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
username TEXT,
domain TEXT,
type TEXT NOT NULL CHECK(type IN ('password','ssh_key')),
encrypted_value TEXT,
ssh_key_id INTEGER REFERENCES ssh_keys(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
hostname TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 22,
protocol TEXT NOT NULL CHECK(protocol IN ('ssh','rdp')),
group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL,
color TEXT,
tags TEXT DEFAULT '[]',
notes TEXT,
options TEXT DEFAULT '{}',
sort_order INTEGER DEFAULT 0,
last_connected DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
foreground TEXT NOT NULL,
background TEXT NOT NULL,
cursor TEXT NOT NULL,
black TEXT NOT NULL,
red TEXT NOT NULL,
green TEXT NOT NULL,
yellow TEXT NOT NULL,
blue TEXT NOT NULL,
magenta TEXT NOT NULL,
cyan TEXT NOT NULL,
white TEXT NOT NULL,
bright_black TEXT NOT NULL,
bright_red TEXT NOT NULL,
bright_green TEXT NOT NULL,
bright_yellow TEXT NOT NULL,
bright_blue TEXT NOT NULL,
bright_magenta TEXT NOT NULL,
bright_cyan TEXT NOT NULL,
bright_white TEXT NOT NULL,
selection_bg TEXT,
selection_fg TEXT,
is_builtin BOOLEAN DEFAULT 0
);
CREATE TABLE IF NOT EXISTS connection_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
protocol TEXT NOT NULL,
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
disconnected_at DATETIME,
duration_secs INTEGER
);
CREATE TABLE IF NOT EXISTS host_keys (
hostname TEXT NOT NULL,
port INTEGER NOT NULL,
key_type TEXT NOT NULL,
fingerprint TEXT NOT NULL,
raw_key TEXT,
first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (hostname, port, key_type)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);

View File

@ -1,39 +0,0 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
func Open(dbPath string) (*sql.DB, error) {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("create db directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, fmt.Errorf("set WAL mode: %w", err)
}
if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
db.Close()
return nil, fmt.Errorf("set busy_timeout: %w", err)
}
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
db.Close()
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
return db, nil
}

View File

@ -1,85 +0,0 @@
package db
import (
"os"
"path/filepath"
"testing"
)
func TestOpenCreatesDatabase(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
db, err := Open(dbPath)
if err != nil {
t.Fatalf("Open() error: %v", err)
}
defer db.Close()
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
t.Fatal("database file was not created")
}
}
func TestOpenSetsWALMode(t *testing.T) {
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("Open() error: %v", err)
}
defer db.Close()
var mode string
err = db.QueryRow("PRAGMA journal_mode").Scan(&mode)
if err != nil {
t.Fatalf("PRAGMA query error: %v", err)
}
if mode != "wal" {
t.Errorf("journal_mode = %q, want %q", mode, "wal")
}
}
func TestOpenSetsBusyTimeout(t *testing.T) {
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("Open() error: %v", err)
}
defer db.Close()
var timeout int
err = db.QueryRow("PRAGMA busy_timeout").Scan(&timeout)
if err != nil {
t.Fatalf("PRAGMA query error: %v", err)
}
if timeout != 5000 {
t.Errorf("busy_timeout = %d, want %d", timeout, 5000)
}
}
func TestMigrateCreatesAllTables(t *testing.T) {
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("Open() error: %v", err)
}
defer db.Close()
if err := Migrate(db); err != nil {
t.Fatalf("Migrate() error: %v", err)
}
expectedTables := []string{
"groups", "connections", "credentials", "ssh_keys",
"themes", "connection_history", "host_keys", "settings",
}
for _, table := range expectedTables {
var name string
err := db.QueryRow(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", table,
).Scan(&name)
if err != nil {
t.Errorf("table %q not found: %v", table, err)
}
}
}

View File

@ -1,322 +0,0 @@
package importer
import (
"fmt"
"strconv"
"strings"
"github.com/vstockwell/wraith/internal/plugin"
)
// MobaConfImporter parses MobaXTerm .mobaconf configuration files and extracts
// connections, groups, host keys, and terminal theme settings.
type MobaConfImporter struct{}
func (m *MobaConfImporter) Name() string { return "MobaXTerm" }
func (m *MobaConfImporter) FileExtensions() []string { return []string{".mobaconf"} }
// Parse reads a .mobaconf file and returns the extracted import result.
func (m *MobaConfImporter) Parse(data []byte) (*plugin.ImportResult, error) {
result := &plugin.ImportResult{
Groups: []plugin.ImportGroup{},
Connections: []plugin.ImportConnection{},
HostKeys: []plugin.ImportHostKey{},
}
sections := parseSections(string(data))
// Parse [Bookmarks_N] sections
for name, lines := range sections {
if !strings.HasPrefix(name, "Bookmarks") {
continue
}
groupName := ""
for _, line := range lines {
key, value := splitKV(line)
if key == "SubRep" && value != "" {
groupName = value
result.Groups = append(result.Groups, plugin.ImportGroup{
Name: groupName,
})
}
}
// Parse session lines within the bookmarks section
for _, line := range lines {
key, value := splitKV(line)
if key == "SubRep" || key == "ImgNum" || key == "" {
continue
}
// Session lines start with a name (possibly prefixed with *)
// and contain = followed by the session definition
if value == "" {
continue
}
sessionName := key
// Strip leading * from session names (marks favorites)
sessionName = strings.TrimPrefix(sessionName, "*")
conn := parseSessionLine(sessionName, value)
if conn != nil {
conn.GroupName = groupName
result.Connections = append(result.Connections, *conn)
}
}
}
// Parse [SSH_Hostkeys] section
if hostKeyLines, ok := sections["SSH_Hostkeys"]; ok {
for _, line := range hostKeyLines {
key, value := splitKV(line)
if key == "" || value == "" {
continue
}
hk := parseHostKeyLine(key, value)
if hk != nil {
result.HostKeys = append(result.HostKeys, *hk)
}
}
}
// Parse [Colors] section
if colorLines, ok := sections["Colors"]; ok {
theme := parseColorSection(colorLines)
if theme != nil {
result.Theme = theme
}
}
return result, nil
}
// parseSections splits .mobaconf INI content into named sections.
// Returns a map of section name -> lines within that section.
func parseSections(content string) map[string][]string {
sections := make(map[string][]string)
currentSection := ""
for _, line := range strings.Split(content, "\n") {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
currentSection = line[1 : len(line)-1]
if _, exists := sections[currentSection]; !exists {
sections[currentSection] = []string{}
}
continue
}
if currentSection != "" && line != "" {
sections[currentSection] = append(sections[currentSection], line)
}
}
return sections
}
// splitKV splits a line on the first = sign into key and value.
func splitKV(line string) (string, string) {
idx := strings.Index(line, "=")
if idx < 0 {
return line, ""
}
return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:])
}
// parseSessionLine parses a MobaXTerm session definition string.
// Format: #type#flags%field1%field2%...
// SSH (#109#): fields[0]=host, fields[1]=port, fields[2]=username
// RDP (#91#): fields[0]=host, fields[1]=port, fields[2]=username
func parseSessionLine(name, value string) *plugin.ImportConnection {
// Find the protocol type marker
hashIdx := strings.Index(value, "#")
if hashIdx < 0 {
return nil
}
// Extract the type number between # markers
rest := value[hashIdx+1:]
secondHash := strings.Index(rest, "#")
if secondHash < 0 {
return nil
}
typeStr := rest[:secondHash]
afterType := rest[secondHash+1:]
// The remainder after the type and flags section: flags%field1%field2%...
// Split on the first % to separate flags from fields, then split the rest
parts := strings.Split(afterType, "%")
if len(parts) < 2 {
return nil
}
// parts[0] = flags (numeric), parts[1:] = fields
fields := parts[1:]
conn := &plugin.ImportConnection{
Name: name,
}
switch typeStr {
case "109": // SSH
conn.Protocol = "ssh"
if len(fields) >= 1 {
conn.Hostname = fields[0]
}
if len(fields) >= 2 {
port, err := strconv.Atoi(fields[1])
if err == nil && port > 0 {
conn.Port = port
} else {
conn.Port = 22
}
} else {
conn.Port = 22
}
if len(fields) >= 3 {
conn.Username = fields[2]
}
case "91": // RDP
conn.Protocol = "rdp"
if len(fields) >= 1 {
conn.Hostname = fields[0]
}
if len(fields) >= 2 {
port, err := strconv.Atoi(fields[1])
if err == nil && port > 0 {
conn.Port = port
} else {
conn.Port = 3389
}
} else {
conn.Port = 3389
}
if len(fields) >= 3 {
conn.Username = fields[2]
}
default:
// Unknown protocol type — skip
return nil
}
if conn.Hostname == "" {
return nil
}
return conn
}
// parseHostKeyLine parses a line from the [SSH_Hostkeys] section.
// Key format: keytype@port:hostname
// Value: the fingerprint data
func parseHostKeyLine(key, value string) *plugin.ImportHostKey {
// Parse key format: keytype@port:hostname
atIdx := strings.Index(key, "@")
if atIdx < 0 {
return nil
}
keyType := key[:atIdx]
rest := key[atIdx+1:]
colonIdx := strings.Index(rest, ":")
if colonIdx < 0 {
return nil
}
portStr := rest[:colonIdx]
hostname := rest[colonIdx+1:]
port, err := strconv.Atoi(portStr)
if err != nil {
return nil
}
return &plugin.ImportHostKey{
Hostname: hostname,
Port: port,
KeyType: keyType,
Fingerprint: value,
}
}
// parseColorSection extracts a terminal theme from the [Colors] section.
func parseColorSection(lines []string) *plugin.ImportTheme {
colorMap := make(map[string]string)
for _, line := range lines {
key, value := splitKV(line)
if key != "" && value != "" {
colorMap[key] = value
}
}
// Need at least foreground and background to form a theme
fg, hasFg := colorMap["ForegroundColour"]
bg, hasBg := colorMap["BackgroundColour"]
if !hasFg || !hasBg {
return nil
}
theme := &plugin.ImportTheme{
Name: "MobaXTerm Import",
Foreground: rgbToHex(fg),
Background: rgbToHex(bg),
}
if cursor, ok := colorMap["CursorColour"]; ok {
theme.Cursor = rgbToHex(cursor)
}
// Map MobaXTerm color names to the 16-color array positions
// Standard ANSI order: black, red, green, yellow, blue, magenta, cyan, white,
// then bright variants in the same order
colorNames := [16][2]string{
{"Black", ""},
{"Red", ""},
{"Green", ""},
{"Yellow", ""},
{"Blue", ""},
{"Magenta", ""},
{"Cyan", ""},
{"White", ""},
{"BoldBlack", ""},
{"BoldRed", ""},
{"BoldGreen", ""},
{"BoldYellow", ""},
{"BoldBlue", ""},
{"BoldMagenta", ""},
{"BoldCyan", ""},
{"BoldWhite", ""},
}
for i, cn := range colorNames {
name := cn[0]
if val, ok := colorMap[name]; ok {
theme.Colors[i] = rgbToHex(val)
}
}
return theme
}
// rgbToHex converts a "R,G,B" string to "#RRGGBB" hex format.
func rgbToHex(rgb string) string {
parts := strings.Split(rgb, ",")
if len(parts) != 3 {
return rgb // Return as-is if not in expected format
}
r, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
g, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
b, err3 := strconv.Atoi(strings.TrimSpace(parts[2]))
if err1 != nil || err2 != nil || err3 != nil {
return rgb
}
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}

View File

@ -1,236 +0,0 @@
package importer
import (
"os"
"testing"
)
func TestParseMobaConf(t *testing.T) {
data, err := os.ReadFile("../../docs/config-export.mobaconf")
if err != nil {
t.Skip("config file not found")
}
imp := &MobaConfImporter{}
result, err := imp.Parse(data)
if err != nil {
t.Fatal(err)
}
if len(result.Groups) == 0 {
t.Error("should parse groups")
}
if len(result.Connections) == 0 {
t.Error("should parse connections")
}
// Check that we found the expected group
foundGroup := false
for _, g := range result.Groups {
if g.Name == "AAA Vantz's Stuff" {
foundGroup = true
break
}
}
if !foundGroup {
t.Error("should find 'AAA Vantz's Stuff' group")
}
// Check for known SSH connections
foundAsgard := false
foundDocker := false
for _, c := range result.Connections {
if c.Name == "Asgard" {
foundAsgard = true
if c.Hostname != "192.168.1.4" {
t.Errorf("Asgard hostname = %q, want 192.168.1.4", c.Hostname)
}
if c.Port != 22 {
t.Errorf("Asgard port = %d, want 22", c.Port)
}
if c.Protocol != "ssh" {
t.Errorf("Asgard protocol = %q, want ssh", c.Protocol)
}
if c.Username != "vstockwell" {
t.Errorf("Asgard username = %q, want vstockwell", c.Username)
}
}
if c.Name == "Docker" {
foundDocker = true
if c.Hostname != "155.254.29.221" {
t.Errorf("Docker hostname = %q, want 155.254.29.221", c.Hostname)
}
}
}
if !foundAsgard {
t.Error("should find Asgard connection")
}
if !foundDocker {
t.Error("should find Docker connection")
}
// Check for RDP connections
foundRDP := false
for _, c := range result.Connections {
if c.Name == "CLT-VMHOST01" {
foundRDP = true
if c.Protocol != "rdp" {
t.Errorf("CLT-VMHOST01 protocol = %q, want rdp", c.Protocol)
}
if c.Hostname != "100.64.1.204" {
t.Errorf("CLT-VMHOST01 hostname = %q, want 100.64.1.204", c.Hostname)
}
if c.Port != 3389 {
t.Errorf("CLT-VMHOST01 port = %d, want 3389", c.Port)
}
}
}
if !foundRDP {
t.Error("should find CLT-VMHOST01 RDP connection")
}
// Check host keys were parsed
if len(result.HostKeys) == 0 {
t.Error("should parse host keys")
}
// Check theme was parsed
if result.Theme == nil {
t.Error("should parse theme from Colors section")
} else {
if result.Theme.Foreground != "#ececec" {
t.Errorf("theme foreground = %q, want #ececec", result.Theme.Foreground)
}
if result.Theme.Background != "#242424" {
t.Errorf("theme background = %q, want #242424", result.Theme.Background)
}
}
}
func TestParseSSHSession(t *testing.T) {
line := `#109#0%192.168.1.4%22%vstockwell%%-1%-1%%%22%%0%0%0%V:\ssh-key%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0`
conn := parseSessionLine("Asgard", line)
if conn == nil {
t.Fatal("should parse SSH session")
}
if conn.Hostname != "192.168.1.4" {
t.Errorf("hostname = %q, want 192.168.1.4", conn.Hostname)
}
if conn.Port != 22 {
t.Errorf("port = %d, want 22", conn.Port)
}
if conn.Protocol != "ssh" {
t.Errorf("protocol = %q, want ssh", conn.Protocol)
}
if conn.Username != "vstockwell" {
t.Errorf("username = %q, want vstockwell", conn.Username)
}
}
func TestParseRDPSession(t *testing.T) {
line := `#91#4%100.64.1.204%3389%%-1%0%0%0%-1%0%0%-1`
conn := parseSessionLine("CLT-VMHOST01", line)
if conn == nil {
t.Fatal("should parse RDP session")
}
if conn.Hostname != "100.64.1.204" {
t.Errorf("hostname = %q, want 100.64.1.204", conn.Hostname)
}
if conn.Port != 3389 {
t.Errorf("port = %d, want 3389", conn.Port)
}
if conn.Protocol != "rdp" {
t.Errorf("protocol = %q, want rdp", conn.Protocol)
}
}
func TestParseSSHSessionWithUsername(t *testing.T) {
line := `#109#0%192.168.1.105%22%root%%-1%-1%%%%%0%0%0%%%-1%0%0%0%%1080%%0%0%1`
conn := parseSessionLine("Node 1(top)", line)
if conn == nil {
t.Fatal("should parse SSH session")
}
if conn.Username != "root" {
t.Errorf("username = %q, want root", conn.Username)
}
}
func TestParseRDPSessionWithUsername(t *testing.T) {
line := `#91#4%192.154.253.107%3389%administrator%0%0%0%0%-1%0%0%-1`
conn := parseSessionLine("Win Game Host", line)
if conn == nil {
t.Fatal("should parse RDP session")
}
if conn.Username != "administrator" {
t.Errorf("username = %q, want administrator", conn.Username)
}
if conn.Hostname != "192.154.253.107" {
t.Errorf("hostname = %q, want 192.154.253.107", conn.Hostname)
}
}
func TestRgbToHex(t *testing.T) {
tests := []struct {
input string
want string
}{
{"236,236,236", "#ececec"},
{"0,0,0", "#000000"},
{"255,255,255", "#ffffff"},
{"36,36,36", "#242424"},
{"128,128,128", "#808080"},
}
for _, tt := range tests {
got := rgbToHex(tt.input)
if got != tt.want {
t.Errorf("rgbToHex(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestParseHostKeyLine(t *testing.T) {
hk := parseHostKeyLine(
"ssh-ed25519@22:192.168.1.4",
"0x29ac3a21e1d5166c45aed41398d71cc889b683d01e1a019bf23cb2e1ce1c8276,0x2a8e2417caf686ac4b219cc3b94cd726fb49d2559bd8725ac2281b842845582b",
)
if hk == nil {
t.Fatal("should parse host key")
}
if hk.Hostname != "192.168.1.4" {
t.Errorf("hostname = %q, want 192.168.1.4", hk.Hostname)
}
if hk.Port != 22 {
t.Errorf("port = %d, want 22", hk.Port)
}
if hk.KeyType != "ssh-ed25519" {
t.Errorf("keyType = %q, want ssh-ed25519", hk.KeyType)
}
}
func TestImporterInterface(t *testing.T) {
imp := &MobaConfImporter{}
if imp.Name() != "MobaXTerm" {
t.Errorf("Name() = %q, want MobaXTerm", imp.Name())
}
exts := imp.FileExtensions()
if len(exts) != 1 || exts[0] != ".mobaconf" {
t.Errorf("FileExtensions() = %v, want [.mobaconf]", exts)
}
}
func TestParseUnknownProtocol(t *testing.T) {
line := `#999#0%hostname%22%user`
conn := parseSessionLine("Unknown", line)
if conn != nil {
t.Error("should return nil for unknown protocol type")
}
}
func TestParseEmptySessionLine(t *testing.T) {
conn := parseSessionLine("Empty", "")
if conn != nil {
t.Error("should return nil for empty session line")
}
}

View File

@ -1,57 +0,0 @@
package plugin
type ProtocolHandler interface {
Name() string
Connect(config map[string]interface{}) (Session, error)
Disconnect(sessionID string) error
}
type Session interface {
ID() string
Protocol() string
Write(data []byte) error
Close() error
}
type Importer interface {
Name() string
FileExtensions() []string
Parse(data []byte) (*ImportResult, error)
}
type ImportResult struct {
Groups []ImportGroup `json:"groups"`
Connections []ImportConnection `json:"connections"`
HostKeys []ImportHostKey `json:"hostKeys"`
Theme *ImportTheme `json:"theme,omitempty"`
}
type ImportGroup struct {
Name string `json:"name"`
ParentName string `json:"parentName,omitempty"`
}
type ImportConnection struct {
Name string `json:"name"`
Hostname string `json:"hostname"`
Port int `json:"port"`
Protocol string `json:"protocol"`
Username string `json:"username"`
GroupName string `json:"groupName"`
Notes string `json:"notes"`
}
type ImportHostKey struct {
Hostname string `json:"hostname"`
Port int `json:"port"`
KeyType string `json:"keyType"`
Fingerprint string `json:"fingerprint"`
}
type ImportTheme struct {
Name string `json:"name"`
Foreground string `json:"foreground"`
Background string `json:"background"`
Cursor string `json:"cursor"`
Colors [16]string `json:"colors"`
}

View File

@ -1,47 +0,0 @@
package plugin
import "fmt"
type Registry struct {
protocols map[string]ProtocolHandler
importers map[string]Importer
}
func NewRegistry() *Registry {
return &Registry{
protocols: make(map[string]ProtocolHandler),
importers: make(map[string]Importer),
}
}
func (r *Registry) RegisterProtocol(handler ProtocolHandler) {
r.protocols[handler.Name()] = handler
}
func (r *Registry) RegisterImporter(imp Importer) {
r.importers[imp.Name()] = imp
}
func (r *Registry) GetProtocol(name string) (ProtocolHandler, error) {
h, ok := r.protocols[name]
if !ok {
return nil, fmt.Errorf("protocol handler %q not registered", name)
}
return h, nil
}
func (r *Registry) GetImporter(name string) (Importer, error) {
imp, ok := r.importers[name]
if !ok {
return nil, fmt.Errorf("importer %q not registered", name)
}
return imp, nil
}
func (r *Registry) ListProtocols() []string {
names := make([]string, 0, len(r.protocols))
for name := range r.protocols {
names = append(names, name)
}
return names
}

View File

@ -1,14 +0,0 @@
package rdp
import "runtime"
// NewProductionBackend returns the appropriate RDP backend for the current
// platform. On Windows it returns a FreeRDPBackend that loads freerdp3.dll
// at runtime via syscall. On other platforms it falls back to MockBackend
// so the application can still be developed and tested without FreeRDP.
func NewProductionBackend() RDPBackend {
if runtime.GOOS == "windows" {
return NewFreeRDPBackend()
}
return NewMockBackend()
}

View File

@ -1,45 +0,0 @@
//go:build !windows
package rdp
import "fmt"
// FreeRDPBackend is a stub on non-Windows platforms. The real implementation
// lives in freerdp_windows.go and loads FreeRDP3 DLLs via syscall at runtime.
type FreeRDPBackend struct{}
// NewFreeRDPBackend creates a stub backend that returns errors on all operations.
func NewFreeRDPBackend() *FreeRDPBackend {
return &FreeRDPBackend{}
}
func (f *FreeRDPBackend) Connect(config RDPConfig) error {
return fmt.Errorf("FreeRDP backend is only available on Windows — use MockBackend for development")
}
func (f *FreeRDPBackend) Disconnect() error {
return nil
}
func (f *FreeRDPBackend) SendMouseEvent(x, y int, flags uint32) error {
return fmt.Errorf("FreeRDP backend is not available on this platform")
}
func (f *FreeRDPBackend) SendKeyEvent(scancode uint32, pressed bool) error {
return fmt.Errorf("FreeRDP backend is not available on this platform")
}
func (f *FreeRDPBackend) SendClipboard(data string) error {
return fmt.Errorf("FreeRDP backend is not available on this platform")
}
func (f *FreeRDPBackend) GetFrame() ([]byte, error) {
return nil, fmt.Errorf("FreeRDP backend is not available on this platform")
}
func (f *FreeRDPBackend) IsConnected() bool {
return false
}
// Ensure FreeRDPBackend satisfies the RDPBackend interface at compile time.
var _ RDPBackend = (*FreeRDPBackend)(nil)

View File

@ -1,289 +0,0 @@
//go:build windows
package rdp
import (
"fmt"
"sync"
"syscall"
"time"
"unsafe"
)
var (
libfreerdp = syscall.NewLazyDLL("libfreerdp3.dll")
libfreerdpClient = syscall.NewLazyDLL("libfreerdp-client3.dll")
// Instance lifecycle
procFreerdpNew = libfreerdp.NewProc("freerdp_new")
procFreerdpFree = libfreerdp.NewProc("freerdp_free")
procFreerdpConnect = libfreerdp.NewProc("freerdp_connect")
procFreerdpDisconnect = libfreerdp.NewProc("freerdp_disconnect")
// Settings
procSettingsSetString = libfreerdp.NewProc("freerdp_settings_set_string")
procSettingsSetUint32 = libfreerdp.NewProc("freerdp_settings_set_uint32")
procSettingsSetBool = libfreerdp.NewProc("freerdp_settings_set_bool")
// Input
procInputSendMouse = libfreerdp.NewProc("freerdp_input_send_mouse_event")
procInputSendKeyboard = libfreerdp.NewProc("freerdp_input_send_keyboard_event")
// Event loop
procCheckEventHandles = libfreerdp.NewProc("freerdp_check_event_handles")
// Client helpers
procClientNew = libfreerdpClient.NewProc("freerdp_client_context_new")
procClientFree = libfreerdpClient.NewProc("freerdp_client_context_free")
)
// FreeRDP settings IDs (from FreeRDP3 freerdp/settings.h)
const (
FreeRDP_ServerHostname = 20
FreeRDP_ServerPort = 21
FreeRDP_Username = 22
FreeRDP_Password = 23
FreeRDP_Domain = 24
FreeRDP_DesktopWidth = 1025
FreeRDP_DesktopHeight = 1026
FreeRDP_ColorDepth = 1027
FreeRDP_FullscreenMode = 1028
FreeRDP_IgnoreCertificate = 4556
FreeRDP_AuthenticationOnly = 4554
FreeRDP_NlaSecurity = 4560
FreeRDP_TlsSecurity = 4561
FreeRDP_RdpSecurity = 4562
)
// Keyboard event flags for FreeRDP input calls.
const (
KBD_FLAGS_EXTENDED = 0x0100
KBD_FLAGS_DOWN = 0x4000
KBD_FLAGS_RELEASE = 0x8000
)
// FreeRDPBackend implements RDPBackend using the FreeRDP3 library loaded at
// runtime via syscall.NewLazyDLL. This avoids any CGO dependency and allows
// cross-compilation from Linux while the DLLs are resolved at runtime on
// Windows.
type FreeRDPBackend struct {
instance uintptr // freerdp*
settings uintptr // rdpSettings*
input uintptr // rdpInput*
buffer *PixelBuffer
connected bool
config RDPConfig
mu sync.Mutex
stopCh chan struct{}
}
// NewFreeRDPBackend creates a new FreeRDP-backed RDP backend. The underlying
// DLLs are not loaded until Connect is called.
func NewFreeRDPBackend() *FreeRDPBackend {
return &FreeRDPBackend{
stopCh: make(chan struct{}),
}
}
// Connect establishes an RDP session using FreeRDP3. It creates a new FreeRDP
// instance, configures connection settings, and starts the event loop.
func (f *FreeRDPBackend) Connect(config RDPConfig) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.connected {
return fmt.Errorf("already connected")
}
f.config = config
// Create a bare FreeRDP instance.
ret, _, err := procFreerdpNew.Call()
if ret == 0 {
return fmt.Errorf("freerdp_new failed: %v", err)
}
f.instance = ret
// Configure connection settings via the settings accessor functions.
f.setString(FreeRDP_ServerHostname, config.Hostname)
f.setUint32(FreeRDP_ServerPort, uint32(config.Port))
f.setString(FreeRDP_Username, config.Username)
f.setString(FreeRDP_Password, config.Password)
if config.Domain != "" {
f.setString(FreeRDP_Domain, config.Domain)
}
// Display settings with sensible defaults.
width := config.Width
if width == 0 {
width = 1920
}
height := config.Height
if height == 0 {
height = 1080
}
f.setUint32(FreeRDP_DesktopWidth, uint32(width))
f.setUint32(FreeRDP_DesktopHeight, uint32(height))
colorDepth := config.ColorDepth
if colorDepth == 0 {
colorDepth = 32
}
f.setUint32(FreeRDP_ColorDepth, uint32(colorDepth))
// Security mode selection.
switch config.Security {
case "nla":
f.setBool(FreeRDP_NlaSecurity, true)
case "tls":
f.setBool(FreeRDP_TlsSecurity, true)
case "rdp":
f.setBool(FreeRDP_RdpSecurity, true)
default:
f.setBool(FreeRDP_NlaSecurity, true)
}
// Accept all server certificates. Per-host pinning is a future enhancement.
f.setBool(FreeRDP_IgnoreCertificate, true)
// Allocate the pixel buffer for frame capture.
f.buffer = NewPixelBuffer(width, height)
// TODO: Register PostConnect callback to set up bitmap update handler.
// TODO: Register BitmapUpdate callback to write frames into f.buffer.
// Initiate the RDP connection.
ret, _, err = procFreerdpConnect.Call(f.instance)
if ret == 0 {
procFreerdpFree.Call(f.instance)
f.instance = 0
return fmt.Errorf("freerdp_connect failed: %v", err)
}
f.connected = true
// Start the event processing loop in a background goroutine.
go f.eventLoop()
return nil
}
// eventLoop polls FreeRDP for incoming events at roughly 60 fps. It runs
// until the stop channel is closed or the connection is marked as inactive.
func (f *FreeRDPBackend) eventLoop() {
for {
select {
case <-f.stopCh:
return
default:
f.mu.Lock()
if !f.connected {
f.mu.Unlock()
return
}
procCheckEventHandles.Call(f.instance)
f.mu.Unlock()
time.Sleep(16 * time.Millisecond) // ~60 fps
}
}
}
// Disconnect tears down the RDP session and frees the FreeRDP instance.
func (f *FreeRDPBackend) Disconnect() error {
f.mu.Lock()
defer f.mu.Unlock()
if !f.connected {
return nil
}
close(f.stopCh)
f.connected = false
procFreerdpDisconnect.Call(f.instance)
procFreerdpFree.Call(f.instance)
f.instance = 0
return nil
}
// SendMouseEvent forwards a mouse event to the remote RDP session.
func (f *FreeRDPBackend) SendMouseEvent(x, y int, flags uint32) error {
f.mu.Lock()
defer f.mu.Unlock()
if !f.connected || f.input == 0 {
return fmt.Errorf("not connected")
}
procInputSendMouse.Call(f.input, uintptr(flags), uintptr(x), uintptr(y))
return nil
}
// SendKeyEvent forwards a keyboard event to the remote RDP session. The
// scancode uses the same format as the ScancodeMap in input.go — extended
// keys have the 0xE0 prefix in the high byte.
func (f *FreeRDPBackend) SendKeyEvent(scancode uint32, pressed bool) error {
f.mu.Lock()
defer f.mu.Unlock()
if !f.connected || f.input == 0 {
return fmt.Errorf("not connected")
}
var flags uint32
if pressed {
flags = KBD_FLAGS_DOWN
} else {
flags = KBD_FLAGS_RELEASE
}
if scancode > 0xFF {
flags |= KBD_FLAGS_EXTENDED
}
procInputSendKeyboard.Call(f.input, uintptr(flags), uintptr(scancode&0xFF))
return nil
}
// SendClipboard sends clipboard text to the remote session.
// TODO: Implement via the FreeRDP cliprdr channel.
func (f *FreeRDPBackend) SendClipboard(data string) error {
return nil
}
// GetFrame returns the current full-frame RGBA pixel buffer. The frame is
// populated by bitmap update callbacks registered during PostConnect.
func (f *FreeRDPBackend) GetFrame() ([]byte, error) {
if f.buffer == nil {
return nil, fmt.Errorf("no frame buffer")
}
return f.buffer.GetFrame(), nil
}
// IsConnected reports whether the backend has an active RDP connection.
func (f *FreeRDPBackend) IsConnected() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.connected
}
// setString sets a string setting on the FreeRDP instance.
func (f *FreeRDPBackend) setString(id int, value string) {
b, _ := syscall.BytePtrFromString(value)
procSettingsSetString.Call(f.settings, uintptr(id), uintptr(unsafe.Pointer(b)))
}
// setUint32 sets a uint32 setting on the FreeRDP instance.
func (f *FreeRDPBackend) setUint32(id int, value uint32) {
procSettingsSetUint32.Call(f.settings, uintptr(id), uintptr(value))
}
// setBool sets a boolean setting on the FreeRDP instance.
func (f *FreeRDPBackend) setBool(id int, value bool) {
v := uintptr(0)
if value {
v = 1
}
procSettingsSetBool.Call(f.settings, uintptr(id), v)
}
// Ensure FreeRDPBackend satisfies the RDPBackend interface at compile time.
var _ RDPBackend = (*FreeRDPBackend)(nil)

View File

@ -1,189 +0,0 @@
package rdp
// RDP mouse event flags — these match the MS-RDPBCGR specification.
const (
MouseFlagMove uint32 = 0x0800 // Mouse moved (no button change)
MouseFlagButton1 uint32 = 0x1000 // Left button
MouseFlagButton2 uint32 = 0x2000 // Right button
MouseFlagButton3 uint32 = 0x4000 // Middle button
MouseFlagDown uint32 = 0x8000 // Button pressed (absence = released)
// Extended mouse flags for wheel events
MouseFlagWheel uint32 = 0x0200 // Vertical wheel rotation
MouseFlagWheelNeg uint32 = 0x0100 // Negative wheel direction (scroll down)
MouseFlagHWheel uint32 = 0x0400 // Horizontal wheel rotation
)
// ScancodeMap maps JavaScript KeyboardEvent.code strings to RDP hardware
// scancodes (Set 1 / XT scan codes). This covers the standard US 104-key
// layout. Extended keys (those with a 0xE0 prefix on the wire) have the
// high byte set to 0xE0.
//
// Reference: USB HID to PS/2 scancode mapping + MS-RDPBCGR 2.2.8.1.1.3.1.1.1
var ScancodeMap = map[string]uint32{
// ── Row 0: Escape + Function keys ──────────────────────────────
"Escape": 0x0001,
"F1": 0x003B,
"F2": 0x003C,
"F3": 0x003D,
"F4": 0x003E,
"F5": 0x003F,
"F6": 0x0040,
"F7": 0x0041,
"F8": 0x0042,
"F9": 0x0043,
"F10": 0x0044,
"F11": 0x0057,
"F12": 0x0058,
// ── Row 1: Number row ──────────────────────────────────────────
"Backquote": 0x0029, // ` ~
"Digit1": 0x0002,
"Digit2": 0x0003,
"Digit3": 0x0004,
"Digit4": 0x0005,
"Digit5": 0x0006,
"Digit6": 0x0007,
"Digit7": 0x0008,
"Digit8": 0x0009,
"Digit9": 0x000A,
"Digit0": 0x000B,
"Minus": 0x000C, // - _
"Equal": 0x000D, // = +
"Backspace": 0x000E,
// ── Row 2: QWERTY row ─────────────────────────────────────────
"Tab": 0x000F,
"KeyQ": 0x0010,
"KeyW": 0x0011,
"KeyE": 0x0012,
"KeyR": 0x0013,
"KeyT": 0x0014,
"KeyY": 0x0015,
"KeyU": 0x0016,
"KeyI": 0x0017,
"KeyO": 0x0018,
"KeyP": 0x0019,
"BracketLeft": 0x001A, // [ {
"BracketRight":0x001B, // ] }
"Backslash": 0x002B, // \ |
// ── Row 3: Home row ───────────────────────────────────────────
"CapsLock": 0x003A,
"KeyA": 0x001E,
"KeyS": 0x001F,
"KeyD": 0x0020,
"KeyF": 0x0021,
"KeyG": 0x0022,
"KeyH": 0x0023,
"KeyJ": 0x0024,
"KeyK": 0x0025,
"KeyL": 0x0026,
"Semicolon": 0x0027, // ; :
"Quote": 0x0028, // ' "
"Enter": 0x001C,
// ── Row 4: Bottom row ─────────────────────────────────────────
"ShiftLeft": 0x002A,
"KeyZ": 0x002C,
"KeyX": 0x002D,
"KeyC": 0x002E,
"KeyV": 0x002F,
"KeyB": 0x0030,
"KeyN": 0x0031,
"KeyM": 0x0032,
"Comma": 0x0033, // , <
"Period": 0x0034, // . >
"Slash": 0x0035, // / ?
"ShiftRight": 0x0036,
// ── Row 5: Bottom modifiers + space ───────────────────────────
"ControlLeft": 0x001D,
"MetaLeft": 0xE05B, // Left Windows / Super
"AltLeft": 0x0038,
"Space": 0x0039,
"AltRight": 0xE038, // Right Alt (extended)
"MetaRight": 0xE05C, // Right Windows / Super
"ContextMenu": 0xE05D, // Application / Menu key
"ControlRight": 0xE01D, // Right Ctrl (extended)
// ── Navigation cluster ────────────────────────────────────────
"PrintScreen": 0xE037,
"ScrollLock": 0x0046,
"Pause": 0x0045, // Note: Pause has special handling on wire
"Insert": 0xE052,
"Home": 0xE047,
"PageUp": 0xE049,
"Delete": 0xE053,
"End": 0xE04F,
"PageDown": 0xE051,
// ── Arrow keys ────────────────────────────────────────────────
"ArrowUp": 0xE048,
"ArrowLeft": 0xE04B,
"ArrowDown": 0xE050,
"ArrowRight": 0xE04D,
// ── Numpad ────────────────────────────────────────────────────
"NumLock": 0x0045,
"NumpadDivide": 0xE035,
"NumpadMultiply":0x0037,
"NumpadSubtract":0x004A,
"Numpad7": 0x0047,
"Numpad8": 0x0048,
"Numpad9": 0x0049,
"NumpadAdd": 0x004E,
"Numpad4": 0x004B,
"Numpad5": 0x004C,
"Numpad6": 0x004D,
"Numpad1": 0x004F,
"Numpad2": 0x0050,
"Numpad3": 0x0051,
"NumpadEnter": 0xE01C,
"Numpad0": 0x0052,
"NumpadDecimal": 0x0053,
// ── Multimedia / browser keys (common on 104+ key layouts) ───
"BrowserBack": 0xE06A,
"BrowserForward": 0xE069,
"BrowserRefresh": 0xE067,
"BrowserStop": 0xE068,
"BrowserSearch": 0xE065,
"BrowserFavorites":0xE066,
"BrowserHome": 0xE032,
"VolumeMute": 0xE020,
"VolumeDown": 0xE02E,
"VolumeUp": 0xE030,
"MediaTrackNext": 0xE019,
"MediaTrackPrevious":0xE010,
"MediaStop": 0xE024,
"MediaPlayPause": 0xE022,
"LaunchMail": 0xE06C,
"LaunchApp1": 0xE06B,
"LaunchApp2": 0xE021,
// ── International keys ────────────────────────────────────────
"IntlBackslash": 0x0056, // key between left Shift and Z on ISO keyboards
"IntlYen": 0x007D, // Yen key on Japanese keyboards
"IntlRo": 0x0073, // Ro key on Japanese keyboards
}
// JSKeyToScancode translates a JavaScript KeyboardEvent.code string to an
// RDP hardware scancode. Returns the scancode and true if a mapping exists,
// or 0 and false for unmapped keys.
func JSKeyToScancode(jsCode string) (uint32, bool) {
sc, ok := ScancodeMap[jsCode]
return sc, ok
}
// IsExtendedKey returns true if the scancode has the 0xE0 extended prefix.
// Extended keys require a two-byte sequence on the RDP wire.
func IsExtendedKey(scancode uint32) bool {
return (scancode & 0xFF00) == 0xE000
}
// ScancodeValue returns the low byte of the scancode (the actual scan code
// value without the extended prefix).
func ScancodeValue(scancode uint32) uint8 {
return uint8(scancode & 0xFF)
}

View File

@ -1,227 +0,0 @@
package rdp
import "testing"
func TestScancodeMapping(t *testing.T) {
tests := []struct {
jsCode string
expected uint32
}{
{"Escape", 0x0001},
{"Digit1", 0x0002},
{"Digit0", 0x000B},
{"KeyA", 0x001E},
{"KeyZ", 0x002C},
{"Enter", 0x001C},
{"Space", 0x0039},
{"Tab", 0x000F},
{"Backspace", 0x000E},
{"ShiftLeft", 0x002A},
{"ShiftRight", 0x0036},
{"ControlLeft", 0x001D},
{"ControlRight", 0xE01D},
{"AltLeft", 0x0038},
{"AltRight", 0xE038},
{"CapsLock", 0x003A},
{"F1", 0x003B},
{"F12", 0x0058},
{"ArrowUp", 0xE048},
{"ArrowDown", 0xE050},
{"ArrowLeft", 0xE04B},
{"ArrowRight", 0xE04D},
{"Insert", 0xE052},
{"Delete", 0xE053},
{"Home", 0xE047},
{"End", 0xE04F},
{"PageUp", 0xE049},
{"PageDown", 0xE051},
{"NumLock", 0x0045},
{"Numpad0", 0x0052},
{"Numpad9", 0x0049},
{"NumpadEnter", 0xE01C},
{"NumpadAdd", 0x004E},
{"NumpadSubtract", 0x004A},
{"NumpadMultiply", 0x0037},
{"NumpadDivide", 0xE035},
{"NumpadDecimal", 0x0053},
{"MetaLeft", 0xE05B},
{"MetaRight", 0xE05C},
{"ContextMenu", 0xE05D},
{"PrintScreen", 0xE037},
{"ScrollLock", 0x0046},
{"Backquote", 0x0029},
{"Minus", 0x000C},
{"Equal", 0x000D},
{"BracketLeft", 0x001A},
{"BracketRight", 0x001B},
{"Backslash", 0x002B},
{"Semicolon", 0x0027},
{"Quote", 0x0028},
{"Comma", 0x0033},
{"Period", 0x0034},
{"Slash", 0x0035},
}
for _, tt := range tests {
t.Run(tt.jsCode, func(t *testing.T) {
sc, ok := JSKeyToScancode(tt.jsCode)
if !ok {
t.Fatalf("JSKeyToScancode(%q) returned false", tt.jsCode)
}
if sc != tt.expected {
t.Errorf("JSKeyToScancode(%q) = 0x%04X, want 0x%04X", tt.jsCode, sc, tt.expected)
}
})
}
}
func TestAllLetterKeys(t *testing.T) {
// Verify all 26 letter keys are mapped
letters := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for _, ch := range letters {
code := "Key" + string(ch)
_, ok := JSKeyToScancode(code)
if !ok {
t.Errorf("missing mapping for %s", code)
}
}
}
func TestAllDigitKeys(t *testing.T) {
for i := 0; i <= 9; i++ {
code := "Digit" + string(rune('0'+i))
_, ok := JSKeyToScancode(code)
if !ok {
t.Errorf("missing mapping for %s", code)
}
}
}
func TestAllFunctionKeys(t *testing.T) {
fKeys := []string{"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"}
for _, key := range fKeys {
_, ok := JSKeyToScancode(key)
if !ok {
t.Errorf("missing mapping for %s", key)
}
}
}
func TestAllNumpadKeys(t *testing.T) {
numpadKeys := []string{
"Numpad0", "Numpad1", "Numpad2", "Numpad3", "Numpad4",
"Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9",
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide",
"NumpadDecimal", "NumpadEnter", "NumLock",
}
for _, key := range numpadKeys {
_, ok := JSKeyToScancode(key)
if !ok {
t.Errorf("missing mapping for %s", key)
}
}
}
func TestUnknownKey(t *testing.T) {
sc, ok := JSKeyToScancode("FakeKey123")
if ok {
t.Errorf("unknown key returned ok=true with scancode 0x%04X", sc)
}
if sc != 0 {
t.Errorf("unknown key scancode = 0x%04X, want 0", sc)
}
}
func TestIsExtendedKey(t *testing.T) {
tests := []struct {
name string
scancode uint32
extended bool
}{
{"Escape (not extended)", 0x0001, false},
{"Enter (not extended)", 0x001C, false},
{"Right Ctrl (extended)", 0xE01D, true},
{"Right Alt (extended)", 0xE038, true},
{"ArrowUp (extended)", 0xE048, true},
{"Insert (extended)", 0xE052, true},
{"NumpadEnter (extended)", 0xE01C, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsExtendedKey(tt.scancode)
if got != tt.extended {
t.Errorf("IsExtendedKey(0x%04X) = %v, want %v", tt.scancode, got, tt.extended)
}
})
}
}
func TestScancodeValue(t *testing.T) {
tests := []struct {
name string
scancode uint32
value uint8
}{
{"Escape", 0x0001, 0x01},
{"Right Ctrl", 0xE01D, 0x1D},
{"ArrowUp", 0xE048, 0x48},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ScancodeValue(tt.scancode)
if got != tt.value {
t.Errorf("ScancodeValue(0x%04X) = 0x%02X, want 0x%02X", tt.scancode, got, tt.value)
}
})
}
}
func TestMouseFlags(t *testing.T) {
// Verify the flag constants have the right values
if MouseFlagMove != 0x0800 {
t.Errorf("MouseFlagMove = 0x%04X, want 0x0800", MouseFlagMove)
}
if MouseFlagButton1 != 0x1000 {
t.Errorf("MouseFlagButton1 = 0x%04X, want 0x1000", MouseFlagButton1)
}
if MouseFlagButton2 != 0x2000 {
t.Errorf("MouseFlagButton2 = 0x%04X, want 0x2000", MouseFlagButton2)
}
if MouseFlagButton3 != 0x4000 {
t.Errorf("MouseFlagButton3 = 0x%04X, want 0x4000", MouseFlagButton3)
}
if MouseFlagDown != 0x8000 {
t.Errorf("MouseFlagDown = 0x%04X, want 0x8000", MouseFlagDown)
}
// Test combining flags — left click down
flags := MouseFlagButton1 | MouseFlagDown
if flags != 0x9000 {
t.Errorf("left click down = 0x%04X, want 0x9000", flags)
}
// Test combining flags — right click up (no Down flag)
flags = MouseFlagButton2
if flags != 0x2000 {
t.Errorf("right click up = 0x%04X, want 0x2000", flags)
}
}
func TestScancodeMapCompleteness(t *testing.T) {
// Minimum expected mappings for a standard 104-key US keyboard:
// 26 letters + 10 digits + 12 F-keys + escape + tab + caps + 2 shifts +
// 2 ctrls + 2 alts + 2 metas + space + enter + backspace +
// numrow punctuation (backquote, minus, equal) +
// bracket pair + backslash + semicolon + quote + comma + period + slash +
// 4 arrows + insert + delete + home + end + pageup + pagedown +
// printscreen + scrolllock + pause +
// numlock + numpad 0-9 + numpad operators (5) + numpad enter + numpad decimal +
// context menu
// = 26+10+12+1+1+1+2+2+2+2+1+1+1+3+2+1+1+1+1+1+1+4+6+3+1+10+5+1+1+1 = ~104
minExpected := 90 // conservative lower bound for core keys
if len(ScancodeMap) < minExpected {
t.Errorf("ScancodeMap has %d entries, expected at least %d", len(ScancodeMap), minExpected)
}
}

View File

@ -1,313 +0,0 @@
package rdp
import (
"fmt"
"math"
"sync"
"time"
)
// MockBackend implements the RDPBackend interface for development and testing.
// Instead of connecting to a real RDP server, it generates animated test frames
// with a gradient pattern, moving shapes, and a timestamp overlay.
type MockBackend struct {
connected bool
config RDPConfig
buffer *PixelBuffer
mu sync.Mutex
startTime time.Time
clipboard string
}
// NewMockBackend creates a new MockBackend instance.
func NewMockBackend() *MockBackend {
return &MockBackend{}
}
// Connect initializes the mock backend with the given configuration and starts
// generating test frames.
func (m *MockBackend) Connect(config RDPConfig) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.connected {
return fmt.Errorf("already connected")
}
width := config.Width
height := config.Height
if width <= 0 {
width = 1920
}
if height <= 0 {
height = 1080
}
m.config = config
m.config.Width = width
m.config.Height = height
m.buffer = NewPixelBuffer(width, height)
m.connected = true
m.startTime = time.Now()
// Generate the initial test frame
m.generateFrame()
return nil
}
// Disconnect shuts down the mock backend.
func (m *MockBackend) Disconnect() error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.connected {
return fmt.Errorf("not connected")
}
m.connected = false
m.buffer = nil
return nil
}
// SendMouseEvent records a mouse event (no-op for mock).
func (m *MockBackend) SendMouseEvent(x, y int, flags uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.connected {
return fmt.Errorf("not connected")
}
// In the mock, we draw a cursor indicator at the mouse position
if m.buffer != nil && flags&MouseFlagMove != 0 {
m.drawCursor(x, y)
}
return nil
}
// SendKeyEvent records a key event (no-op for mock).
func (m *MockBackend) SendKeyEvent(scancode uint32, pressed bool) error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.connected {
return fmt.Errorf("not connected")
}
return nil
}
// SendClipboard stores clipboard text (mock implementation).
func (m *MockBackend) SendClipboard(data string) error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.connected {
return fmt.Errorf("not connected")
}
m.clipboard = data
return nil
}
// GetFrame returns the current test frame. Each call regenerates the frame
// with an updated animation state so the renderer can verify dynamic updates.
func (m *MockBackend) GetFrame() ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
if !m.connected {
return nil, fmt.Errorf("not connected")
}
m.generateFrame()
return m.buffer.GetFrame(), nil
}
// IsConnected reports whether the mock backend is connected.
func (m *MockBackend) IsConnected() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.connected
}
// generateFrame creates a visually interesting test frame with:
// - A blue-to-purple diagonal gradient background
// - An animated bouncing rectangle
// - A grid overlay for alignment verification
// - Session info text area
func (m *MockBackend) generateFrame() {
w := m.config.Width
h := m.config.Height
elapsed := time.Since(m.startTime).Seconds()
data := make([]byte, w*h*4)
// ── Background: diagonal gradient from dark blue to dark purple ──
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
offset := (y*w + x) * 4
// Normalized coordinates
nx := float64(x) / float64(w)
ny := float64(y) / float64(h)
diag := (nx + ny) / 2.0
r := uint8(20 + diag*40) // 2060
g := uint8(25 + (1.0-diag)*30) // 2555
b := uint8(80 + diag*100) // 80180
data[offset+0] = r
data[offset+1] = g
data[offset+2] = b
data[offset+3] = 255
}
}
// ── Grid overlay: subtle lines every 100 pixels ──
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
if x%100 == 0 || y%100 == 0 {
offset := (y*w + x) * 4
data[offset+0] = min8(data[offset+0]+20, 255)
data[offset+1] = min8(data[offset+1]+20, 255)
data[offset+2] = min8(data[offset+2]+20, 255)
}
}
}
// ── Animated bouncing rectangle ──
rectW, rectH := 200, 120
// Bounce horizontally and vertically using sine/cosine
cx := int(float64(w-rectW) * (0.5 + 0.4*math.Sin(elapsed*0.7)))
cy := int(float64(h-rectH) * (0.5 + 0.4*math.Cos(elapsed*0.5)))
// Color cycles through hues
hue := math.Mod(elapsed*30, 360)
rr, gg, bb := hueToRGB(hue)
for ry := 0; ry < rectH; ry++ {
for rx := 0; rx < rectW; rx++ {
px := cx + rx
py := cy + ry
if px >= 0 && px < w && py >= 0 && py < h {
offset := (py*w + px) * 4
// Border: 3px white outline
if rx < 3 || rx >= rectW-3 || ry < 3 || ry >= rectH-3 {
data[offset+0] = 255
data[offset+1] = 255
data[offset+2] = 255
data[offset+3] = 255
} else {
data[offset+0] = rr
data[offset+1] = gg
data[offset+2] = bb
data[offset+3] = 220
}
}
}
}
// ── Info panel in top-left corner ──
panelW, panelH := 320, 80
for py := 10; py < 10+panelH && py < h; py++ {
for px := 10; px < 10+panelW && px < w; px++ {
offset := (py*w + px) * 4
data[offset+0] = 0
data[offset+1] = 0
data[offset+2] = 0
data[offset+3] = 180
}
}
// ── Secondary animated element: pulsing circle ──
circleX := w / 2
circleY := h / 2
radius := 40.0 + 20.0*math.Sin(elapsed*2.0)
for dy := -70; dy <= 70; dy++ {
for dx := -70; dx <= 70; dx++ {
dist := math.Sqrt(float64(dx*dx + dy*dy))
if dist <= radius && dist >= radius-4 {
px := circleX + dx
py := circleY + dy
if px >= 0 && px < w && py >= 0 && py < h {
offset := (py*w + px) * 4
data[offset+0] = 88
data[offset+1] = 166
data[offset+2] = 255
data[offset+3] = 255
}
}
}
}
// Apply as a full-frame update
m.buffer.Update(0, 0, w, h, data)
}
// drawCursor draws a small crosshair at the given position.
func (m *MockBackend) drawCursor(cx, cy int) {
size := 10
w := m.config.Width
h := m.config.Height
cursorData := make([]byte, (size*2+1)*(size*2+1)*4)
idx := 0
for dy := -size; dy <= size; dy++ {
for dx := -size; dx <= size; dx++ {
if dx == 0 || dy == 0 {
cursorData[idx+0] = 255
cursorData[idx+1] = 255
cursorData[idx+2] = 0
cursorData[idx+3] = 200
}
idx += 4
}
}
startX := cx - size
startY := cy - size
if startX < 0 {
startX = 0
}
if startY < 0 {
startY = 0
}
_ = w
_ = h
m.buffer.Update(startX, startY, size*2+1, size*2+1, cursorData)
}
// hueToRGB converts a hue angle (0-360) to RGB values.
func hueToRGB(hue float64) (uint8, uint8, uint8) {
h := math.Mod(hue, 360) / 60
c := 200.0 // chroma
x := c * (1 - math.Abs(math.Mod(h, 2)-1))
var r, g, b float64
switch {
case h < 1:
r, g, b = c, x, 0
case h < 2:
r, g, b = x, c, 0
case h < 3:
r, g, b = 0, c, x
case h < 4:
r, g, b = 0, x, c
case h < 5:
r, g, b = x, 0, c
default:
r, g, b = c, 0, x
}
return uint8(r + 55), uint8(g + 55), uint8(b + 55)
}
// min8 returns the smaller of two uint8 values.
func min8(a, b uint8) uint8 {
if a < b {
return a
}
return b
}

View File

@ -1,103 +0,0 @@
package rdp
import "sync"
// PixelBuffer holds a shared RGBA pixel buffer that is updated by the RDP
// backend (via partial region updates) and read by the frame-serving path.
type PixelBuffer struct {
Width int
Height int
Data []byte // RGBA pixel data, len = Width * Height * 4
mu sync.RWMutex
dirty bool
}
// NewPixelBuffer allocates a buffer for the given resolution.
// The buffer is initialized to all zeros (transparent black).
func NewPixelBuffer(width, height int) *PixelBuffer {
return &PixelBuffer{
Width: width,
Height: height,
Data: make([]byte, width*height*4),
}
}
// Update applies a partial region update to the pixel buffer.
// The data slice must contain w*h*4 bytes of RGBA pixel data.
// Pixels outside the buffer bounds are silently clipped.
func (p *PixelBuffer) Update(x, y, w, h int, data []byte) {
p.mu.Lock()
defer p.mu.Unlock()
for row := 0; row < h; row++ {
destY := y + row
if destY < 0 || destY >= p.Height {
continue
}
srcOffset := row * w * 4
destOffset := (destY*p.Width + x) * 4
// Calculate how many pixels we can copy on this row
copyWidth := w
if x < 0 {
srcOffset += (-x) * 4
copyWidth += x // reduce by the clipped amount
destOffset = destY * p.Width * 4
}
if x+copyWidth > p.Width {
copyWidth = p.Width - x
if x < 0 {
copyWidth = p.Width
}
}
if copyWidth <= 0 {
continue
}
srcEnd := srcOffset + copyWidth*4
if srcEnd > len(data) {
srcEnd = len(data)
}
if srcOffset >= len(data) {
continue
}
destEnd := destOffset + copyWidth*4
if destEnd > len(p.Data) {
destEnd = len(p.Data)
}
copy(p.Data[destOffset:destEnd], data[srcOffset:srcEnd])
}
p.dirty = true
}
// GetFrame returns a copy of the full pixel buffer.
// The caller receives an independent copy that will not be affected
// by subsequent updates.
func (p *PixelBuffer) GetFrame() []byte {
p.mu.RLock()
defer p.mu.RUnlock()
frame := make([]byte, len(p.Data))
copy(frame, p.Data)
return frame
}
// IsDirty reports whether the buffer has been updated since the last
// ClearDirty call.
func (p *PixelBuffer) IsDirty() bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.dirty
}
// ClearDirty resets the dirty flag. Typically called after a frame has
// been sent to the frontend.
func (p *PixelBuffer) ClearDirty() {
p.mu.Lock()
defer p.mu.Unlock()
p.dirty = false
}

View File

@ -1,172 +0,0 @@
package rdp
import "testing"
func TestNewPixelBuffer(t *testing.T) {
pb := NewPixelBuffer(100, 50)
if pb.Width != 100 {
t.Errorf("Width = %d, want 100", pb.Width)
}
if pb.Height != 50 {
t.Errorf("Height = %d, want 50", pb.Height)
}
expectedLen := 100 * 50 * 4
if len(pb.Data) != expectedLen {
t.Errorf("Data length = %d, want %d", len(pb.Data), expectedLen)
}
// All pixels should be initialized to zero
for i, b := range pb.Data {
if b != 0 {
t.Errorf("Data[%d] = %d, want 0", i, b)
break
}
}
}
func TestNewPixelBufferSmall(t *testing.T) {
pb := NewPixelBuffer(1, 1)
if len(pb.Data) != 4 {
t.Errorf("1x1 buffer Data length = %d, want 4", len(pb.Data))
}
}
func TestUpdateRegion(t *testing.T) {
pb := NewPixelBuffer(10, 10) // 10x10 pixels
// Create a 3x2 red patch
patch := make([]byte, 3*2*4) // 3 wide, 2 tall
for i := 0; i < 3*2; i++ {
patch[i*4+0] = 255 // R
patch[i*4+1] = 0 // G
patch[i*4+2] = 0 // B
patch[i*4+3] = 255 // A
}
// Apply at position (2, 3)
pb.Update(2, 3, 3, 2, patch)
// Check that pixel (2,3) is red
offset := (3*10 + 2) * 4
if pb.Data[offset+0] != 255 || pb.Data[offset+1] != 0 || pb.Data[offset+2] != 0 || pb.Data[offset+3] != 255 {
t.Errorf("pixel (2,3) = [%d,%d,%d,%d], want [255,0,0,255]",
pb.Data[offset+0], pb.Data[offset+1], pb.Data[offset+2], pb.Data[offset+3])
}
// Check that pixel (4,4) is red (last pixel of patch)
offset = (4*10 + 4) * 4
if pb.Data[offset+0] != 255 || pb.Data[offset+1] != 0 || pb.Data[offset+2] != 0 || pb.Data[offset+3] != 255 {
t.Errorf("pixel (4,4) = [%d,%d,%d,%d], want [255,0,0,255]",
pb.Data[offset+0], pb.Data[offset+1], pb.Data[offset+2], pb.Data[offset+3])
}
// Check that pixel (1,3) is still black (just outside patch)
offset = (3*10 + 1) * 4
if pb.Data[offset+0] != 0 || pb.Data[offset+3] != 0 {
t.Errorf("pixel (1,3) should be untouched, got [%d,%d,%d,%d]",
pb.Data[offset+0], pb.Data[offset+1], pb.Data[offset+2], pb.Data[offset+3])
}
// Check that pixel (5,3) is still black (just outside patch)
offset = (3*10 + 5) * 4
if pb.Data[offset+0] != 0 || pb.Data[offset+3] != 0 {
t.Errorf("pixel (5,3) should be untouched, got [%d,%d,%d,%d]",
pb.Data[offset+0], pb.Data[offset+1], pb.Data[offset+2], pb.Data[offset+3])
}
}
func TestUpdateRegionClipping(t *testing.T) {
pb := NewPixelBuffer(10, 10)
// Create a 5x5 green patch and place it at (8, 8), so it overflows
patch := make([]byte, 5*5*4)
for i := 0; i < 5*5; i++ {
patch[i*4+0] = 0
patch[i*4+1] = 255
patch[i*4+2] = 0
patch[i*4+3] = 255
}
// Should not panic — overflowing regions are clipped
pb.Update(8, 8, 5, 5, patch)
// Pixel (8,8) should be green
offset := (8*10 + 8) * 4
if pb.Data[offset+1] != 255 {
t.Errorf("pixel (8,8) G = %d, want 255", pb.Data[offset+1])
}
// Pixel (9,9) should also be green (last valid pixel)
offset = (9*10 + 9) * 4
if pb.Data[offset+1] != 255 {
t.Errorf("pixel (9,9) G = %d, want 255", pb.Data[offset+1])
}
}
func TestDirtyFlag(t *testing.T) {
pb := NewPixelBuffer(10, 10)
if pb.IsDirty() {
t.Error("new buffer should not be dirty")
}
// Update a region
patch := make([]byte, 4) // 1x1 pixel
patch[0] = 255
patch[3] = 255
pb.Update(0, 0, 1, 1, patch)
if !pb.IsDirty() {
t.Error("buffer should be dirty after update")
}
pb.ClearDirty()
if pb.IsDirty() {
t.Error("buffer should not be dirty after ClearDirty")
}
}
func TestGetFrameReturnsCopy(t *testing.T) {
pb := NewPixelBuffer(2, 2)
// Set first pixel to white
patch := []byte{255, 255, 255, 255}
pb.Update(0, 0, 1, 1, patch)
frame1 := pb.GetFrame()
// Modify the returned frame
frame1[0] = 0
// Get another frame — it should still have the original value
frame2 := pb.GetFrame()
if frame2[0] != 255 {
t.Errorf("GetFrame did not return an independent copy: got %d, want 255", frame2[0])
}
}
func TestFullFrameUpdate(t *testing.T) {
pb := NewPixelBuffer(4, 4)
// Create a full-frame update with all blue pixels
fullFrame := make([]byte, 4*4*4)
for i := 0; i < 4*4; i++ {
fullFrame[i*4+0] = 0
fullFrame[i*4+1] = 0
fullFrame[i*4+2] = 255
fullFrame[i*4+3] = 255
}
pb.Update(0, 0, 4, 4, fullFrame)
frame := pb.GetFrame()
for i := 0; i < 4*4; i++ {
if frame[i*4+2] != 255 {
t.Errorf("pixel %d blue channel = %d, want 255", i, frame[i*4+2])
break
}
}
}

View File

@ -1,172 +0,0 @@
package rdp
import (
"fmt"
"sync"
"time"
"github.com/google/uuid"
)
// RDPSession represents an active RDP connection with its associated state.
type RDPSession struct {
ID string `json:"id"`
Config RDPConfig `json:"config"`
Backend RDPBackend `json:"-"`
Buffer *PixelBuffer `json:"-"`
ConnID int64 `json:"connectionId"`
Connected time.Time `json:"connected"`
}
// RDPService manages multiple RDP sessions. It uses a backend factory to
// create new RDPBackend instances — during development the factory returns
// MockBackend instances; in production it will return FreeRDP-backed ones.
type RDPService struct {
sessions map[string]*RDPSession
mu sync.RWMutex
backendFactory func() RDPBackend
}
// NewRDPService creates a new service with the given backend factory.
// The factory is called once per Connect to create a fresh backend.
func NewRDPService(factory func() RDPBackend) *RDPService {
return &RDPService{
sessions: make(map[string]*RDPSession),
backendFactory: factory,
}
}
// Connect creates a new RDP session using the provided configuration.
// Returns the session ID on success.
func (s *RDPService) Connect(config RDPConfig, connectionID int64) (string, error) {
// Apply defaults
if config.Port <= 0 {
config.Port = 3389
}
if config.Width <= 0 {
config.Width = 1920
}
if config.Height <= 0 {
config.Height = 1080
}
if config.ColorDepth <= 0 {
config.ColorDepth = 32
}
if config.Security == "" {
config.Security = "nla"
}
backend := s.backendFactory()
if err := backend.Connect(config); err != nil {
return "", fmt.Errorf("rdp connect to %s:%d: %w", config.Hostname, config.Port, err)
}
sessionID := uuid.NewString()
session := &RDPSession{
ID: sessionID,
Config: config,
Backend: backend,
Buffer: NewPixelBuffer(config.Width, config.Height),
ConnID: connectionID,
Connected: time.Now(),
}
s.mu.Lock()
s.sessions[sessionID] = session
s.mu.Unlock()
return sessionID, nil
}
// Disconnect tears down the RDP session and removes it from tracking.
func (s *RDPService) Disconnect(sessionID string) error {
s.mu.Lock()
session, ok := s.sessions[sessionID]
if !ok {
s.mu.Unlock()
return fmt.Errorf("session %s not found", sessionID)
}
delete(s.sessions, sessionID)
s.mu.Unlock()
return session.Backend.Disconnect()
}
// SendMouse forwards a mouse event to the session's backend.
func (s *RDPService) SendMouse(sessionID string, x, y int, flags uint32) error {
session, err := s.getSession(sessionID)
if err != nil {
return err
}
return session.Backend.SendMouseEvent(x, y, flags)
}
// SendKey forwards a key event to the session's backend.
func (s *RDPService) SendKey(sessionID string, scancode uint32, pressed bool) error {
session, err := s.getSession(sessionID)
if err != nil {
return err
}
return session.Backend.SendKeyEvent(scancode, pressed)
}
// SendClipboard forwards clipboard text to the session's backend.
func (s *RDPService) SendClipboard(sessionID string, data string) error {
session, err := s.getSession(sessionID)
if err != nil {
return err
}
return session.Backend.SendClipboard(data)
}
// GetFrame returns the current RGBA pixel buffer for the given session.
func (s *RDPService) GetFrame(sessionID string) ([]byte, error) {
session, err := s.getSession(sessionID)
if err != nil {
return nil, err
}
return session.Backend.GetFrame()
}
// GetSessionInfo returns the session metadata without the backend or buffer.
// This is safe to serialize to JSON for the frontend.
func (s *RDPService) GetSessionInfo(sessionID string) (*RDPSession, error) {
session, err := s.getSession(sessionID)
if err != nil {
return nil, err
}
return session, nil
}
// ListSessions returns all active RDP sessions.
func (s *RDPService) ListSessions() []*RDPSession {
s.mu.RLock()
defer s.mu.RUnlock()
list := make([]*RDPSession, 0, len(s.sessions))
for _, sess := range s.sessions {
list = append(list, sess)
}
return list
}
// IsConnected returns whether a specific session is still connected.
func (s *RDPService) IsConnected(sessionID string) bool {
session, err := s.getSession(sessionID)
if err != nil {
return false
}
return session.Backend.IsConnected()
}
// getSession is an internal helper that retrieves a session by ID.
func (s *RDPService) getSession(sessionID string) (*RDPSession, error) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[sessionID]
if !ok {
return nil, fmt.Errorf("session %s not found", sessionID)
}
return session, nil
}

View File

@ -1,255 +0,0 @@
package rdp
import "testing"
func TestNewRDPService(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
if svc == nil {
t.Fatal("NewRDPService returned nil")
}
if len(svc.ListSessions()) != 0 {
t.Error("new service should have no sessions")
}
}
func TestConnectWithMockBackend(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
config := RDPConfig{
Hostname: "test-host",
Port: 3389,
Username: "admin",
Width: 1024,
Height: 768,
}
sessionID, err := svc.Connect(config, 42)
if err != nil {
t.Fatalf("Connect error: %v", err)
}
if sessionID == "" {
t.Fatal("Connect returned empty session ID")
}
// Verify session is tracked
sessions := svc.ListSessions()
if len(sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(sessions))
}
if sessions[0].ID != sessionID {
t.Errorf("session ID = %q, want %q", sessions[0].ID, sessionID)
}
if sessions[0].ConnID != 42 {
t.Errorf("ConnID = %d, want 42", sessions[0].ConnID)
}
if sessions[0].Config.Hostname != "test-host" {
t.Errorf("Hostname = %q, want %q", sessions[0].Config.Hostname, "test-host")
}
}
func TestConnectDefaults(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
// Connect with zero values — should get defaults
config := RDPConfig{Hostname: "host"}
sessionID, err := svc.Connect(config, 1)
if err != nil {
t.Fatalf("Connect error: %v", err)
}
info, err := svc.GetSessionInfo(sessionID)
if err != nil {
t.Fatalf("GetSessionInfo error: %v", err)
}
if info.Config.Port != 3389 {
t.Errorf("default Port = %d, want 3389", info.Config.Port)
}
if info.Config.Width != 1920 {
t.Errorf("default Width = %d, want 1920", info.Config.Width)
}
if info.Config.Height != 1080 {
t.Errorf("default Height = %d, want 1080", info.Config.Height)
}
if info.Config.ColorDepth != 32 {
t.Errorf("default ColorDepth = %d, want 32", info.Config.ColorDepth)
}
if info.Config.Security != "nla" {
t.Errorf("default Security = %q, want %q", info.Config.Security, "nla")
}
}
func TestDisconnect(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
sessionID, err := svc.Connect(config, 1)
if err != nil {
t.Fatalf("Connect error: %v", err)
}
if err := svc.Disconnect(sessionID); err != nil {
t.Fatalf("Disconnect error: %v", err)
}
if len(svc.ListSessions()) != 0 {
t.Error("expected 0 sessions after disconnect")
}
// Verify double-disconnect returns error
if err := svc.Disconnect(sessionID); err == nil {
t.Error("expected error on double disconnect")
}
}
func TestDisconnectNotFound(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
if err := svc.Disconnect("nonexistent"); err == nil {
t.Error("expected error for nonexistent session")
}
}
func TestSessionTracking(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
config := RDPConfig{Hostname: "host", Width: 640, Height: 480}
id1, _ := svc.Connect(config, 1)
id2, _ := svc.Connect(config, 2)
id3, _ := svc.Connect(config, 3)
if len(svc.ListSessions()) != 3 {
t.Fatalf("expected 3 sessions, got %d", len(svc.ListSessions()))
}
// Disconnect middle session
if err := svc.Disconnect(id2); err != nil {
t.Fatalf("Disconnect error: %v", err)
}
sessions := svc.ListSessions()
if len(sessions) != 2 {
t.Fatalf("expected 2 sessions, got %d", len(sessions))
}
// Verify remaining sessions
ids := make(map[string]bool)
for _, s := range sessions {
ids[s.ID] = true
}
if !ids[id1] || !ids[id3] {
t.Error("remaining sessions should be id1 and id3")
}
}
func TestSendMouse(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
sessionID, _ := svc.Connect(config, 1)
if err := svc.SendMouse(sessionID, 100, 200, MouseFlagMove); err != nil {
t.Errorf("SendMouse error: %v", err)
}
if err := svc.SendMouse(sessionID, 100, 200, MouseFlagButton1|MouseFlagDown); err != nil {
t.Errorf("SendMouse click error: %v", err)
}
}
func TestSendMouseNotFound(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
if err := svc.SendMouse("nonexistent", 0, 0, 0); err == nil {
t.Error("expected error for nonexistent session")
}
}
func TestSendKey(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
sessionID, _ := svc.Connect(config, 1)
// Send key down
sc, _ := JSKeyToScancode("KeyA")
if err := svc.SendKey(sessionID, sc, true); err != nil {
t.Errorf("SendKey down error: %v", err)
}
// Send key up
if err := svc.SendKey(sessionID, sc, false); err != nil {
t.Errorf("SendKey up error: %v", err)
}
}
func TestSendKeyNotFound(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
if err := svc.SendKey("nonexistent", 0x001E, true); err == nil {
t.Error("expected error for nonexistent session")
}
}
func TestSendClipboard(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
sessionID, _ := svc.Connect(config, 1)
if err := svc.SendClipboard(sessionID, "hello clipboard"); err != nil {
t.Errorf("SendClipboard error: %v", err)
}
}
func TestGetFrame(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
config := RDPConfig{Hostname: "host", Width: 100, Height: 100}
sessionID, _ := svc.Connect(config, 1)
frame, err := svc.GetFrame(sessionID)
if err != nil {
t.Fatalf("GetFrame error: %v", err)
}
expectedLen := 100 * 100 * 4
if len(frame) != expectedLen {
t.Errorf("frame length = %d, want %d", len(frame), expectedLen)
}
// Verify the frame is not all zeros (mock generates colored content)
allZero := true
for _, b := range frame {
if b != 0 {
allZero = false
break
}
}
if allZero {
t.Error("mock frame should not be all zeros")
}
}
func TestGetFrameNotFound(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
_, err := svc.GetFrame("nonexistent")
if err == nil {
t.Error("expected error for nonexistent session")
}
}
func TestIsConnected(t *testing.T) {
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
sessionID, _ := svc.Connect(config, 1)
if !svc.IsConnected(sessionID) {
t.Error("session should be connected")
}
svc.Disconnect(sessionID)
if svc.IsConnected(sessionID) {
t.Error("session should not be connected after disconnect")
}
}

View File

@ -1,52 +0,0 @@
package rdp
// RDPConfig holds the parameters needed to establish an RDP connection.
type RDPConfig struct {
Hostname string // Remote host address
Port int // RDP port (default 3389)
Username string
Password string
Domain string // Windows domain (optional)
Width int // Desktop width in pixels
Height int // Desktop height in pixels
ColorDepth int // 16, 24, or 32
Security string // "nla", "tls", or "rdp"
}
// FrameUpdate represents a partial screen update from the RDP server.
// The data is in RGBA format (4 bytes per pixel).
type FrameUpdate struct {
X int // top-left X of the updated region
Y int // top-left Y of the updated region
Width int // width of the updated region in pixels
Height int // height of the updated region in pixels
Data []byte // RGBA pixel data, len = Width * Height * 4
}
// RDPBackend abstracts the FreeRDP implementation so that a mock can be
// used during development on platforms where FreeRDP is not available.
type RDPBackend interface {
// Connect establishes an RDP session with the given configuration.
Connect(config RDPConfig) error
// Disconnect tears down the RDP session.
Disconnect() error
// SendMouseEvent sends a mouse event with the given position and flags.
// Flags use the RDP mouse event flag constants (MouseFlag*).
SendMouseEvent(x, y int, flags uint32) error
// SendKeyEvent sends a keyboard event for the given RDP scancode.
// pressed=true for key down, false for key up.
SendKeyEvent(scancode uint32, pressed bool) error
// SendClipboard sends clipboard text to the remote session.
SendClipboard(data string) error
// GetFrame returns the current full-frame RGBA pixel buffer.
// The returned slice length is Width * Height * 4.
GetFrame() ([]byte, error)
// IsConnected reports whether the backend has an active connection.
IsConnected() bool
}

View File

@ -1,96 +0,0 @@
package session
import (
"fmt"
"sync"
"github.com/google/uuid"
)
const MaxSessions = 32
type Manager struct {
mu sync.RWMutex
sessions map[string]*SessionInfo
}
func NewManager() *Manager {
return &Manager{
sessions: make(map[string]*SessionInfo),
}
}
func (m *Manager) Create(connectionID int64, protocol string) (*SessionInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.sessions) >= MaxSessions {
return nil, fmt.Errorf("maximum sessions (%d) reached", MaxSessions)
}
s := &SessionInfo{
ID: uuid.NewString(),
ConnectionID: connectionID,
Protocol: protocol,
State: StateConnecting,
TabPosition: len(m.sessions),
}
m.sessions[s.ID] = s
return s, nil
}
func (m *Manager) Get(id string) (*SessionInfo, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[id]
return s, ok
}
func (m *Manager) List() []*SessionInfo {
m.mu.RLock()
defer m.mu.RUnlock()
list := make([]*SessionInfo, 0, len(m.sessions))
for _, s := range m.sessions {
list = append(list, s)
}
return list
}
func (m *Manager) SetState(id string, state SessionState) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[id]
if !ok {
return fmt.Errorf("session %s not found", id)
}
s.State = state
return nil
}
func (m *Manager) Detach(id string) error {
return m.SetState(id, StateDetached)
}
func (m *Manager) Reattach(id, windowID string) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[id]
if !ok {
return fmt.Errorf("session %s not found", id)
}
s.State = StateConnected
s.WindowID = windowID
return nil
}
func (m *Manager) Remove(id string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.sessions, id)
}
func (m *Manager) Count() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.sessions)
}

View File

@ -1,64 +0,0 @@
package session
import "testing"
func TestCreateSession(t *testing.T) {
m := NewManager()
s, err := m.Create(1, "ssh")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if s.ID == "" {
t.Error("session ID should not be empty")
}
if s.State != StateConnecting {
t.Errorf("State = %q, want %q", s.State, StateConnecting)
}
}
func TestMaxSessions(t *testing.T) {
m := NewManager()
for i := 0; i < MaxSessions; i++ {
_, err := m.Create(int64(i), "ssh")
if err != nil {
t.Fatalf("Create() error at %d: %v", i, err)
}
}
_, err := m.Create(999, "ssh")
if err == nil {
t.Error("Create() should fail at max sessions")
}
}
func TestDetachReattach(t *testing.T) {
m := NewManager()
s, _ := m.Create(1, "ssh")
m.SetState(s.ID, StateConnected)
if err := m.Detach(s.ID); err != nil {
t.Fatalf("Detach() error: %v", err)
}
got, _ := m.Get(s.ID)
if got.State != StateDetached {
t.Errorf("State = %q, want %q", got.State, StateDetached)
}
if err := m.Reattach(s.ID, "window-1"); err != nil {
t.Fatalf("Reattach() error: %v", err)
}
got, _ = m.Get(s.ID)
if got.State != StateConnected {
t.Errorf("State = %q, want %q", got.State, StateConnected)
}
}
func TestRemoveSession(t *testing.T) {
m := NewManager()
s, _ := m.Create(1, "ssh")
m.Remove(s.ID)
if m.Count() != 0 {
t.Error("session should have been removed")
}
}

View File

@ -1,22 +0,0 @@
package session
import "time"
type SessionState string
const (
StateConnecting SessionState = "connecting"
StateConnected SessionState = "connected"
StateDisconnected SessionState = "disconnected"
StateDetached SessionState = "detached"
)
type SessionInfo struct {
ID string `json:"id"`
ConnectionID int64 `json:"connectionId"`
Protocol string `json:"protocol"`
State SessionState `json:"state"`
WindowID string `json:"windowId"`
TabPosition int `json:"tabPosition"`
ConnectedAt time.Time `json:"connectedAt"`
}

View File

@ -1,41 +0,0 @@
package settings
import "database/sql"
type SettingsService struct {
db *sql.DB
}
func NewSettingsService(db *sql.DB) *SettingsService {
return &SettingsService{db: db}
}
func (s *SettingsService) Get(key string) (string, error) {
var value string
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
func (s *SettingsService) GetDefault(key, defaultValue string) string {
val, err := s.Get(key)
if err != nil || val == "" {
return defaultValue
}
return val
}
func (s *SettingsService) Set(key, value string) error {
_, err := s.db.Exec(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
key, value, value,
)
return err
}
func (s *SettingsService) Delete(key string) error {
_, err := s.db.Exec("DELETE FROM settings WHERE key = ?", key)
return err
}

View File

@ -1,70 +0,0 @@
package settings
import (
"path/filepath"
"testing"
"github.com/vstockwell/wraith/internal/db"
)
func setupTestDB(t *testing.T) *SettingsService {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(d); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
return NewSettingsService(d)
}
func TestSetAndGet(t *testing.T) {
s := setupTestDB(t)
if err := s.Set("theme", "dracula"); err != nil {
t.Fatalf("Set() error: %v", err)
}
val, err := s.Get("theme")
if err != nil {
t.Fatalf("Get() error: %v", err)
}
if val != "dracula" {
t.Errorf("Get() = %q, want %q", val, "dracula")
}
}
func TestGetMissing(t *testing.T) {
s := setupTestDB(t)
val, err := s.Get("nonexistent")
if err != nil {
t.Fatalf("Get() error: %v", err)
}
if val != "" {
t.Errorf("Get() = %q, want empty string", val)
}
}
func TestSetOverwrites(t *testing.T) {
s := setupTestDB(t)
s.Set("key", "value1")
s.Set("key", "value2")
val, _ := s.Get("key")
if val != "value2" {
t.Errorf("Get() = %q, want %q", val, "value2")
}
}
func TestGetWithDefault(t *testing.T) {
s := setupTestDB(t)
val := s.GetDefault("missing", "fallback")
if val != "fallback" {
t.Errorf("GetDefault() = %q, want %q", val, "fallback")
}
}

View File

@ -1,238 +0,0 @@
package sftp
import (
"fmt"
"io"
"os"
"sort"
"strings"
"sync"
"github.com/pkg/sftp"
)
const MaxEditFileSize = 5 * 1024 * 1024 // 5MB
// FileEntry represents a file or directory in a remote filesystem.
type FileEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
Permissions string `json:"permissions"`
ModTime string `json:"modTime"`
}
// SFTPService manages SFTP clients keyed by session ID.
type SFTPService struct {
clients map[string]*sftp.Client
mu sync.RWMutex
}
// NewSFTPService creates a new SFTPService.
func NewSFTPService() *SFTPService {
return &SFTPService{
clients: make(map[string]*sftp.Client),
}
}
// RegisterClient stores an SFTP client for a session.
func (s *SFTPService) RegisterClient(sessionID string, client *sftp.Client) {
s.mu.Lock()
defer s.mu.Unlock()
s.clients[sessionID] = client
}
// RemoveClient removes and closes an SFTP client.
func (s *SFTPService) RemoveClient(sessionID string) {
s.mu.Lock()
client, ok := s.clients[sessionID]
if ok {
delete(s.clients, sessionID)
}
s.mu.Unlock()
if ok && client != nil {
client.Close()
}
}
// getClient returns the SFTP client for a session or an error if not found.
func (s *SFTPService) getClient(sessionID string) (*sftp.Client, error) {
s.mu.RLock()
defer s.mu.RUnlock()
client, ok := s.clients[sessionID]
if !ok {
return nil, fmt.Errorf("no SFTP client for session %s", sessionID)
}
return client, nil
}
// SortEntries sorts file entries with directories first, then alphabetically by name.
func SortEntries(entries []FileEntry) {
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDir != entries[j].IsDir {
return entries[i].IsDir
}
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
})
}
// fileInfoToEntry converts an os.FileInfo and its path into a FileEntry.
func fileInfoToEntry(info os.FileInfo, path string) FileEntry {
return FileEntry{
Name: info.Name(),
Path: path,
Size: info.Size(),
IsDir: info.IsDir(),
Permissions: info.Mode().Perm().String(),
ModTime: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"),
}
}
// List returns directory contents sorted (dirs first, then files alphabetically).
func (s *SFTPService) List(sessionID string, path string) ([]FileEntry, error) {
client, err := s.getClient(sessionID)
if err != nil {
return nil, err
}
infos, err := client.ReadDir(path)
if err != nil {
return nil, fmt.Errorf("read directory %s: %w", path, err)
}
entries := make([]FileEntry, 0, len(infos))
for _, info := range infos {
entryPath := path
if !strings.HasSuffix(entryPath, "/") {
entryPath += "/"
}
entryPath += info.Name()
entries = append(entries, fileInfoToEntry(info, entryPath))
}
SortEntries(entries)
return entries, nil
}
// ReadFile reads a file (max 5MB). Returns content as string.
func (s *SFTPService) ReadFile(sessionID string, path string) (string, error) {
client, err := s.getClient(sessionID)
if err != nil {
return "", err
}
info, err := client.Stat(path)
if err != nil {
return "", fmt.Errorf("stat %s: %w", path, err)
}
if info.IsDir() {
return "", fmt.Errorf("%s is a directory", path)
}
if info.Size() > MaxEditFileSize {
return "", fmt.Errorf("file %s is %d bytes, exceeds max edit size of %d bytes", path, info.Size(), MaxEditFileSize)
}
f, err := client.Open(path)
if err != nil {
return "", fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
return string(data), nil
}
// WriteFile writes content to a file.
func (s *SFTPService) WriteFile(sessionID string, path string, content string) error {
client, err := s.getClient(sessionID)
if err != nil {
return err
}
f, err := client.Create(path)
if err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
defer f.Close()
if _, err := f.Write([]byte(content)); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
// Mkdir creates a directory.
func (s *SFTPService) Mkdir(sessionID string, path string) error {
client, err := s.getClient(sessionID)
if err != nil {
return err
}
if err := client.Mkdir(path); err != nil {
return fmt.Errorf("mkdir %s: %w", path, err)
}
return nil
}
// Delete removes a file or empty directory.
func (s *SFTPService) Delete(sessionID string, path string) error {
client, err := s.getClient(sessionID)
if err != nil {
return err
}
info, err := client.Stat(path)
if err != nil {
return fmt.Errorf("stat %s: %w", path, err)
}
if info.IsDir() {
if err := client.RemoveDirectory(path); err != nil {
return fmt.Errorf("remove directory %s: %w", path, err)
}
} else {
if err := client.Remove(path); err != nil {
return fmt.Errorf("remove %s: %w", path, err)
}
}
return nil
}
// Rename renames/moves a file.
func (s *SFTPService) Rename(sessionID string, oldPath, newPath string) error {
client, err := s.getClient(sessionID)
if err != nil {
return err
}
if err := client.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("rename %s to %s: %w", oldPath, newPath, err)
}
return nil
}
// Stat returns info about a file/directory.
func (s *SFTPService) Stat(sessionID string, path string) (*FileEntry, error) {
client, err := s.getClient(sessionID)
if err != nil {
return nil, err
}
info, err := client.Stat(path)
if err != nil {
return nil, fmt.Errorf("stat %s: %w", path, err)
}
entry := fileInfoToEntry(info, path)
return &entry, nil
}

View File

@ -1,119 +0,0 @@
package sftp
import (
"testing"
)
func TestNewSFTPService(t *testing.T) {
svc := NewSFTPService()
if svc == nil {
t.Fatal("nil")
}
}
func TestListWithoutClient(t *testing.T) {
svc := NewSFTPService()
_, err := svc.List("nonexistent", "/")
if err == nil {
t.Error("should error without client")
}
}
func TestReadFileWithoutClient(t *testing.T) {
svc := NewSFTPService()
_, err := svc.ReadFile("nonexistent", "/etc/hosts")
if err == nil {
t.Error("should error without client")
}
}
func TestWriteFileWithoutClient(t *testing.T) {
svc := NewSFTPService()
err := svc.WriteFile("nonexistent", "/tmp/test", "data")
if err == nil {
t.Error("should error without client")
}
}
func TestMkdirWithoutClient(t *testing.T) {
svc := NewSFTPService()
err := svc.Mkdir("nonexistent", "/tmp/newdir")
if err == nil {
t.Error("should error without client")
}
}
func TestDeleteWithoutClient(t *testing.T) {
svc := NewSFTPService()
err := svc.Delete("nonexistent", "/tmp/file")
if err == nil {
t.Error("should error without client")
}
}
func TestRenameWithoutClient(t *testing.T) {
svc := NewSFTPService()
err := svc.Rename("nonexistent", "/old", "/new")
if err == nil {
t.Error("should error without client")
}
}
func TestStatWithoutClient(t *testing.T) {
svc := NewSFTPService()
_, err := svc.Stat("nonexistent", "/tmp")
if err == nil {
t.Error("should error without client")
}
}
func TestFileEntrySorting(t *testing.T) {
// Test that SortEntries puts dirs first, then alpha
entries := []FileEntry{
{Name: "zebra.txt", IsDir: false},
{Name: "alpha", IsDir: true},
{Name: "beta.conf", IsDir: false},
{Name: "omega", IsDir: true},
}
SortEntries(entries)
if entries[0].Name != "alpha" {
t.Errorf("[0] = %s, want alpha", entries[0].Name)
}
if entries[1].Name != "omega" {
t.Errorf("[1] = %s, want omega", entries[1].Name)
}
if entries[2].Name != "beta.conf" {
t.Errorf("[2] = %s, want beta.conf", entries[2].Name)
}
if entries[3].Name != "zebra.txt" {
t.Errorf("[3] = %s, want zebra.txt", entries[3].Name)
}
}
func TestSortEntriesEmpty(t *testing.T) {
entries := []FileEntry{}
SortEntries(entries)
if len(entries) != 0 {
t.Errorf("expected empty slice, got %d entries", len(entries))
}
}
func TestSortEntriesCaseInsensitive(t *testing.T) {
entries := []FileEntry{
{Name: "Zebra", IsDir: false},
{Name: "alpha", IsDir: false},
}
SortEntries(entries)
if entries[0].Name != "alpha" {
t.Errorf("[0] = %s, want alpha", entries[0].Name)
}
if entries[1].Name != "Zebra" {
t.Errorf("[1] = %s, want Zebra", entries[1].Name)
}
}
func TestMaxEditFileSize(t *testing.T) {
if MaxEditFileSize != 5*1024*1024 {
t.Errorf("MaxEditFileSize = %d, want %d", MaxEditFileSize, 5*1024*1024)
}
}

View File

@ -1,128 +0,0 @@
package ssh
import (
"bytes"
"fmt"
"net/url"
"sync"
)
// CWDTracker parses OSC 7 escape sequences from terminal output to track the
// remote working directory.
type CWDTracker struct {
currentPath string
mu sync.RWMutex
}
// NewCWDTracker creates a new CWDTracker.
func NewCWDTracker() *CWDTracker {
return &CWDTracker{}
}
// osc7Prefix is the escape sequence that starts an OSC 7 directive.
var osc7Prefix = []byte("\033]7;")
// stTerminator is the ST (String Terminator) escape: ESC + backslash.
var stTerminator = []byte("\033\\")
// belTerminator is the BEL character, an alternative OSC terminator.
var belTerminator = []byte{0x07}
// ProcessOutput scans data for OSC 7 sequences of the form:
//
// \033]7;file://hostname/path\033\\ (ST terminator)
// \033]7;file://hostname/path\007 (BEL terminator)
//
// It returns cleaned output with all OSC 7 sequences stripped and the new CWD
// path (or "" if no OSC 7 was found).
func (t *CWDTracker) ProcessOutput(data []byte) (cleaned []byte, newCWD string) {
var result []byte
remaining := data
var lastCWD string
for {
idx := bytes.Index(remaining, osc7Prefix)
if idx == -1 {
result = append(result, remaining...)
break
}
// Append everything before the OSC 7 sequence.
result = append(result, remaining[:idx]...)
// Find the end of the OSC 7 payload (after the prefix).
afterPrefix := remaining[idx+len(osc7Prefix):]
// Try ST terminator first (\033\\), then BEL (\007).
endIdx := -1
terminatorLen := 0
if stIdx := bytes.Index(afterPrefix, stTerminator); stIdx != -1 {
endIdx = stIdx
terminatorLen = len(stTerminator)
}
if belIdx := bytes.Index(afterPrefix, belTerminator); belIdx != -1 {
if endIdx == -1 || belIdx < endIdx {
endIdx = belIdx
terminatorLen = len(belTerminator)
}
}
if endIdx == -1 {
// No terminator found; treat the rest as literal output.
result = append(result, remaining[idx:]...)
break
}
// Extract the URI payload between prefix and terminator.
payload := string(afterPrefix[:endIdx])
if path := extractPathFromOSC7(payload); path != "" {
lastCWD = path
}
remaining = afterPrefix[endIdx+terminatorLen:]
}
if lastCWD != "" {
t.mu.Lock()
t.currentPath = lastCWD
t.mu.Unlock()
}
return result, lastCWD
}
// GetCWD returns the current tracked working directory.
func (t *CWDTracker) GetCWD() string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.currentPath
}
// extractPathFromOSC7 parses a file:// URI and returns the path component.
func extractPathFromOSC7(uri string) string {
u, err := url.Parse(uri)
if err != nil {
return ""
}
if u.Scheme != "file" {
return ""
}
return u.Path
}
// ShellIntegrationCommand returns the shell command to inject for CWD tracking
// via OSC 7. The returned command sets up a prompt hook that emits the OSC 7
// escape sequence after every command.
func ShellIntegrationCommand(shellType string) string {
switch shellType {
case "bash":
return fmt.Sprintf(`PROMPT_COMMAND='printf "\033]7;file://%%s%%s\033\\" "$HOSTNAME" "$PWD"'`)
case "zsh":
return fmt.Sprintf(`precmd() { printf '\033]7;file://%%s%%s\033\\' "$HOST" "$PWD" }`)
case "fish":
return `function __wraith_osc7 --on-event fish_prompt; printf '\033]7;file://%s%s\033\\' (hostname) (pwd); end`
default:
return ""
}
}

View File

@ -1,72 +0,0 @@
package ssh
import (
"testing"
)
func TestProcessOutputBasicOSC7(t *testing.T) {
tracker := NewCWDTracker()
input := []byte("hello\033]7;file://myhost/home/user\033\\world")
cleaned, cwd := tracker.ProcessOutput(input)
if string(cleaned) != "helloworld" {
t.Errorf("cleaned = %q", string(cleaned))
}
if cwd != "/home/user" {
t.Errorf("cwd = %q", cwd)
}
}
func TestProcessOutputBELTerminator(t *testing.T) {
tracker := NewCWDTracker()
input := []byte("output\033]7;file://host/tmp\007more")
cleaned, cwd := tracker.ProcessOutput(input)
if string(cleaned) != "outputmore" {
t.Errorf("cleaned = %q", string(cleaned))
}
if cwd != "/tmp" {
t.Errorf("cwd = %q", cwd)
}
}
func TestProcessOutputNoOSC7(t *testing.T) {
tracker := NewCWDTracker()
input := []byte("just normal output")
cleaned, cwd := tracker.ProcessOutput(input)
if string(cleaned) != "just normal output" {
t.Errorf("cleaned = %q", string(cleaned))
}
if cwd != "" {
t.Errorf("cwd should be empty, got %q", cwd)
}
}
func TestProcessOutputMultipleOSC7(t *testing.T) {
tracker := NewCWDTracker()
input := []byte("\033]7;file://h/dir1\033\\text\033]7;file://h/dir2\033\\end")
cleaned, cwd := tracker.ProcessOutput(input)
if string(cleaned) != "textend" {
t.Errorf("cleaned = %q", string(cleaned))
}
if cwd != "/dir2" {
t.Errorf("cwd = %q, want /dir2", cwd)
}
}
func TestGetCWDPersists(t *testing.T) {
tracker := NewCWDTracker()
tracker.ProcessOutput([]byte("\033]7;file://h/home/user\033\\"))
if tracker.GetCWD() != "/home/user" {
t.Errorf("GetCWD = %q", tracker.GetCWD())
}
}
func TestShellIntegrationCommand(t *testing.T) {
cmd := ShellIntegrationCommand("bash")
if cmd == "" {
t.Error("bash command should not be empty")
}
cmd = ShellIntegrationCommand("zsh")
if cmd == "" {
t.Error("zsh command should not be empty")
}
}

View File

@ -1,89 +0,0 @@
package ssh
import (
"database/sql"
"fmt"
)
// HostKeyResult represents the result of a host key verification.
type HostKeyResult int
const (
HostKeyNew HostKeyResult = iota // never seen this host before
HostKeyMatch // fingerprint matches stored
HostKeyChanged // fingerprint CHANGED — possible MITM
)
// HostKeyStore stores and verifies SSH host key fingerprints in the host_keys SQLite table.
type HostKeyStore struct {
db *sql.DB
}
// NewHostKeyStore creates a new HostKeyStore backed by the given database.
func NewHostKeyStore(db *sql.DB) *HostKeyStore {
return &HostKeyStore{db: db}
}
// Verify checks whether the given fingerprint matches any stored host key for the
// specified hostname, port, and key type. It returns HostKeyNew if no key is stored,
// HostKeyMatch if the fingerprint matches, or HostKeyChanged if it differs.
func (s *HostKeyStore) Verify(hostname string, port int, keyType string, fingerprint string) (HostKeyResult, error) {
var storedFingerprint string
err := s.db.QueryRow(
"SELECT fingerprint FROM host_keys WHERE hostname = ? AND port = ? AND key_type = ?",
hostname, port, keyType,
).Scan(&storedFingerprint)
if err == sql.ErrNoRows {
return HostKeyNew, nil
}
if err != nil {
return 0, fmt.Errorf("query host key: %w", err)
}
if storedFingerprint == fingerprint {
return HostKeyMatch, nil
}
return HostKeyChanged, nil
}
// Store inserts or replaces a host key fingerprint for the given hostname, port, and key type.
func (s *HostKeyStore) Store(hostname string, port int, keyType string, fingerprint string, rawKey string) error {
_, err := s.db.Exec(
`INSERT INTO host_keys (hostname, port, key_type, fingerprint, raw_key)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (hostname, port, key_type)
DO UPDATE SET fingerprint = excluded.fingerprint, raw_key = excluded.raw_key`,
hostname, port, keyType, fingerprint, rawKey,
)
if err != nil {
return fmt.Errorf("store host key: %w", err)
}
return nil
}
// Delete removes all stored host keys for the given hostname and port.
func (s *HostKeyStore) Delete(hostname string, port int) error {
_, err := s.db.Exec(
"DELETE FROM host_keys WHERE hostname = ? AND port = ?",
hostname, port,
)
if err != nil {
return fmt.Errorf("delete host key: %w", err)
}
return nil
}
// GetFingerprint returns the stored fingerprint for the given hostname and port.
// It returns an empty string and sql.ErrNoRows if no key is stored.
func (s *HostKeyStore) GetFingerprint(hostname string, port int) (string, error) {
var fingerprint string
err := s.db.QueryRow(
"SELECT fingerprint FROM host_keys WHERE hostname = ? AND port = ?",
hostname, port,
).Scan(&fingerprint)
if err != nil {
return "", fmt.Errorf("get fingerprint: %w", err)
}
return fingerprint, nil
}

View File

@ -1,77 +0,0 @@
package ssh
import (
"path/filepath"
"testing"
"github.com/vstockwell/wraith/internal/db"
)
func setupHostKeyStore(t *testing.T) *HostKeyStore {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(d); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
return NewHostKeyStore(d)
}
func TestVerifyNewHost(t *testing.T) {
store := setupHostKeyStore(t)
result, err := store.Verify("192.168.1.4", 22, "ssh-ed25519", "SHA256:abc123")
if err != nil {
t.Fatal(err)
}
if result != HostKeyNew {
t.Errorf("got %d, want HostKeyNew", result)
}
}
func TestStoreAndVerifyMatch(t *testing.T) {
store := setupHostKeyStore(t)
if err := store.Store("192.168.1.4", 22, "ssh-ed25519", "SHA256:abc123", "AAAA..."); err != nil {
t.Fatal(err)
}
result, err := store.Verify("192.168.1.4", 22, "ssh-ed25519", "SHA256:abc123")
if err != nil {
t.Fatal(err)
}
if result != HostKeyMatch {
t.Errorf("got %d, want HostKeyMatch", result)
}
}
func TestVerifyChangedKey(t *testing.T) {
store := setupHostKeyStore(t)
if err := store.Store("192.168.1.4", 22, "ssh-ed25519", "SHA256:abc123", "AAAA..."); err != nil {
t.Fatal(err)
}
result, err := store.Verify("192.168.1.4", 22, "ssh-ed25519", "SHA256:DIFFERENT")
if err != nil {
t.Fatal(err)
}
if result != HostKeyChanged {
t.Errorf("got %d, want HostKeyChanged", result)
}
}
func TestDeleteHostKey(t *testing.T) {
store := setupHostKeyStore(t)
if err := store.Store("192.168.1.4", 22, "ssh-ed25519", "SHA256:abc123", "AAAA..."); err != nil {
t.Fatal(err)
}
if err := store.Delete("192.168.1.4", 22); err != nil {
t.Fatal(err)
}
result, err := store.Verify("192.168.1.4", 22, "ssh-ed25519", "SHA256:abc123")
if err != nil {
t.Fatal(err)
}
if result != HostKeyNew {
t.Errorf("after delete, got %d, want HostKeyNew", result)
}
}

View File

@ -1,259 +0,0 @@
package ssh
import (
"database/sql"
"fmt"
"io"
"sync"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/ssh"
)
// OutputHandler is called when data is read from an SSH session's stdout.
// In production this will emit Wails events; for testing, a simple callback.
type OutputHandler func(sessionID string, data []byte)
// SSHSession represents an active SSH connection with its PTY shell session.
type SSHSession struct {
ID string
Client *ssh.Client
Session *ssh.Session
Stdin io.WriteCloser
ConnID int64
Hostname string
Port int
Username string
Connected time.Time
mu sync.Mutex
}
// SSHService manages SSH connections and their associated sessions.
type SSHService struct {
sessions map[string]*SSHSession
mu sync.RWMutex
db *sql.DB
outputHandler OutputHandler
}
// NewSSHService creates a new SSHService. The outputHandler is called when data
// arrives from a session's stdout. Pass nil if output handling is not needed.
func NewSSHService(db *sql.DB, outputHandler OutputHandler) *SSHService {
return &SSHService{
sessions: make(map[string]*SSHSession),
db: db,
outputHandler: outputHandler,
}
}
// Connect dials an SSH server, opens a session with a PTY and shell, and
// launches a goroutine to read stdout. Returns the session ID.
func (s *SSHService) Connect(hostname string, port int, username string, authMethods []ssh.AuthMethod, cols, rows int) (string, error) {
addr := fmt.Sprintf("%s:%d", hostname, port)
config := &ssh.ClientConfig{
User: username,
Auth: authMethods,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 15 * time.Second,
}
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return "", fmt.Errorf("ssh dial %s: %w", addr, err)
}
session, err := client.NewSession()
if err != nil {
client.Close()
return "", fmt.Errorf("new session: %w", err)
}
modes := ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
session.Close()
client.Close()
return "", fmt.Errorf("request pty: %w", err)
}
stdin, err := session.StdinPipe()
if err != nil {
session.Close()
client.Close()
return "", fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := session.StdoutPipe()
if err != nil {
session.Close()
client.Close()
return "", fmt.Errorf("stdout pipe: %w", err)
}
if err := session.Shell(); err != nil {
session.Close()
client.Close()
return "", fmt.Errorf("start shell: %w", err)
}
sessionID := uuid.NewString()
sshSession := &SSHSession{
ID: sessionID,
Client: client,
Session: session,
Stdin: stdin,
Hostname: hostname,
Port: port,
Username: username,
Connected: time.Now(),
}
s.mu.Lock()
s.sessions[sessionID] = sshSession
s.mu.Unlock()
// Launch goroutine to read stdout and forward data via the output handler
go s.readLoop(sessionID, stdout)
return sessionID, nil
}
// readLoop continuously reads from the session stdout and calls the output
// handler with data. It stops when the reader returns an error (typically EOF
// when the session closes).
func (s *SSHService) readLoop(sessionID string, reader io.Reader) {
buf := make([]byte, 32*1024)
for {
n, err := reader.Read(buf)
if n > 0 && s.outputHandler != nil {
data := make([]byte, n)
copy(data, buf[:n])
s.outputHandler(sessionID, data)
}
if err != nil {
break
}
}
}
// Write sends data to the session's stdin.
func (s *SSHService) Write(sessionID string, data string) error {
s.mu.RLock()
sess, ok := s.sessions[sessionID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("session %s not found", sessionID)
}
sess.mu.Lock()
defer sess.mu.Unlock()
if sess.Stdin == nil {
return fmt.Errorf("session %s stdin is closed", sessionID)
}
_, err := sess.Stdin.Write([]byte(data))
if err != nil {
return fmt.Errorf("write to session %s: %w", sessionID, err)
}
return nil
}
// Resize sends a window-change request to the remote PTY.
func (s *SSHService) Resize(sessionID string, cols, rows int) error {
s.mu.RLock()
sess, ok := s.sessions[sessionID]
s.mu.RUnlock()
if !ok {
return fmt.Errorf("session %s not found", sessionID)
}
sess.mu.Lock()
defer sess.mu.Unlock()
if sess.Session == nil {
return fmt.Errorf("session %s is closed", sessionID)
}
if err := sess.Session.WindowChange(rows, cols); err != nil {
return fmt.Errorf("resize session %s: %w", sessionID, err)
}
return nil
}
// Disconnect closes the SSH session and client, and removes it from tracking.
func (s *SSHService) Disconnect(sessionID string) error {
s.mu.Lock()
sess, ok := s.sessions[sessionID]
if !ok {
s.mu.Unlock()
return fmt.Errorf("session %s not found", sessionID)
}
delete(s.sessions, sessionID)
s.mu.Unlock()
sess.mu.Lock()
defer sess.mu.Unlock()
if sess.Stdin != nil {
sess.Stdin.Close()
}
if sess.Session != nil {
sess.Session.Close()
}
if sess.Client != nil {
sess.Client.Close()
}
return nil
}
// GetSession returns the SSHSession for the given ID, or false if not found.
func (s *SSHService) GetSession(sessionID string) (*SSHSession, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
sess, ok := s.sessions[sessionID]
return sess, ok
}
// ListSessions returns all active SSH sessions.
func (s *SSHService) ListSessions() []*SSHSession {
s.mu.RLock()
defer s.mu.RUnlock()
list := make([]*SSHSession, 0, len(s.sessions))
for _, sess := range s.sessions {
list = append(list, sess)
}
return list
}
// BuildPasswordAuth creates an ssh.AuthMethod for password authentication.
func (s *SSHService) BuildPasswordAuth(password string) ssh.AuthMethod {
return ssh.Password(password)
}
// BuildKeyAuth creates an ssh.AuthMethod from a PEM-encoded private key.
// If the key is encrypted, pass the passphrase; otherwise pass an empty string.
func (s *SSHService) BuildKeyAuth(privateKey []byte, passphrase string) (ssh.AuthMethod, error) {
var signer ssh.Signer
var err error
if passphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(passphrase))
} else {
signer, err = ssh.ParsePrivateKey(privateKey)
}
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
return ssh.PublicKeys(signer), nil
}

View File

@ -1,148 +0,0 @@
package ssh
import (
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
func TestNewSSHService(t *testing.T) {
svc := NewSSHService(nil, nil)
if svc == nil {
t.Fatal("NewSSHService returned nil")
}
if len(svc.ListSessions()) != 0 {
t.Error("new service should have no sessions")
}
}
func TestBuildPasswordAuth(t *testing.T) {
svc := NewSSHService(nil, nil)
auth := svc.BuildPasswordAuth("mypassword")
if auth == nil {
t.Error("BuildPasswordAuth returned nil")
}
}
func TestBuildKeyAuth(t *testing.T) {
svc := NewSSHService(nil, nil)
// Generate a test Ed25519 key
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey error: %v", err)
}
pemBlock, err := ssh.MarshalPrivateKey(priv, "")
if err != nil {
t.Fatalf("MarshalPrivateKey error: %v", err)
}
keyBytes := pem.EncodeToMemory(pemBlock)
auth, err := svc.BuildKeyAuth(keyBytes, "")
if err != nil {
t.Fatalf("BuildKeyAuth error: %v", err)
}
if auth == nil {
t.Error("BuildKeyAuth returned nil")
}
}
func TestBuildKeyAuthInvalidKey(t *testing.T) {
svc := NewSSHService(nil, nil)
_, err := svc.BuildKeyAuth([]byte("not a key"), "")
if err == nil {
t.Error("BuildKeyAuth should fail with invalid key")
}
}
func TestSessionTracking(t *testing.T) {
svc := NewSSHService(nil, nil)
// Manually add a session to test tracking
svc.mu.Lock()
svc.sessions["test-123"] = &SSHSession{
ID: "test-123",
Hostname: "192.168.1.4",
Port: 22,
Username: "vstockwell",
Connected: time.Now(),
}
svc.mu.Unlock()
s, ok := svc.GetSession("test-123")
if !ok {
t.Fatal("session not found")
}
if s.Hostname != "192.168.1.4" {
t.Errorf("Hostname = %q, want %q", s.Hostname, "192.168.1.4")
}
sessions := svc.ListSessions()
if len(sessions) != 1 {
t.Errorf("ListSessions() = %d, want 1", len(sessions))
}
}
func TestGetSessionNotFound(t *testing.T) {
svc := NewSSHService(nil, nil)
_, ok := svc.GetSession("nonexistent")
if ok {
t.Error("GetSession should return false for nonexistent session")
}
}
func TestWriteNotFound(t *testing.T) {
svc := NewSSHService(nil, nil)
err := svc.Write("nonexistent", "data")
if err == nil {
t.Error("Write should fail for nonexistent session")
}
}
func TestResizeNotFound(t *testing.T) {
svc := NewSSHService(nil, nil)
err := svc.Resize("nonexistent", 80, 24)
if err == nil {
t.Error("Resize should fail for nonexistent session")
}
}
func TestDisconnectNotFound(t *testing.T) {
svc := NewSSHService(nil, nil)
err := svc.Disconnect("nonexistent")
if err == nil {
t.Error("Disconnect should fail for nonexistent session")
}
}
func TestDisconnectRemovesSession(t *testing.T) {
svc := NewSSHService(nil, nil)
// Manually add a session with nil Client/Session/Stdin (no real connection)
svc.mu.Lock()
svc.sessions["test-dc"] = &SSHSession{
ID: "test-dc",
Hostname: "10.0.0.1",
Port: 22,
Username: "admin",
Connected: time.Now(),
}
svc.mu.Unlock()
if err := svc.Disconnect("test-dc"); err != nil {
t.Fatalf("Disconnect error: %v", err)
}
_, ok := svc.GetSession("test-dc")
if ok {
t.Error("session should be removed after Disconnect")
}
if len(svc.ListSessions()) != 0 {
t.Error("ListSessions should be empty after Disconnect")
}
}

View File

@ -1,94 +0,0 @@
package theme
type Theme struct {
ID int64 `json:"id"`
Name string `json:"name"`
Foreground string `json:"foreground"`
Background string `json:"background"`
Cursor string `json:"cursor"`
Black string `json:"black"`
Red string `json:"red"`
Green string `json:"green"`
Yellow string `json:"yellow"`
Blue string `json:"blue"`
Magenta string `json:"magenta"`
Cyan string `json:"cyan"`
White string `json:"white"`
BrightBlack string `json:"brightBlack"`
BrightRed string `json:"brightRed"`
BrightGreen string `json:"brightGreen"`
BrightYellow string `json:"brightYellow"`
BrightBlue string `json:"brightBlue"`
BrightMagenta string `json:"brightMagenta"`
BrightCyan string `json:"brightCyan"`
BrightWhite string `json:"brightWhite"`
SelectionBg string `json:"selectionBg,omitempty"`
SelectionFg string `json:"selectionFg,omitempty"`
IsBuiltin bool `json:"isBuiltin"`
}
var BuiltinThemes = []Theme{
{
Name: "Dracula", IsBuiltin: true,
Foreground: "#f8f8f2", Background: "#282a36", Cursor: "#f8f8f2",
Black: "#21222c", Red: "#ff5555", Green: "#50fa7b", Yellow: "#f1fa8c",
Blue: "#bd93f9", Magenta: "#ff79c6", Cyan: "#8be9fd", White: "#f8f8f2",
BrightBlack: "#6272a4", BrightRed: "#ff6e6e", BrightGreen: "#69ff94",
BrightYellow: "#ffffa5", BrightBlue: "#d6acff", BrightMagenta: "#ff92df",
BrightCyan: "#a4ffff", BrightWhite: "#ffffff",
},
{
Name: "Nord", IsBuiltin: true,
Foreground: "#d8dee9", Background: "#2e3440", Cursor: "#d8dee9",
Black: "#3b4252", Red: "#bf616a", Green: "#a3be8c", Yellow: "#ebcb8b",
Blue: "#81a1c1", Magenta: "#b48ead", Cyan: "#88c0d0", White: "#e5e9f0",
BrightBlack: "#4c566a", BrightRed: "#bf616a", BrightGreen: "#a3be8c",
BrightYellow: "#ebcb8b", BrightBlue: "#81a1c1", BrightMagenta: "#b48ead",
BrightCyan: "#8fbcbb", BrightWhite: "#eceff4",
},
{
Name: "Monokai", IsBuiltin: true,
Foreground: "#f8f8f2", Background: "#272822", Cursor: "#f8f8f0",
Black: "#272822", Red: "#f92672", Green: "#a6e22e", Yellow: "#f4bf75",
Blue: "#66d9ef", Magenta: "#ae81ff", Cyan: "#a1efe4", White: "#f8f8f2",
BrightBlack: "#75715e", BrightRed: "#f92672", BrightGreen: "#a6e22e",
BrightYellow: "#f4bf75", BrightBlue: "#66d9ef", BrightMagenta: "#ae81ff",
BrightCyan: "#a1efe4", BrightWhite: "#f9f8f5",
},
{
Name: "One Dark", IsBuiltin: true,
Foreground: "#abb2bf", Background: "#282c34", Cursor: "#528bff",
Black: "#282c34", Red: "#e06c75", Green: "#98c379", Yellow: "#e5c07b",
Blue: "#61afef", Magenta: "#c678dd", Cyan: "#56b6c2", White: "#abb2bf",
BrightBlack: "#545862", BrightRed: "#e06c75", BrightGreen: "#98c379",
BrightYellow: "#e5c07b", BrightBlue: "#61afef", BrightMagenta: "#c678dd",
BrightCyan: "#56b6c2", BrightWhite: "#c8ccd4",
},
{
Name: "Solarized Dark", IsBuiltin: true,
Foreground: "#839496", Background: "#002b36", Cursor: "#839496",
Black: "#073642", Red: "#dc322f", Green: "#859900", Yellow: "#b58900",
Blue: "#268bd2", Magenta: "#d33682", Cyan: "#2aa198", White: "#eee8d5",
BrightBlack: "#002b36", BrightRed: "#cb4b16", BrightGreen: "#586e75",
BrightYellow: "#657b83", BrightBlue: "#839496", BrightMagenta: "#6c71c4",
BrightCyan: "#93a1a1", BrightWhite: "#fdf6e3",
},
{
Name: "Gruvbox Dark", IsBuiltin: true,
Foreground: "#ebdbb2", Background: "#282828", Cursor: "#ebdbb2",
Black: "#282828", Red: "#cc241d", Green: "#98971a", Yellow: "#d79921",
Blue: "#458588", Magenta: "#b16286", Cyan: "#689d6a", White: "#a89984",
BrightBlack: "#928374", BrightRed: "#fb4934", BrightGreen: "#b8bb26",
BrightYellow: "#fabd2f", BrightBlue: "#83a598", BrightMagenta: "#d3869b",
BrightCyan: "#8ec07c", BrightWhite: "#ebdbb2",
},
{
Name: "MobaXTerm Classic", IsBuiltin: true,
Foreground: "#ececec", Background: "#242424", Cursor: "#b4b4c0",
Black: "#000000", Red: "#aa4244", Green: "#7e8d53", Yellow: "#e4b46d",
Blue: "#6e9aba", Magenta: "#9e5085", Cyan: "#80d5cf", White: "#cccccc",
BrightBlack: "#808080", BrightRed: "#cc7b7d", BrightGreen: "#a5b17c",
BrightYellow: "#ecc995", BrightBlue: "#96b6cd", BrightMagenta: "#c083ac",
BrightCyan: "#a9e2de", BrightWhite: "#cccccc",
},
}

View File

@ -1,85 +0,0 @@
package theme
import (
"database/sql"
"fmt"
)
type ThemeService struct {
db *sql.DB
}
func NewThemeService(db *sql.DB) *ThemeService {
return &ThemeService{db: db}
}
func (s *ThemeService) SeedBuiltins() error {
for _, t := range BuiltinThemes {
_, err := s.db.Exec(
`INSERT OR IGNORE INTO themes (name, foreground, background, cursor,
black, red, green, yellow, blue, magenta, cyan, white,
bright_black, bright_red, bright_green, bright_yellow, bright_blue,
bright_magenta, bright_cyan, bright_white, selection_bg, selection_fg, is_builtin)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)`,
t.Name, t.Foreground, t.Background, t.Cursor,
t.Black, t.Red, t.Green, t.Yellow, t.Blue, t.Magenta, t.Cyan, t.White,
t.BrightBlack, t.BrightRed, t.BrightGreen, t.BrightYellow, t.BrightBlue,
t.BrightMagenta, t.BrightCyan, t.BrightWhite, t.SelectionBg, t.SelectionFg,
)
if err != nil {
return fmt.Errorf("seed theme %s: %w", t.Name, err)
}
}
return nil
}
func (s *ThemeService) List() ([]Theme, error) {
rows, err := s.db.Query(
`SELECT id, name, foreground, background, cursor,
black, red, green, yellow, blue, magenta, cyan, white,
bright_black, bright_red, bright_green, bright_yellow, bright_blue,
bright_magenta, bright_cyan, bright_white,
COALESCE(selection_bg,''), COALESCE(selection_fg,''), is_builtin
FROM themes ORDER BY is_builtin DESC, name`)
if err != nil {
return nil, err
}
defer rows.Close()
var themes []Theme
for rows.Next() {
var t Theme
if err := rows.Scan(&t.ID, &t.Name, &t.Foreground, &t.Background, &t.Cursor,
&t.Black, &t.Red, &t.Green, &t.Yellow, &t.Blue, &t.Magenta, &t.Cyan, &t.White,
&t.BrightBlack, &t.BrightRed, &t.BrightGreen, &t.BrightYellow, &t.BrightBlue,
&t.BrightMagenta, &t.BrightCyan, &t.BrightWhite,
&t.SelectionBg, &t.SelectionFg, &t.IsBuiltin); err != nil {
return nil, err
}
themes = append(themes, t)
}
if err := rows.Err(); err != nil {
return nil, err
}
return themes, nil
}
func (s *ThemeService) GetByName(name string) (*Theme, error) {
var t Theme
err := s.db.QueryRow(
`SELECT id, name, foreground, background, cursor,
black, red, green, yellow, blue, magenta, cyan, white,
bright_black, bright_red, bright_green, bright_yellow, bright_blue,
bright_magenta, bright_cyan, bright_white,
COALESCE(selection_bg,''), COALESCE(selection_fg,''), is_builtin
FROM themes WHERE name = ?`, name,
).Scan(&t.ID, &t.Name, &t.Foreground, &t.Background, &t.Cursor,
&t.Black, &t.Red, &t.Green, &t.Yellow, &t.Blue, &t.Magenta, &t.Cyan, &t.White,
&t.BrightBlack, &t.BrightRed, &t.BrightGreen, &t.BrightYellow, &t.BrightBlue,
&t.BrightMagenta, &t.BrightCyan, &t.BrightWhite,
&t.SelectionBg, &t.SelectionFg, &t.IsBuiltin)
if err != nil {
return nil, fmt.Errorf("get theme %s: %w", name, err)
}
return &t, nil
}

View File

@ -1,60 +0,0 @@
package theme
import (
"path/filepath"
"testing"
"github.com/vstockwell/wraith/internal/db"
)
func setupTestDB(t *testing.T) *ThemeService {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(d); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
return NewThemeService(d)
}
func TestSeedBuiltins(t *testing.T) {
svc := setupTestDB(t)
if err := svc.SeedBuiltins(); err != nil {
t.Fatalf("SeedBuiltins() error: %v", err)
}
themes, err := svc.List()
if err != nil {
t.Fatalf("List() error: %v", err)
}
if len(themes) != len(BuiltinThemes) {
t.Errorf("len(themes) = %d, want %d", len(themes), len(BuiltinThemes))
}
}
func TestSeedBuiltinsIdempotent(t *testing.T) {
svc := setupTestDB(t)
svc.SeedBuiltins()
svc.SeedBuiltins()
themes, _ := svc.List()
if len(themes) != len(BuiltinThemes) {
t.Errorf("len(themes) = %d after double seed, want %d", len(themes), len(BuiltinThemes))
}
}
func TestGetByName(t *testing.T) {
svc := setupTestDB(t)
svc.SeedBuiltins()
theme, err := svc.GetByName("Dracula")
if err != nil {
t.Fatalf("GetByName() error: %v", err)
}
if theme.Background != "#282a36" {
t.Errorf("Background = %q, want %q", theme.Background, "#282a36")
}
}

View File

@ -1,95 +0,0 @@
package vault
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
const (
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 4
argonKeyLen = 32
)
func DeriveKey(password string, salt []byte) []byte {
return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
}
func GenerateSalt() ([]byte, error) {
salt := make([]byte, 32)
if _, err := rand.Read(salt); err != nil {
return nil, fmt.Errorf("generate salt: %w", err)
}
return salt, nil
}
type VaultService struct {
key []byte
}
func NewVaultService(key []byte) *VaultService {
return &VaultService{key: key}
}
func (v *VaultService) Encrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(v.key)
if err != nil {
return "", fmt.Errorf("create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("create GCM: %w", err)
}
iv := make([]byte, gcm.NonceSize())
if _, err := rand.Read(iv); err != nil {
return "", fmt.Errorf("generate IV: %w", err)
}
sealed := gcm.Seal(nil, iv, []byte(plaintext), nil)
return fmt.Sprintf("v1:%s:%s", hex.EncodeToString(iv), hex.EncodeToString(sealed)), nil
}
func (v *VaultService) Decrypt(encrypted string) (string, error) {
parts := strings.SplitN(encrypted, ":", 3)
if len(parts) != 3 || parts[0] != "v1" {
return "", errors.New("invalid encrypted format: expected v1:{iv}:{sealed}")
}
iv, err := hex.DecodeString(parts[1])
if err != nil {
return "", fmt.Errorf("decode IV: %w", err)
}
sealed, err := hex.DecodeString(parts[2])
if err != nil {
return "", fmt.Errorf("decode sealed data: %w", err)
}
block, err := aes.NewCipher(v.key)
if err != nil {
return "", fmt.Errorf("create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("create GCM: %w", err)
}
plaintext, err := gcm.Open(nil, iv, sealed, nil)
if err != nil {
return "", fmt.Errorf("decrypt: %w", err)
}
return string(plaintext), nil
}

View File

@ -1,104 +0,0 @@
package vault
import (
"strings"
"testing"
)
func TestDeriveKeyConsistent(t *testing.T) {
salt := []byte("test-salt-exactly-32-bytes-long!")
key1 := DeriveKey("mypassword", salt)
key2 := DeriveKey("mypassword", salt)
if len(key1) != 32 {
t.Errorf("key length = %d, want 32", len(key1))
}
if string(key1) != string(key2) {
t.Error("same password+salt produced different keys")
}
}
func TestDeriveKeyDifferentPasswords(t *testing.T) {
salt := []byte("test-salt-exactly-32-bytes-long!")
key1 := DeriveKey("password1", salt)
key2 := DeriveKey("password2", salt)
if string(key1) == string(key2) {
t.Error("different passwords produced same key")
}
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
key := DeriveKey("testpassword", []byte("test-salt-exactly-32-bytes-long!"))
vs := NewVaultService(key)
plaintext := "super-secret-ssh-key-data"
encrypted, err := vs.Encrypt(plaintext)
if err != nil {
t.Fatalf("Encrypt() error: %v", err)
}
if !strings.HasPrefix(encrypted, "v1:") {
t.Errorf("encrypted does not start with v1: prefix: %q", encrypted[:10])
}
decrypted, err := vs.Decrypt(encrypted)
if err != nil {
t.Fatalf("Decrypt() error: %v", err)
}
if decrypted != plaintext {
t.Errorf("Decrypt() = %q, want %q", decrypted, plaintext)
}
}
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
key := DeriveKey("testpassword", []byte("test-salt-exactly-32-bytes-long!"))
vs := NewVaultService(key)
enc1, _ := vs.Encrypt("same-data")
enc2, _ := vs.Encrypt("same-data")
if enc1 == enc2 {
t.Error("two encryptions of same data produced identical ciphertext (IV reuse)")
}
}
func TestDecryptWrongKey(t *testing.T) {
key1 := DeriveKey("password1", []byte("test-salt-exactly-32-bytes-long!"))
key2 := DeriveKey("password2", []byte("test-salt-exactly-32-bytes-long!"))
vs1 := NewVaultService(key1)
vs2 := NewVaultService(key2)
encrypted, _ := vs1.Encrypt("secret")
_, err := vs2.Decrypt(encrypted)
if err == nil {
t.Error("Decrypt() with wrong key should return error")
}
}
func TestDecryptInvalidFormat(t *testing.T) {
key := DeriveKey("test", []byte("test-salt-exactly-32-bytes-long!"))
vs := NewVaultService(key)
_, err := vs.Decrypt("not-valid-format")
if err == nil {
t.Error("Decrypt() with invalid format should return error")
}
}
func TestGenerateSalt(t *testing.T) {
salt1, err := GenerateSalt()
if err != nil {
t.Fatalf("GenerateSalt() error: %v", err)
}
if len(salt1) != 32 {
t.Errorf("salt length = %d, want 32", len(salt1))
}
salt2, _ := GenerateSalt()
if string(salt1) == string(salt2) {
t.Error("two calls to GenerateSalt produced identical salt")
}
}

Some files were not shown because too many files have changed in this diff Show More