Compare commits
No commits in common. "main" and "v1.4.5" have entirely different histories.
@ -61,24 +61,13 @@ jobs:
|
|||||||
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
||||||
cargo install tauri-cli --version "^2"
|
cargo install tauri-cli --version "^2"
|
||||||
|
|
||||||
- name: Build Tauri app (with update signing)
|
- name: Build Tauri app
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
||||||
$env:TAURI_SIGNING_PRIVATE_KEY = "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}"
|
|
||||||
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}"
|
|
||||||
cargo tauri build
|
cargo tauri build
|
||||||
Write-Host "=== Build output ==="
|
Write-Host "=== Build output ==="
|
||||||
Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*
|
Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
|
||||||
|
|
||||||
- name: Build and package MCP bridge binary
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
|
||||||
cd src-tauri
|
|
||||||
cargo build --release --bin wraith-mcp-bridge
|
|
||||||
Write-Host "Bridge binary built:"
|
|
||||||
Get-ChildItem target\release\wraith-mcp-bridge.exe
|
|
||||||
|
|
||||||
- name: Download jsign
|
- name: Download jsign
|
||||||
shell: powershell
|
shell: powershell
|
||||||
@ -104,10 +93,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
||||||
$token = [System.IO.File]::ReadAllText("$env:TEMP\aztoken.txt")
|
$token = [System.IO.File]::ReadAllText("$env:TEMP\aztoken.txt")
|
||||||
# Sign NSIS installers + MCP bridge binary
|
$binaries = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
|
||||||
$binaries = @()
|
|
||||||
$binaries += Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
|
|
||||||
$binaries += Get-Item src-tauri\target\release\wraith-mcp-bridge.exe -ErrorAction SilentlyContinue
|
|
||||||
foreach ($binary in $binaries) {
|
foreach ($binary in $binaries) {
|
||||||
Write-Host "Signing: $($binary.FullName)"
|
Write-Host "Signing: $($binary.FullName)"
|
||||||
java -jar jsign.jar --storetype AZUREKEYVAULT --keystore "${{ secrets.AZURE_KEY_VAULT_URL }}" --storepass $token --alias "${{ secrets.AZURE_CERT_NAME }}" --tsaurl http://timestamp.digicert.com --tsmode RFC3161 $binary.FullName
|
java -jar jsign.jar --storetype AZUREKEYVAULT --keystore "${{ secrets.AZURE_KEY_VAULT_URL }}" --storepass $token --alias "${{ secrets.AZURE_CERT_NAME }}" --tsaurl http://timestamp.digicert.com --tsmode RFC3161 $binary.FullName
|
||||||
@ -115,83 +101,42 @@ jobs:
|
|||||||
}
|
}
|
||||||
Remove-Item "$env:TEMP\aztoken.txt" -ErrorAction SilentlyContinue
|
Remove-Item "$env:TEMP\aztoken.txt" -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
- name: Upload all artifacts to SeaweedFS
|
- name: Upload to Gitea
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
$ver = ("${{ github.ref_name }}" -replace '^v','')
|
$ver = ("${{ github.ref_name }}" -replace '^v','')
|
||||||
$s3 = "https://files.command.vigilcyber.com/wraith"
|
$giteaUrl = "https://git.command.vigilcyber.com"
|
||||||
|
$headers = @{ Authorization = "token ${{ secrets.GIT_TOKEN }}" }
|
||||||
|
|
||||||
# Upload installer
|
|
||||||
$installers = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
|
$installers = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
|
||||||
foreach ($file in $installers) {
|
foreach ($file in $installers) {
|
||||||
|
$hash = (Get-FileHash $file.FullName -Algorithm SHA256).Hash.ToLower()
|
||||||
|
@{ version = $ver; filename = $file.Name; sha256 = $hash; platform = "windows"; architecture = "amd64"; released = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"); signed = $true } | ConvertTo-Json | Out-File version.json -Encoding utf8
|
||||||
|
|
||||||
Write-Host "Uploading: $($file.Name)"
|
Write-Host "Uploading: $($file.Name)"
|
||||||
Invoke-RestMethod -Uri "$s3/$ver/$($file.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $file.FullName
|
Invoke-RestMethod -Uri "$giteaUrl/api/packages/vstockwell/generic/wraith/$ver/$($file.Name)" -Method PUT -Headers $headers -ContentType "application/octet-stream" -InFile $file.FullName
|
||||||
# Also upload as 'latest' for direct download links
|
|
||||||
Invoke-RestMethod -Uri "$s3/latest/$($file.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $file.FullName
|
Write-Host "Uploading: version.json"
|
||||||
|
Invoke-RestMethod -Uri "$giteaUrl/api/packages/vstockwell/generic/wraith/$ver/version.json" -Method PUT -Headers $headers -ContentType "application/octet-stream" -InFile version.json
|
||||||
}
|
}
|
||||||
|
|
||||||
# Upload MCP bridge binary
|
Write-Host "=== Upload complete ==="
|
||||||
$bridge = "src-tauri\target\release\wraith-mcp-bridge.exe"
|
|
||||||
if (Test-Path $bridge) {
|
|
||||||
Write-Host "Uploading: wraith-mcp-bridge.exe"
|
|
||||||
Invoke-RestMethod -Uri "$s3/$ver/wraith-mcp-bridge.exe" -Method PUT -ContentType "application/octet-stream" -InFile $bridge
|
|
||||||
Invoke-RestMethod -Uri "$s3/latest/wraith-mcp-bridge.exe" -Method PUT -ContentType "application/octet-stream" -InFile $bridge
|
|
||||||
}
|
|
||||||
|
|
||||||
# Upload .nsis.zip for Tauri auto-updater
|
- name: Create Release and attach installers
|
||||||
$zipFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip | Select-Object -First 1
|
|
||||||
if ($zipFile) {
|
|
||||||
Write-Host "Uploading: $($zipFile.Name)"
|
|
||||||
Invoke-RestMethod -Uri "$s3/$ver/$($zipFile.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $zipFile.FullName
|
|
||||||
}
|
|
||||||
|
|
||||||
# Upload version.json metadata
|
|
||||||
$installer = $installers | Select-Object -First 1
|
|
||||||
if ($installer) {
|
|
||||||
$hash = (Get-FileHash $installer.FullName -Algorithm SHA256).Hash.ToLower()
|
|
||||||
@{ version = $ver; filename = $installer.Name; sha256 = $hash; platform = "windows"; architecture = "amd64"; released = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"); signed = $true } | ConvertTo-Json | Out-File version.json -Encoding utf8
|
|
||||||
Invoke-RestMethod -Uri "$s3/$ver/version.json" -Method PUT -ContentType "application/json" -InFile version.json
|
|
||||||
Invoke-RestMethod -Uri "$s3/latest/version.json" -Method PUT -ContentType "application/json" -InFile version.json
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "=== SeaweedFS upload complete ==="
|
|
||||||
|
|
||||||
- name: Generate and upload update.json for Tauri updater
|
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
$ver = ("${{ github.ref_name }}" -replace '^v','')
|
$ver = ("${{ github.ref_name }}" -replace '^v','')
|
||||||
$s3 = "https://files.command.vigilcyber.com/wraith"
|
$giteaUrl = "https://git.command.vigilcyber.com"
|
||||||
|
$headers = @{ Authorization = "token ${{ secrets.GIT_TOKEN }}"; "Content-Type" = "application/json" }
|
||||||
|
$body = @{ tag_name = "v$ver"; name = "Wraith v$ver"; body = "Wraith Desktop v$ver - Tauri v2 / Rust build." } | ConvertTo-Json
|
||||||
|
$release = Invoke-RestMethod -Uri "$giteaUrl/api/v1/repos/vstockwell/wraith/releases" -Method POST -Headers $headers -Body $body
|
||||||
|
$releaseId = $release.id
|
||||||
|
Write-Host "Release v$ver created (id: $releaseId)"
|
||||||
|
|
||||||
$sigFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip.sig | Select-Object -First 1
|
$installers = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
|
||||||
$zipFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip | Select-Object -First 1
|
$uploadHeaders = @{ Authorization = "token ${{ secrets.GIT_TOKEN }}" }
|
||||||
|
foreach ($file in $installers) {
|
||||||
if ($sigFile -and $zipFile) {
|
Write-Host "Attaching $($file.Name) to release..."
|
||||||
$signature = Get-Content $sigFile.FullName -Raw
|
Invoke-RestMethod -Uri "$giteaUrl/api/v1/repos/vstockwell/wraith/releases/$releaseId/assets?name=$($file.Name)" -Method POST -Headers $uploadHeaders -ContentType "application/octet-stream" -InFile $file.FullName
|
||||||
$downloadUrl = "$s3/$ver/$($zipFile.Name)"
|
Write-Host "Attached: $($file.Name)"
|
||||||
|
|
||||||
$updateJson = @{
|
|
||||||
version = "v$ver"
|
|
||||||
notes = "Wraith Desktop v$ver"
|
|
||||||
pub_date = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
|
|
||||||
platforms = @{
|
|
||||||
"windows-x86_64" = @{
|
|
||||||
signature = $signature.Trim()
|
|
||||||
url = $downloadUrl
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} | ConvertTo-Json -Depth 4
|
|
||||||
|
|
||||||
$updateJson | Out-File update.json -Encoding utf8
|
|
||||||
Write-Host "update.json content:"
|
|
||||||
Get-Content update.json
|
|
||||||
|
|
||||||
# Upload to root (Tauri updater endpoint)
|
|
||||||
Invoke-RestMethod -Uri "$s3/update.json" -Method PUT -ContentType "application/json" -InFile update.json
|
|
||||||
# Also versioned copy
|
|
||||||
Invoke-RestMethod -Uri "$s3/$ver/update.json" -Method PUT -ContentType "application/json" -InFile update.json
|
|
||||||
|
|
||||||
Write-Host "=== Update manifest uploaded ==="
|
|
||||||
} else {
|
|
||||||
Write-Host 'WARNING - No .sig file found, update signing may have failed'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,7 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
src-tauri/binaries/
|
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.claude/worktrees/
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
20
package-lock.json
generated
20
package-lock.json
generated
@ -19,9 +19,7 @@
|
|||||||
"@codemirror/theme-one-dark": "^6.0.0",
|
"@codemirror/theme-one-dark": "^6.0.0",
|
||||||
"@codemirror/view": "^6.0.0",
|
"@codemirror/view": "^6.0.0",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-search": "^0.16.0",
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
@ -1518,15 +1516,6 @@
|
|||||||
"url": "https://opencollective.com/tauri"
|
"url": "https://opencollective.com/tauri"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-process": {
|
|
||||||
"version": "2.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
|
||||||
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tauri-apps/plugin-shell": {
|
"node_modules/@tauri-apps/plugin-shell": {
|
||||||
"version": "2.3.5",
|
"version": "2.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
|
||||||
@ -1536,15 +1525,6 @@
|
|||||||
"@tauri-apps/api": "^2.10.1"
|
"@tauri-apps/api": "^2.10.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-updater": {
|
|
||||||
"version": "2.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz",
|
|
||||||
"integrity": "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==",
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2.10.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
|||||||
36
package.json
36
package.json
@ -10,33 +10,31 @@
|
|||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.0.0",
|
"vue": "^3.5.0",
|
||||||
"@codemirror/commands": "^6.0.0",
|
"pinia": "^3.0.0",
|
||||||
"@codemirror/lang-javascript": "^6.0.0",
|
|
||||||
"@codemirror/lang-json": "^6.0.0",
|
|
||||||
"@codemirror/lang-markdown": "^6.0.0",
|
|
||||||
"@codemirror/lang-python": "^6.0.0",
|
|
||||||
"@codemirror/language": "^6.0.0",
|
|
||||||
"@codemirror/state": "^6.0.0",
|
|
||||||
"@codemirror/theme-one-dark": "^6.0.0",
|
|
||||||
"@codemirror/view": "^6.0.0",
|
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-search": "^0.16.0",
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@codemirror/view": "^6.0.0",
|
||||||
"pinia": "^3.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
"vue": "^3.5.0"
|
"@codemirror/commands": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/lang-javascript": "^6.0.0",
|
||||||
|
"@codemirror/lang-json": "^6.0.0",
|
||||||
|
"@codemirror/lang-python": "^6.0.0",
|
||||||
|
"@codemirror/lang-markdown": "^6.0.0",
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/theme-one-dark": "^6.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
|
||||||
"tailwindcss": "^4.0.0",
|
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vue-tsc": "^2.0.0"
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"vue-tsc": "^2.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@ -2991,7 +2991,6 @@ checksum = "47c225751e8fbfaaaac5572a80e25d0a0921e9cf408c55509526161b5609157c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ironrdp-connector",
|
"ironrdp-connector",
|
||||||
"ironrdp-core",
|
"ironrdp-core",
|
||||||
"ironrdp-displaycontrol",
|
|
||||||
"ironrdp-graphics",
|
"ironrdp-graphics",
|
||||||
"ironrdp-input",
|
"ironrdp-input",
|
||||||
"ironrdp-pdu",
|
"ironrdp-pdu",
|
||||||
@ -8914,11 +8913,9 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
|
||||||
"ureq",
|
"ureq",
|
||||||
"uuid",
|
"uuid",
|
||||||
"x509-cert",
|
"x509-cert",
|
||||||
"zeroize",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -12,15 +12,11 @@ crate-type = ["lib", "cdylib", "staticlib"]
|
|||||||
name = "wraith-mcp-bridge"
|
name = "wraith-mcp-bridge"
|
||||||
path = "src/bin/wraith_mcp_bridge.rs"
|
path = "src/bin/wraith_mcp_bridge.rs"
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
devtools = ["tauri/devtools"]
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["devtools"] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
@ -37,8 +33,6 @@ uuid = { version = "1", features = ["v4"] }
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
dashmap = "6"
|
dashmap = "6"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-util = "0.7"
|
|
||||||
zeroize = { version = "1", features = ["derive"] }
|
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
@ -65,7 +59,7 @@ ureq = "3"
|
|||||||
png = "0.17"
|
png = "0.17"
|
||||||
|
|
||||||
# RDP (IronRDP)
|
# RDP (IronRDP)
|
||||||
ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input", "displaycontrol"] }
|
ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input"] }
|
||||||
ironrdp-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] }
|
ironrdp-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] }
|
||||||
ironrdp-tls = { version = "0.2", features = ["rustls"] }
|
ironrdp-tls = { version = "0.2", features = ["rustls"] }
|
||||||
tokio-rustls = "0.26"
|
tokio-rustls = "0.26"
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
{
|
{
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default capabilities for the main Wraith window",
|
"description": "Default capabilities for the main Wraith window",
|
||||||
"windows": ["main", "tool-*", "detached-*", "editor-*", "help-*"],
|
"windows": ["main", "tool-*"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:event:default",
|
"core:event:default",
|
||||||
"core:window:default",
|
"core:window:default",
|
||||||
"core:window:allow-create",
|
"shell:allow-open"
|
||||||
"core:webview:default",
|
|
||||||
"core:webview:allow-create-webview-window",
|
|
||||||
"shell:allow-open",
|
|
||||||
"updater:default"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
{"default":{"identifier":"default","description":"Default capabilities for the main Wraith window","local":true,"windows":["main","tool-*","detached-*","editor-*","help-*"],"permissions":["core:default","core:event:default","core:window:default","core:window:allow-create","core:webview:default","core:webview:allow-create-webview-window","shell:allow-open","updater:default"]}}
|
{"default":{"identifier":"default","description":"Default capabilities for the main Wraith window","local":true,"windows":["main","tool-*"],"permissions":["core:default","core:event:default","core:window:default","shell:allow-open"]}}
|
||||||
@ -38,22 +38,19 @@ struct JsonRpcError {
|
|||||||
message: String,
|
message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_data_dir() -> Result<std::path::PathBuf, String> {
|
fn get_mcp_port() -> Result<u16, String> {
|
||||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
// Check standard locations for the port file
|
||||||
Ok(std::path::PathBuf::from(appdata).join("Wraith"))
|
let port_file = if let Ok(appdata) = std::env::var("APPDATA") {
|
||||||
|
std::path::PathBuf::from(appdata).join("Wraith").join("mcp-port")
|
||||||
} else if let Ok(home) = std::env::var("HOME") {
|
} else if let Ok(home) = std::env::var("HOME") {
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
Ok(std::path::PathBuf::from(home).join("Library").join("Application Support").join("Wraith"))
|
std::path::PathBuf::from(home).join("Library").join("Application Support").join("Wraith").join("mcp-port")
|
||||||
} else {
|
} else {
|
||||||
Ok(std::path::PathBuf::from(home).join(".local").join("share").join("wraith"))
|
std::path::PathBuf::from(home).join(".local").join("share").join("wraith").join("mcp-port")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err("Cannot determine data directory".to_string())
|
return Err("Cannot determine data directory".to_string());
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
fn get_mcp_port() -> Result<u16, String> {
|
|
||||||
let port_file = get_data_dir()?.join("mcp-port");
|
|
||||||
|
|
||||||
let port_str = std::fs::read_to_string(&port_file)
|
let port_str = std::fs::read_to_string(&port_file)
|
||||||
.map_err(|e| format!("Cannot read MCP port file at {}: {} — is Wraith running?", port_file.display(), e))?;
|
.map_err(|e| format!("Cannot read MCP port file at {}: {} — is Wraith running?", port_file.display(), e))?;
|
||||||
@ -62,15 +59,6 @@ fn get_mcp_port() -> Result<u16, String> {
|
|||||||
.map_err(|e| format!("Invalid port in MCP port file: {}", e))
|
.map_err(|e| format!("Invalid port in MCP port file: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_mcp_token() -> Result<String, String> {
|
|
||||||
let token_file = get_data_dir()?.join("mcp-token");
|
|
||||||
|
|
||||||
let token = std::fs::read_to_string(&token_file)
|
|
||||||
.map_err(|e| format!("Cannot read MCP token file at {}: {} — is Wraith running?", token_file.display(), e))?;
|
|
||||||
|
|
||||||
Ok(token.trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_initialize(id: Value) -> JsonRpcResponse {
|
fn handle_initialize(id: Value) -> JsonRpcResponse {
|
||||||
JsonRpcResponse {
|
JsonRpcResponse {
|
||||||
jsonrpc: "2.0".to_string(),
|
jsonrpc: "2.0".to_string(),
|
||||||
@ -95,19 +83,6 @@ fn handle_tools_list(id: Value) -> JsonRpcResponse {
|
|||||||
id,
|
id,
|
||||||
result: Some(serde_json::json!({
|
result: Some(serde_json::json!({
|
||||||
"tools": [
|
"tools": [
|
||||||
{
|
|
||||||
"name": "terminal_type",
|
|
||||||
"description": "Type text into a terminal session (like a human typing). Optionally presses Enter after. Use this to send messages or commands without output capture.",
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"session_id": { "type": "string", "description": "The session ID to type into" },
|
|
||||||
"text": { "type": "string", "description": "The text to type" },
|
|
||||||
"press_enter": { "type": "boolean", "description": "Whether to press Enter after typing (default: true)" }
|
|
||||||
},
|
|
||||||
"required": ["session_id", "text"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "terminal_read",
|
"name": "terminal_read",
|
||||||
"description": "Read recent terminal output from an active SSH or PTY session (ANSI codes stripped)",
|
"description": "Read recent terminal output from an active SSH or PTY session (ANSI codes stripped)",
|
||||||
@ -236,72 +211,6 @@ fn handle_tools_list(id: Value) -> JsonRpcResponse {
|
|||||||
"description": "Generate a cryptographically secure random password",
|
"description": "Generate a cryptographically secure random password",
|
||||||
"inputSchema": { "type": "object", "properties": { "length": { "type": "number" }, "uppercase": { "type": "boolean" }, "lowercase": { "type": "boolean" }, "digits": { "type": "boolean" }, "symbols": { "type": "boolean" } } }
|
"inputSchema": { "type": "object", "properties": { "length": { "type": "number" }, "uppercase": { "type": "boolean" }, "lowercase": { "type": "boolean" }, "digits": { "type": "boolean" }, "symbols": { "type": "boolean" } } }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "docker_ps",
|
|
||||||
"description": "List all Docker containers with status, image, and ports",
|
|
||||||
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" } }, "required": ["session_id"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "docker_action",
|
|
||||||
"description": "Perform a Docker action: start, stop, restart, remove, logs, builder-prune, system-prune",
|
|
||||||
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "action": { "type": "string", "description": "start|stop|restart|remove|logs|builder-prune|system-prune" }, "target": { "type": "string", "description": "Container name (not needed for prune actions)" } }, "required": ["session_id", "action", "target"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "docker_exec",
|
|
||||||
"description": "Execute a command inside a running Docker container",
|
|
||||||
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "container": { "type": "string" }, "command": { "type": "string" } }, "required": ["session_id", "container", "command"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "service_status",
|
|
||||||
"description": "Check systemd service status on a remote host",
|
|
||||||
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "target": { "type": "string", "description": "Service name" } }, "required": ["session_id", "target"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "process_list",
|
|
||||||
"description": "List processes on a remote host (top CPU by default, or filter by name)",
|
|
||||||
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "target": { "type": "string", "description": "Process name filter (empty for top 30 by CPU)" } }, "required": ["session_id", "target"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "git_status",
|
|
||||||
"description": "Get git status of a remote repository",
|
|
||||||
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "path": { "type": "string", "description": "Path to the git repo on the remote host" } }, "required": ["session_id", "path"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "git_pull",
|
|
||||||
"description": "Pull latest changes on a remote repository",
|
|
||||||
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "path": { "type": "string" } }, "required": ["session_id", "path"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "git_log",
|
|
||||||
"description": "Show recent commits on a remote repository",
|
|
||||||
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "path": { "type": "string" } }, "required": ["session_id", "path"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rdp_click",
|
|
||||||
"description": "Click at a position in an RDP session (use terminal_screenshot first to see coordinates)",
|
|
||||||
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "x": { "type": "number" }, "y": { "type": "number" }, "button": { "type": "string", "description": "left (default), right, or middle" } }, "required": ["session_id", "x", "y"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rdp_type",
|
|
||||||
"description": "Type text into an RDP session via clipboard paste",
|
|
||||||
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "text": { "type": "string" } }, "required": ["session_id", "text"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rdp_clipboard",
|
|
||||||
"description": "Set the clipboard content on a remote RDP session",
|
|
||||||
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "text": { "type": "string" } }, "required": ["session_id", "text"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ssh_connect",
|
|
||||||
"description": "Open a new SSH connection through Wraith. Returns the session ID for use with other tools.",
|
|
||||||
"inputSchema": { "type": "object", "properties": {
|
|
||||||
"hostname": { "type": "string" },
|
|
||||||
"port": { "type": "number", "description": "Default: 22" },
|
|
||||||
"username": { "type": "string" },
|
|
||||||
"password": { "type": "string", "description": "Password (for password auth)" },
|
|
||||||
"private_key_path": { "type": "string", "description": "Path to SSH private key file on the local machine" }
|
|
||||||
}, "required": ["hostname", "username"] }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "list_sessions",
|
"name": "list_sessions",
|
||||||
"description": "List all active Wraith sessions (SSH, RDP, PTY) with connection details",
|
"description": "List all active Wraith sessions (SSH, RDP, PTY) with connection details",
|
||||||
@ -316,13 +225,12 @@ fn handle_tools_list(id: Value) -> JsonRpcResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn call_wraith(port: u16, token: &str, endpoint: &str, body: Value) -> Result<Value, String> {
|
fn call_wraith(port: u16, endpoint: &str, body: Value) -> Result<Value, String> {
|
||||||
let url = format!("http://127.0.0.1:{}{}", port, endpoint);
|
let url = format!("http://127.0.0.1:{}{}", port, endpoint);
|
||||||
let body_str = serde_json::to_string(&body).unwrap_or_default();
|
let body_str = serde_json::to_string(&body).unwrap_or_default();
|
||||||
|
|
||||||
let mut resp = ureq::post(url)
|
let mut resp = ureq::post(url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", &format!("Bearer {}", token))
|
|
||||||
.send(body_str.as_bytes())
|
.send(body_str.as_bytes())
|
||||||
.map_err(|e| format!("HTTP request to Wraith failed: {}", e))?;
|
.map_err(|e| format!("HTTP request to Wraith failed: {}", e))?;
|
||||||
|
|
||||||
@ -340,40 +248,27 @@ fn call_wraith(port: u16, token: &str, endpoint: &str, body: Value) -> Result<Va
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_tool_call(id: Value, port: u16, token: &str, tool_name: &str, args: &Value) -> JsonRpcResponse {
|
fn handle_tool_call(id: Value, port: u16, tool_name: &str, args: &Value) -> JsonRpcResponse {
|
||||||
let result = match tool_name {
|
let result = match tool_name {
|
||||||
"list_sessions" => call_wraith(port, token, "/mcp/sessions", serde_json::json!({})),
|
"list_sessions" => call_wraith(port, "/mcp/sessions", serde_json::json!({})),
|
||||||
"terminal_type" => call_wraith(port, token, "/mcp/terminal/type", args.clone()),
|
"terminal_read" => call_wraith(port, "/mcp/terminal/read", args.clone()),
|
||||||
"terminal_read" => call_wraith(port, token, "/mcp/terminal/read", args.clone()),
|
"terminal_execute" => call_wraith(port, "/mcp/terminal/execute", args.clone()),
|
||||||
"terminal_execute" => call_wraith(port, token, "/mcp/terminal/execute", args.clone()),
|
"sftp_list" => call_wraith(port, "/mcp/sftp/list", args.clone()),
|
||||||
"sftp_list" => call_wraith(port, token, "/mcp/sftp/list", args.clone()),
|
"sftp_read" => call_wraith(port, "/mcp/sftp/read", args.clone()),
|
||||||
"sftp_read" => call_wraith(port, token, "/mcp/sftp/read", args.clone()),
|
"sftp_write" => call_wraith(port, "/mcp/sftp/write", args.clone()),
|
||||||
"sftp_write" => call_wraith(port, token, "/mcp/sftp/write", args.clone()),
|
"network_scan" => call_wraith(port, "/mcp/tool/scan-network", args.clone()),
|
||||||
"network_scan" => call_wraith(port, token, "/mcp/tool/scan-network", args.clone()),
|
"port_scan" => call_wraith(port, "/mcp/tool/scan-ports", args.clone()),
|
||||||
"port_scan" => call_wraith(port, token, "/mcp/tool/scan-ports", args.clone()),
|
"ping" => call_wraith(port, "/mcp/tool/ping", args.clone()),
|
||||||
"ping" => call_wraith(port, token, "/mcp/tool/ping", args.clone()),
|
"traceroute" => call_wraith(port, "/mcp/tool/traceroute", args.clone()),
|
||||||
"traceroute" => call_wraith(port, token, "/mcp/tool/traceroute", args.clone()),
|
"dns_lookup" => call_wraith(port, "/mcp/tool/dns", args.clone()),
|
||||||
"dns_lookup" => call_wraith(port, token, "/mcp/tool/dns", args.clone()),
|
"whois" => call_wraith(port, "/mcp/tool/whois", args.clone()),
|
||||||
"whois" => call_wraith(port, token, "/mcp/tool/whois", args.clone()),
|
"wake_on_lan" => call_wraith(port, "/mcp/tool/wol", args.clone()),
|
||||||
"wake_on_lan" => call_wraith(port, token, "/mcp/tool/wol", args.clone()),
|
"bandwidth_test" => call_wraith(port, "/mcp/tool/bandwidth", args.clone()),
|
||||||
"bandwidth_test" => call_wraith(port, token, "/mcp/tool/bandwidth", args.clone()),
|
"subnet_calc" => call_wraith(port, "/mcp/tool/subnet", args.clone()),
|
||||||
"subnet_calc" => call_wraith(port, token, "/mcp/tool/subnet", args.clone()),
|
"generate_ssh_key" => call_wraith(port, "/mcp/tool/keygen", args.clone()),
|
||||||
"generate_ssh_key" => call_wraith(port, token, "/mcp/tool/keygen", args.clone()),
|
"generate_password" => call_wraith(port, "/mcp/tool/passgen", args.clone()),
|
||||||
"generate_password" => call_wraith(port, token, "/mcp/tool/passgen", args.clone()),
|
|
||||||
"docker_ps" => call_wraith(port, token, "/mcp/docker/ps", args.clone()),
|
|
||||||
"docker_action" => call_wraith(port, token, "/mcp/docker/action", args.clone()),
|
|
||||||
"docker_exec" => call_wraith(port, token, "/mcp/docker/exec", args.clone()),
|
|
||||||
"service_status" => call_wraith(port, token, "/mcp/service/status", args.clone()),
|
|
||||||
"process_list" => call_wraith(port, token, "/mcp/process/list", args.clone()),
|
|
||||||
"git_status" => call_wraith(port, token, "/mcp/git/status", args.clone()),
|
|
||||||
"git_pull" => call_wraith(port, token, "/mcp/git/pull", args.clone()),
|
|
||||||
"git_log" => call_wraith(port, token, "/mcp/git/log", args.clone()),
|
|
||||||
"rdp_click" => call_wraith(port, token, "/mcp/rdp/click", args.clone()),
|
|
||||||
"rdp_type" => call_wraith(port, token, "/mcp/rdp/type", args.clone()),
|
|
||||||
"rdp_clipboard" => call_wraith(port, token, "/mcp/rdp/clipboard", args.clone()),
|
|
||||||
"ssh_connect" => call_wraith(port, token, "/mcp/ssh/connect", args.clone()),
|
|
||||||
"terminal_screenshot" => {
|
"terminal_screenshot" => {
|
||||||
let result = call_wraith(port, token, "/mcp/screenshot", args.clone());
|
let result = call_wraith(port, "/mcp/screenshot", args.clone());
|
||||||
// Screenshot returns base64 PNG — wrap as image content for multimodal AI
|
// Screenshot returns base64 PNG — wrap as image content for multimodal AI
|
||||||
return match result {
|
return match result {
|
||||||
Ok(b64) => JsonRpcResponse {
|
Ok(b64) => JsonRpcResponse {
|
||||||
@ -433,14 +328,6 @@ fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let token = match get_mcp_token() {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("wraith-mcp-bridge: {}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let stdin = io::stdin();
|
let stdin = io::stdin();
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
|
|
||||||
@ -479,7 +366,7 @@ fn main() {
|
|||||||
let args = request.params.get("arguments")
|
let args = request.params.get("arguments")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or(Value::Object(serde_json::Map::new()));
|
.unwrap_or(Value::Object(serde_json::Map::new()));
|
||||||
handle_tool_call(request.id, port, &token, tool_name, &args)
|
handle_tool_call(request.id, port, tool_name, &args)
|
||||||
}
|
}
|
||||||
"notifications/initialized" | "notifications/cancelled" => {
|
"notifications/initialized" | "notifications/cancelled" => {
|
||||||
// Notifications don't get responses
|
// Notifications don't get responses
|
||||||
|
|||||||
@ -92,19 +92,3 @@ pub fn search_connections(
|
|||||||
) -> Result<Vec<ConnectionRecord>, String> {
|
) -> Result<Vec<ConnectionRecord>, String> {
|
||||||
state.connections.search(&query)
|
state.connections.search(&query)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn reorder_connections(
|
|
||||||
ids: Vec<i64>,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.connections.reorder_connections(&ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn reorder_groups(
|
|
||||||
ids: Vec<i64>,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.connections.reorder_groups(&ids)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,16 +3,34 @@ use tauri::State;
|
|||||||
use crate::credentials::Credential;
|
use crate::credentials::Credential;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Guard helper: lock the credentials mutex and return a ref to the inner
|
||||||
|
/// `CredentialService`, or a "Vault is locked" error if the vault has not
|
||||||
|
/// been unlocked for this session.
|
||||||
|
///
|
||||||
|
/// This is a macro rather than a function because returning a `MutexGuard`
|
||||||
|
/// from a helper function would require lifetime annotations that complicate
|
||||||
|
/// the tauri command signatures unnecessarily.
|
||||||
|
macro_rules! require_unlocked {
|
||||||
|
($state:expr) => {{
|
||||||
|
let guard = $state
|
||||||
|
.credentials
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Credentials mutex was poisoned".to_string())?;
|
||||||
|
if guard.is_none() {
|
||||||
|
return Err("Vault is locked — call unlock before accessing credentials".into());
|
||||||
|
}
|
||||||
|
// SAFETY: we just checked `is_none` above, so `unwrap` cannot panic.
|
||||||
|
guard
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
/// Return all credentials ordered by name.
|
/// Return all credentials ordered by name.
|
||||||
///
|
///
|
||||||
/// Secret values (passwords, private keys) are never included — only metadata.
|
/// Secret values (passwords, private keys) are never included — only metadata.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_credentials(state: State<'_, AppState>) -> Result<Vec<Credential>, String> {
|
pub fn list_credentials(state: State<'_, AppState>) -> Result<Vec<Credential>, String> {
|
||||||
let guard = state.credentials.lock().await;
|
let guard = require_unlocked!(state);
|
||||||
let svc = guard
|
guard.as_ref().unwrap().list()
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
|
|
||||||
svc.list()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store a new username/password credential.
|
/// Store a new username/password credential.
|
||||||
@ -21,18 +39,18 @@ pub async fn list_credentials(state: State<'_, AppState>) -> Result<Vec<Credenti
|
|||||||
/// Returns the created credential record (without the plaintext password).
|
/// Returns the created credential record (without the plaintext password).
|
||||||
/// `domain` is `None` for non-domain credentials; `Some("")` is treated as NULL.
|
/// `domain` is `None` for non-domain credentials; `Some("")` is treated as NULL.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn create_password(
|
pub fn create_password(
|
||||||
name: String,
|
name: String,
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
domain: Option<String>,
|
domain: Option<String>,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Credential, String> {
|
) -> Result<Credential, String> {
|
||||||
let guard = state.credentials.lock().await;
|
let guard = require_unlocked!(state);
|
||||||
let svc = guard
|
guard
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
|
.unwrap()
|
||||||
svc.create_password(name, username, password, domain)
|
.create_password(name, username, password, domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store a new SSH private key credential.
|
/// Store a new SSH private key credential.
|
||||||
@ -41,18 +59,18 @@ pub async fn create_password(
|
|||||||
/// Pass `None` for `passphrase` when the key has no passphrase.
|
/// Pass `None` for `passphrase` when the key has no passphrase.
|
||||||
/// Returns the created credential record without any secret material.
|
/// Returns the created credential record without any secret material.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn create_ssh_key(
|
pub fn create_ssh_key(
|
||||||
name: String,
|
name: String,
|
||||||
username: String,
|
username: String,
|
||||||
private_key_pem: String,
|
private_key_pem: String,
|
||||||
passphrase: Option<String>,
|
passphrase: Option<String>,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Credential, String> {
|
) -> Result<Credential, String> {
|
||||||
let guard = state.credentials.lock().await;
|
let guard = require_unlocked!(state);
|
||||||
let svc = guard
|
guard
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
|
.unwrap()
|
||||||
svc.create_ssh_key(name, username, private_key_pem, passphrase)
|
.create_ssh_key(name, username, private_key_pem, passphrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a credential by id.
|
/// Delete a credential by id.
|
||||||
@ -60,30 +78,21 @@ pub async fn create_ssh_key(
|
|||||||
/// For SSH key credentials, the associated `ssh_keys` row is also deleted.
|
/// For SSH key credentials, the associated `ssh_keys` row is also deleted.
|
||||||
/// Returns `Err` if the vault is locked or the id does not exist.
|
/// Returns `Err` if the vault is locked or the id does not exist.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_credential(id: i64, state: State<'_, AppState>) -> Result<(), String> {
|
pub fn delete_credential(id: i64, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
let guard = state.credentials.lock().await;
|
let guard = require_unlocked!(state);
|
||||||
let svc = guard
|
guard.as_ref().unwrap().delete(id)
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
|
|
||||||
svc.delete(id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt and return the password for a credential.
|
/// Decrypt and return the password for a credential.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn decrypt_password(credential_id: i64, state: State<'_, AppState>) -> Result<String, String> {
|
pub fn decrypt_password(credential_id: i64, state: State<'_, AppState>) -> Result<String, String> {
|
||||||
let guard = state.credentials.lock().await;
|
let guard = require_unlocked!(state);
|
||||||
let svc = guard
|
guard.as_ref().unwrap().decrypt_password(credential_id)
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
|
|
||||||
svc.decrypt_password(credential_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt and return the SSH private key and passphrase.
|
/// Decrypt and return the SSH private key and passphrase.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn decrypt_ssh_key(ssh_key_id: i64, state: State<'_, AppState>) -> Result<(String, String), String> {
|
pub fn decrypt_ssh_key(ssh_key_id: i64, state: State<'_, AppState>) -> Result<(String, String), String> {
|
||||||
let guard = state.credentials.lock().await;
|
let guard = require_unlocked!(state);
|
||||||
let svc = guard
|
guard.as_ref().unwrap().decrypt_ssh_key(ssh_key_id)
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| "Vault is locked — call unlock before accessing credentials".to_string())?;
|
|
||||||
svc.decrypt_ssh_key(ssh_key_id)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
//! Tauri commands for Docker management via SSH exec channels.
|
|
||||||
|
|
||||||
use tauri::State;
|
|
||||||
use serde::Serialize;
|
|
||||||
use crate::AppState;
|
|
||||||
use crate::ssh::exec::exec_on_session;
|
|
||||||
use crate::utils::shell_escape;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DockerContainer {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub image: String,
|
|
||||||
pub status: String,
|
|
||||||
pub ports: String,
|
|
||||||
pub created: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DockerImage {
|
|
||||||
pub id: String,
|
|
||||||
pub repository: String,
|
|
||||||
pub tag: String,
|
|
||||||
pub size: String,
|
|
||||||
pub created: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DockerVolume {
|
|
||||||
pub name: String,
|
|
||||||
pub driver: String,
|
|
||||||
pub mountpoint: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn docker_list_containers(session_id: String, all: Option<bool>, state: State<'_, AppState>) -> Result<Vec<DockerContainer>, String> {
|
|
||||||
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
|
|
||||||
let flag = if all.unwrap_or(true) { "-a" } else { "" };
|
|
||||||
let output = exec_on_session(&session.handle, &format!("docker ps {} --format '{{{{.ID}}}}|{{{{.Names}}}}|{{{{.Image}}}}|{{{{.Status}}}}|{{{{.Ports}}}}|{{{{.CreatedAt}}}}' 2>&1", flag)).await?;
|
|
||||||
Ok(output.lines().filter(|l| !l.is_empty() && !l.starts_with("CONTAINER")).map(|line| {
|
|
||||||
let p: Vec<&str> = line.splitn(6, '|').collect();
|
|
||||||
DockerContainer {
|
|
||||||
id: p.first().unwrap_or(&"").to_string(),
|
|
||||||
name: p.get(1).unwrap_or(&"").to_string(),
|
|
||||||
image: p.get(2).unwrap_or(&"").to_string(),
|
|
||||||
status: p.get(3).unwrap_or(&"").to_string(),
|
|
||||||
ports: p.get(4).unwrap_or(&"").to_string(),
|
|
||||||
created: p.get(5).unwrap_or(&"").to_string(),
|
|
||||||
}
|
|
||||||
}).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn docker_list_images(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerImage>, String> {
|
|
||||||
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
|
|
||||||
let output = exec_on_session(&session.handle, "docker images --format '{{.ID}}|{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}' 2>&1").await?;
|
|
||||||
Ok(output.lines().filter(|l| !l.is_empty()).map(|line| {
|
|
||||||
let p: Vec<&str> = line.splitn(5, '|').collect();
|
|
||||||
DockerImage {
|
|
||||||
id: p.first().unwrap_or(&"").to_string(),
|
|
||||||
repository: p.get(1).unwrap_or(&"").to_string(),
|
|
||||||
tag: p.get(2).unwrap_or(&"").to_string(),
|
|
||||||
size: p.get(3).unwrap_or(&"").to_string(),
|
|
||||||
created: p.get(4).unwrap_or(&"").to_string(),
|
|
||||||
}
|
|
||||||
}).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn docker_list_volumes(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerVolume>, String> {
|
|
||||||
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
|
|
||||||
let output = exec_on_session(&session.handle, "docker volume ls --format '{{.Name}}|{{.Driver}}|{{.Mountpoint}}' 2>&1").await?;
|
|
||||||
Ok(output.lines().filter(|l| !l.is_empty()).map(|line| {
|
|
||||||
let p: Vec<&str> = line.splitn(3, '|').collect();
|
|
||||||
DockerVolume {
|
|
||||||
name: p.first().unwrap_or(&"").to_string(),
|
|
||||||
driver: p.get(1).unwrap_or(&"").to_string(),
|
|
||||||
mountpoint: p.get(2).unwrap_or(&"").to_string(),
|
|
||||||
}
|
|
||||||
}).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn docker_action(session_id: String, action: String, target: String, state: State<'_, AppState>) -> Result<String, String> {
|
|
||||||
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
|
|
||||||
let t = shell_escape(&target);
|
|
||||||
let cmd = match action.as_str() {
|
|
||||||
"start" => format!("docker start {} 2>&1", t),
|
|
||||||
"stop" => format!("docker stop {} 2>&1", t),
|
|
||||||
"restart" => format!("docker restart {} 2>&1", t),
|
|
||||||
"remove" => format!("docker rm -f {} 2>&1", t),
|
|
||||||
"logs" => format!("docker logs --tail 100 {} 2>&1", t),
|
|
||||||
"remove-image" => format!("docker rmi {} 2>&1", t),
|
|
||||||
"remove-volume" => format!("docker volume rm {} 2>&1", t),
|
|
||||||
"builder-prune" => "docker builder prune -f 2>&1".to_string(),
|
|
||||||
"system-prune" => "docker system prune -f 2>&1".to_string(),
|
|
||||||
"system-prune-all" => "docker system prune -a -f 2>&1".to_string(),
|
|
||||||
_ => return Err(format!("Unknown docker action: {}", action)),
|
|
||||||
};
|
|
||||||
exec_on_session(&session.handle, &cmd).await
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ pub async fn mcp_terminal_execute(
|
|||||||
let before = buf.total_written();
|
let before = buf.total_written();
|
||||||
|
|
||||||
// Send command + marker echo
|
// Send command + marker echo
|
||||||
let full_cmd = format!("{}\recho {}\r", command, marker);
|
let full_cmd = format!("{}\necho {}\n", command, marker);
|
||||||
state.ssh.write(&session_id, full_cmd.as_bytes()).await?;
|
state.ssh.write(&session_id, full_cmd.as_bytes()).await?;
|
||||||
|
|
||||||
// Poll scrollback until marker appears or timeout
|
// Poll scrollback until marker appears or timeout
|
||||||
@ -116,19 +116,10 @@ pub async fn mcp_terminal_execute(
|
|||||||
return Ok(clean.trim().to_string());
|
return Ok(clean.trim().to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yield the executor before sleeping so other tasks aren't starved,
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
// then wait 200 ms — much cheaper than the original 50 ms busy-poll.
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the path where the MCP bridge binary is installed.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn mcp_bridge_path() -> String {
|
|
||||||
crate::mcp::bridge_manager::bridge_path().to_string_lossy().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the active session context — last 20 lines of scrollback for a session.
|
/// Get the active session context — last 20 lines of scrollback for a session.
|
||||||
/// Called by the frontend when the user switches tabs, emitted to the copilot.
|
/// Called by the frontend when the user switches tabs, emitted to the copilot.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@ -10,8 +10,4 @@ pub mod pty_commands;
|
|||||||
pub mod mcp_commands;
|
pub mod mcp_commands;
|
||||||
pub mod scanner_commands;
|
pub mod scanner_commands;
|
||||||
pub mod tools_commands;
|
pub mod tools_commands;
|
||||||
pub mod updater;
|
|
||||||
pub mod tools_commands_r2;
|
pub mod tools_commands_r2;
|
||||||
pub mod workspace_commands;
|
|
||||||
pub mod docker_commands;
|
|
||||||
pub mod window_commands;
|
|
||||||
|
|||||||
@ -3,53 +3,35 @@
|
|||||||
//! Mirrors the pattern used by `ssh_commands.rs` — thin command wrappers that
|
//! Mirrors the pattern used by `ssh_commands.rs` — thin command wrappers that
|
||||||
//! delegate to the `RdpService` via `State<AppState>`.
|
//! delegate to the `RdpService` via `State<AppState>`.
|
||||||
|
|
||||||
use tauri::{AppHandle, State};
|
use tauri::State;
|
||||||
use tauri::ipc::Response;
|
|
||||||
|
|
||||||
use crate::rdp::{RdpConfig, RdpSessionInfo};
|
use crate::rdp::{RdpConfig, RdpSessionInfo};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
/// Connect to an RDP server.
|
/// Connect to an RDP server.
|
||||||
|
///
|
||||||
|
/// Performs the full connection handshake (TCP -> TLS -> CredSSP -> RDP) and
|
||||||
|
/// starts streaming frame updates in the background.
|
||||||
|
///
|
||||||
|
/// Returns the session UUID.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn connect_rdp(
|
pub fn connect_rdp(
|
||||||
config: RdpConfig,
|
config: RdpConfig,
|
||||||
app_handle: AppHandle,
|
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
state.rdp.connect(config, app_handle)
|
state.rdp.connect(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the dirty region since last call as raw RGBA bytes via binary IPC.
|
/// Get the current frame buffer as a base64-encoded RGBA string.
|
||||||
///
|
///
|
||||||
/// Binary format: 8-byte header + pixel data
|
/// The frontend decodes this and draws it onto a `<canvas>` element.
|
||||||
/// Header: [x: u16, y: u16, width: u16, height: u16] (little-endian)
|
/// Pixel format: RGBA, 4 bytes per pixel, row-major, top-left origin.
|
||||||
/// If header is all zeros, the payload is a full frame (width*height*4 bytes).
|
|
||||||
/// If header is non-zero, payload contains only the dirty rectangle pixels.
|
|
||||||
/// Returns empty payload if nothing changed.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn rdp_get_frame(
|
pub async fn rdp_get_frame(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Response, String> {
|
) -> Result<String, String> {
|
||||||
let (region, pixels) = state.rdp.get_frame(&session_id)?;
|
state.rdp.get_frame(&session_id).await
|
||||||
if pixels.is_empty() {
|
|
||||||
return Ok(Response::new(Vec::new()));
|
|
||||||
}
|
|
||||||
// Prepend 8-byte dirty rect header
|
|
||||||
let mut out = Vec::with_capacity(8 + pixels.len());
|
|
||||||
match region {
|
|
||||||
Some(rect) => {
|
|
||||||
out.extend_from_slice(&rect.x.to_le_bytes());
|
|
||||||
out.extend_from_slice(&rect.y.to_le_bytes());
|
|
||||||
out.extend_from_slice(&rect.width.to_le_bytes());
|
|
||||||
out.extend_from_slice(&rect.height.to_le_bytes());
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
out.extend_from_slice(&[0u8; 8]); // full frame marker
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.extend_from_slice(&pixels);
|
|
||||||
Ok(Response::new(out))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a mouse event to an RDP session.
|
/// Send a mouse event to an RDP session.
|
||||||
@ -64,7 +46,7 @@ pub fn rdp_get_frame(
|
|||||||
/// - 0x0100 = negative wheel direction
|
/// - 0x0100 = negative wheel direction
|
||||||
/// - 0x0400 = horizontal wheel
|
/// - 0x0400 = horizontal wheel
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn rdp_send_mouse(
|
pub async fn rdp_send_mouse(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
x: u16,
|
x: u16,
|
||||||
y: u16,
|
y: u16,
|
||||||
@ -82,7 +64,7 @@ pub fn rdp_send_mouse(
|
|||||||
///
|
///
|
||||||
/// `pressed` is `true` for key-down, `false` for key-up.
|
/// `pressed` is `true` for key-down, `false` for key-up.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn rdp_send_key(
|
pub async fn rdp_send_key(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
scancode: u16,
|
scancode: u16,
|
||||||
pressed: bool,
|
pressed: bool,
|
||||||
@ -93,7 +75,7 @@ pub fn rdp_send_key(
|
|||||||
|
|
||||||
/// Send clipboard text to an RDP session by simulating keystrokes.
|
/// Send clipboard text to an RDP session by simulating keystrokes.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn rdp_send_clipboard(
|
pub async fn rdp_send_clipboard(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
text: String,
|
text: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@ -101,34 +83,11 @@ pub fn rdp_send_clipboard(
|
|||||||
state.rdp.send_clipboard(&session_id, &text)
|
state.rdp.send_clipboard(&session_id, &text)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force the next get_frame to return a full frame regardless of dirty state.
|
|
||||||
/// Used when switching tabs or after resize to ensure the canvas is fully repainted.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn rdp_force_refresh(
|
|
||||||
session_id: String,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.rdp.force_refresh(&session_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resize the RDP session's desktop resolution.
|
|
||||||
/// Sends a Display Control Virtual Channel request to the server.
|
|
||||||
/// The server will re-render at the new resolution and send updated frames.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn rdp_resize(
|
|
||||||
session_id: String,
|
|
||||||
width: u16,
|
|
||||||
height: u16,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state.rdp.resize(&session_id, width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disconnect an RDP session.
|
/// Disconnect an RDP session.
|
||||||
///
|
///
|
||||||
/// Sends a graceful shutdown to the RDP server and removes the session.
|
/// Sends a graceful shutdown to the RDP server and removes the session.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn disconnect_rdp(
|
pub async fn disconnect_rdp(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@ -137,7 +96,7 @@ pub fn disconnect_rdp(
|
|||||||
|
|
||||||
/// List all active RDP sessions (metadata only).
|
/// List all active RDP sessions (metadata only).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_rdp_sessions(
|
pub async fn list_rdp_sessions(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Vec<RdpSessionInfo>, String> {
|
) -> Result<Vec<RdpSessionInfo>, String> {
|
||||||
Ok(state.rdp.list_sessions())
|
Ok(state.rdp.list_sessions())
|
||||||
|
|||||||
@ -4,8 +4,6 @@ use tauri::State;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::ssh::exec::exec_on_session;
|
|
||||||
use crate::utils::shell_escape;
|
|
||||||
|
|
||||||
// ── Ping ─────────────────────────────────────────────────────────────────────
|
// ── Ping ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -27,7 +25,7 @@ pub async fn tool_ping(
|
|||||||
let session = state.ssh.get_session(&session_id)
|
let session = state.ssh.get_session(&session_id)
|
||||||
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
||||||
let n = count.unwrap_or(4);
|
let n = count.unwrap_or(4);
|
||||||
let cmd = format!("ping -c {} {} 2>&1", n, shell_escape(&target));
|
let cmd = format!("ping -c {} {} 2>&1", n, target);
|
||||||
let output = exec_on_session(&session.handle, &cmd).await?;
|
let output = exec_on_session(&session.handle, &cmd).await?;
|
||||||
Ok(PingResult { target, output })
|
Ok(PingResult { target, output })
|
||||||
}
|
}
|
||||||
@ -41,8 +39,7 @@ pub async fn tool_traceroute(
|
|||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let session = state.ssh.get_session(&session_id)
|
let session = state.ssh.get_session(&session_id)
|
||||||
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
||||||
let t = shell_escape(&target);
|
let cmd = format!("traceroute {} 2>&1 || tracert {} 2>&1", target, target);
|
||||||
let cmd = format!("traceroute {} 2>&1 || tracert {} 2>&1", t, t);
|
|
||||||
exec_on_session(&session.handle, &cmd).await
|
exec_on_session(&session.handle, &cmd).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,16 +65,14 @@ pub async fn tool_wake_on_lan(
|
|||||||
let cmd = format!(
|
let cmd = format!(
|
||||||
r#"python3 -c "
|
r#"python3 -c "
|
||||||
import socket, struct
|
import socket, struct
|
||||||
mac = bytes.fromhex({mac_clean_escaped})
|
mac = bytes.fromhex('{mac_clean}')
|
||||||
pkt = b'\xff'*6 + mac*16
|
pkt = b'\xff'*6 + mac*16
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
s.sendto(pkt, ('255.255.255.255', 9))
|
s.sendto(pkt, ('255.255.255.255', 9))
|
||||||
s.close()
|
s.close()
|
||||||
print('WoL packet sent to {mac_display_escaped}')
|
print('WoL packet sent to {mac_address}')
|
||||||
" 2>&1 || echo "python3 not available — install python3 on remote host for WoL""#,
|
" 2>&1 || echo "python3 not available — install python3 on remote host for WoL""#
|
||||||
mac_clean_escaped = shell_escape(&mac_clean),
|
|
||||||
mac_display_escaped = shell_escape(&mac_address),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
exec_on_session(&session.handle, &cmd).await
|
exec_on_session(&session.handle, &cmd).await
|
||||||
@ -186,3 +181,32 @@ pub fn tool_generate_password_inner(
|
|||||||
Ok(password)
|
Ok(password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn exec_on_session(
|
||||||
|
handle: &std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>,
|
||||||
|
cmd: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut channel = {
|
||||||
|
let h = handle.lock().await;
|
||||||
|
h.channel_open_session().await.map_err(|e| format!("Exec channel failed: {}", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?;
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
loop {
|
||||||
|
match channel.wait().await {
|
||||||
|
Some(russh::ChannelMsg::Data { ref data }) => {
|
||||||
|
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
|
||||||
|
output.push_str(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
|
||||||
|
Some(russh::ChannelMsg::ExitStatus { .. }) => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|||||||
@ -4,8 +4,6 @@ use tauri::State;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::ssh::exec::exec_on_session;
|
|
||||||
use crate::utils::shell_escape;
|
|
||||||
|
|
||||||
// ── DNS Lookup ───────────────────────────────────────────────────────────────
|
// ── DNS Lookup ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -18,11 +16,10 @@ pub async fn tool_dns_lookup(
|
|||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let session = state.ssh.get_session(&session_id)
|
let session = state.ssh.get_session(&session_id)
|
||||||
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
||||||
let d = shell_escape(&domain);
|
let rtype = record_type.unwrap_or_else(|| "A".to_string());
|
||||||
let rt = shell_escape(&record_type.unwrap_or_else(|| "A".to_string()));
|
|
||||||
let cmd = format!(
|
let cmd = format!(
|
||||||
r#"dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null"#,
|
r#"dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null"#,
|
||||||
d, rt, rt, d, rt, d
|
domain, rtype, rtype, domain, rtype, domain
|
||||||
);
|
);
|
||||||
exec_on_session(&session.handle, &cmd).await
|
exec_on_session(&session.handle, &cmd).await
|
||||||
}
|
}
|
||||||
@ -37,7 +34,7 @@ pub async fn tool_whois(
|
|||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let session = state.ssh.get_session(&session_id)
|
let session = state.ssh.get_session(&session_id)
|
||||||
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
||||||
let cmd = format!("whois {} 2>&1 | head -80", shell_escape(&target));
|
let cmd = format!("whois {} 2>&1 | head -80", target);
|
||||||
exec_on_session(&session.handle, &cmd).await
|
exec_on_session(&session.handle, &cmd).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,10 +50,9 @@ pub async fn tool_bandwidth_iperf(
|
|||||||
let session = state.ssh.get_session(&session_id)
|
let session = state.ssh.get_session(&session_id)
|
||||||
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
.ok_or_else(|| format!("SSH session {} not found", session_id))?;
|
||||||
let dur = duration.unwrap_or(5);
|
let dur = duration.unwrap_or(5);
|
||||||
let s = shell_escape(&server);
|
|
||||||
let cmd = format!(
|
let cmd = format!(
|
||||||
"iperf3 -c {} -t {} --json 2>/dev/null || iperf3 -c {} -t {} 2>&1 || echo 'iperf3 not installed — run: apt install iperf3 / brew install iperf3'",
|
"iperf3 -c {} -t {} --json 2>/dev/null || iperf3 -c {} -t {} 2>&1 || echo 'iperf3 not installed — run: apt install iperf3 / brew install iperf3'",
|
||||||
s, dur, s, dur
|
server, dur, server, dur
|
||||||
);
|
);
|
||||||
exec_on_session(&session.handle, &cmd).await
|
exec_on_session(&session.handle, &cmd).await
|
||||||
}
|
}
|
||||||
@ -182,3 +178,27 @@ fn to_ip(val: u32) -> String {
|
|||||||
format!("{}.{}.{}.{}", val >> 24, (val >> 16) & 0xFF, (val >> 8) & 0xFF, val & 0xFF)
|
format!("{}.{}.{}.{}", val >> 24, (val >> 16) & 0xFF, (val >> 8) & 0xFF, val & 0xFF)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn exec_on_session(
|
||||||
|
handle: &std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>,
|
||||||
|
cmd: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut channel = {
|
||||||
|
let h = handle.lock().await;
|
||||||
|
h.channel_open_session().await.map_err(|e| format!("Exec channel failed: {}", e))?
|
||||||
|
};
|
||||||
|
channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?;
|
||||||
|
let mut output = String::new();
|
||||||
|
loop {
|
||||||
|
match channel.wait().await {
|
||||||
|
Some(russh::ChannelMsg::Data { ref data }) => {
|
||||||
|
if let Ok(text) = std::str::from_utf8(data.as_ref()) { output.push_str(text); }
|
||||||
|
}
|
||||||
|
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
|
||||||
|
Some(russh::ChannelMsg::ExitStatus { .. }) => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,94 +0,0 @@
|
|||||||
//! Version check against Gitea releases API.
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct UpdateInfo {
|
|
||||||
pub current_version: String,
|
|
||||||
pub latest_version: String,
|
|
||||||
pub update_available: bool,
|
|
||||||
pub download_url: String,
|
|
||||||
pub release_notes: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check Gitea for the latest release and compare with current version.
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn check_for_updates(app_handle: tauri::AppHandle) -> Result<UpdateInfo, String> {
|
|
||||||
// Read version from tauri.conf.json (patched by CI from git tag)
|
|
||||||
// rather than CARGO_PKG_VERSION which is always 0.1.0
|
|
||||||
let current = app_handle.config().version.clone().unwrap_or_else(|| "0.0.0".to_string());
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("HTTP client error: {}", e))?;
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.get("https://git.command.vigilcyber.com/api/v1/repos/vstockwell/wraith/releases?limit=1")
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to check for updates: {}", e))?;
|
|
||||||
|
|
||||||
let releases: Vec<serde_json::Value> = resp.json().await
|
|
||||||
.map_err(|e| format!("Failed to parse releases: {}", e))?;
|
|
||||||
|
|
||||||
let latest = releases.first()
|
|
||||||
.ok_or_else(|| "No releases found".to_string())?;
|
|
||||||
|
|
||||||
let tag = latest.get("tag_name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("v0.0.0")
|
|
||||||
.trim_start_matches('v')
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let notes = latest.get("body")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Direct download from SeaweedFS
|
|
||||||
let html_url = format!("https://files.command.vigilcyber.com/wraith/{}/", tag);
|
|
||||||
|
|
||||||
let update_available = version_is_newer(&tag, ¤t);
|
|
||||||
|
|
||||||
Ok(UpdateInfo {
|
|
||||||
current_version: current,
|
|
||||||
latest_version: tag,
|
|
||||||
update_available,
|
|
||||||
download_url: html_url,
|
|
||||||
release_notes: notes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compare semver strings. Returns true if `latest` is newer than `current`.
|
|
||||||
fn version_is_newer(latest: &str, current: &str) -> bool {
|
|
||||||
let parse = |v: &str| -> Vec<u32> {
|
|
||||||
v.split('.').filter_map(|s| s.parse().ok()).collect()
|
|
||||||
};
|
|
||||||
let l = parse(latest);
|
|
||||||
let c = parse(current);
|
|
||||||
for i in 0..3 {
|
|
||||||
let lv = l.get(i).copied().unwrap_or(0);
|
|
||||||
let cv = c.get(i).copied().unwrap_or(0);
|
|
||||||
if lv > cv { return true; }
|
|
||||||
if lv < cv { return false; }
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn version_comparison() {
|
|
||||||
assert!(version_is_newer("1.5.7", "1.5.6"));
|
|
||||||
assert!(version_is_newer("1.6.0", "1.5.9"));
|
|
||||||
assert!(version_is_newer("2.0.0", "1.9.9"));
|
|
||||||
assert!(!version_is_newer("1.5.6", "1.5.6"));
|
|
||||||
assert!(!version_is_newer("1.5.5", "1.5.6"));
|
|
||||||
assert!(!version_is_newer("1.4.0", "1.5.0"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
use zeroize::Zeroize;
|
|
||||||
|
|
||||||
use crate::vault::{self, VaultService};
|
use crate::vault::{self, VaultService};
|
||||||
use crate::credentials::CredentialService;
|
use crate::credentials::CredentialService;
|
||||||
@ -22,15 +21,14 @@ pub fn is_first_run(state: State<'_, AppState>) -> bool {
|
|||||||
/// Returns `Err` if the vault has already been set up or if any storage
|
/// Returns `Err` if the vault has already been set up or if any storage
|
||||||
/// operation fails.
|
/// operation fails.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn create_vault(mut password: String, state: State<'_, AppState>) -> Result<(), String> {
|
pub fn create_vault(password: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
let result = async {
|
|
||||||
if !state.is_first_run() {
|
if !state.is_first_run() {
|
||||||
return Err("Vault already exists — use unlock instead of create".into());
|
return Err("Vault already exists — use unlock instead of create".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let salt = vault::generate_salt();
|
let salt = vault::generate_salt();
|
||||||
let key = vault::derive_key(&password, &salt);
|
let key = vault::derive_key(&password, &salt);
|
||||||
let vs = VaultService::new(key.clone());
|
let vs = VaultService::new(key);
|
||||||
|
|
||||||
// Persist the salt so we can re-derive the key on future unlocks.
|
// Persist the salt so we can re-derive the key on future unlocks.
|
||||||
state.settings.set("vault_salt", &hex::encode(salt))?;
|
state.settings.set("vault_salt", &hex::encode(salt))?;
|
||||||
@ -41,14 +39,10 @@ pub async fn create_vault(mut password: String, state: State<'_, AppState>) -> R
|
|||||||
|
|
||||||
// Activate the vault and credentials service for this session.
|
// Activate the vault and credentials service for this session.
|
||||||
let cred_svc = CredentialService::new(state.db.clone(), VaultService::new(key));
|
let cred_svc = CredentialService::new(state.db.clone(), VaultService::new(key));
|
||||||
*state.credentials.lock().await = Some(cred_svc);
|
*state.credentials.lock().unwrap() = Some(cred_svc);
|
||||||
*state.vault.lock().await = Some(vs);
|
*state.vault.lock().unwrap() = Some(vs);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}.await;
|
|
||||||
|
|
||||||
password.zeroize();
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unlock an existing vault using the master password.
|
/// Unlock an existing vault using the master password.
|
||||||
@ -58,8 +52,7 @@ pub async fn create_vault(mut password: String, state: State<'_, AppState>) -> R
|
|||||||
///
|
///
|
||||||
/// Returns `Err("Incorrect master password")` if the password is wrong.
|
/// Returns `Err("Incorrect master password")` if the password is wrong.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn unlock(mut password: String, state: State<'_, AppState>) -> Result<(), String> {
|
pub fn unlock(password: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
let result = async {
|
|
||||||
let salt_hex = state
|
let salt_hex = state
|
||||||
.settings
|
.settings
|
||||||
.get("vault_salt")
|
.get("vault_salt")
|
||||||
@ -69,7 +62,7 @@ pub async fn unlock(mut password: String, state: State<'_, AppState>) -> Result<
|
|||||||
.map_err(|e| format!("Stored vault salt is corrupt: {e}"))?;
|
.map_err(|e| format!("Stored vault salt is corrupt: {e}"))?;
|
||||||
|
|
||||||
let key = vault::derive_key(&password, &salt);
|
let key = vault::derive_key(&password, &salt);
|
||||||
let vs = VaultService::new(key.clone());
|
let vs = VaultService::new(key);
|
||||||
|
|
||||||
// Verify the password by decrypting the check value.
|
// Verify the password by decrypting the check value.
|
||||||
let check_blob = state
|
let check_blob = state
|
||||||
@ -87,18 +80,14 @@ pub async fn unlock(mut password: String, state: State<'_, AppState>) -> Result<
|
|||||||
|
|
||||||
// Activate the vault and credentials service for this session.
|
// Activate the vault and credentials service for this session.
|
||||||
let cred_svc = CredentialService::new(state.db.clone(), VaultService::new(key));
|
let cred_svc = CredentialService::new(state.db.clone(), VaultService::new(key));
|
||||||
*state.credentials.lock().await = Some(cred_svc);
|
*state.credentials.lock().unwrap() = Some(cred_svc);
|
||||||
*state.vault.lock().await = Some(vs);
|
*state.vault.lock().unwrap() = Some(vs);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}.await;
|
|
||||||
|
|
||||||
password.zeroize();
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if the vault is currently unlocked for this session.
|
/// Returns `true` if the vault is currently unlocked for this session.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn is_unlocked(state: State<'_, AppState>) -> Result<bool, String> {
|
pub fn is_unlocked(state: State<'_, AppState>) -> bool {
|
||||||
Ok(state.is_unlocked().await)
|
state.is_unlocked()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
use tauri::AppHandle;
|
|
||||||
use tauri::WebviewWindowBuilder;
|
|
||||||
|
|
||||||
/// Open a child window from the Rust side using WebviewWindowBuilder.
|
|
||||||
///
|
|
||||||
/// The `url` parameter supports hash fragments (e.g. "index.html#/tool/ping?sessionId=abc").
|
|
||||||
/// WebviewUrl::App takes a PathBuf and cannot handle hash/query — so we load plain
|
|
||||||
/// index.html and set the hash via JS after the window is created.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn open_child_window(
|
|
||||||
app_handle: AppHandle,
|
|
||||||
label: String,
|
|
||||||
title: String,
|
|
||||||
url: String,
|
|
||||||
width: f64,
|
|
||||||
height: f64,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// Split "index.html#/tool/ping?sessionId=abc" into path and fragment
|
|
||||||
let (path, hash) = match url.split_once('#') {
|
|
||||||
Some((p, h)) => (p.to_string(), Some(format!("#{}", h))),
|
|
||||||
None => (url.clone(), None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let webview_url = tauri::WebviewUrl::App(path.into());
|
|
||||||
let window = WebviewWindowBuilder::new(&app_handle, &label, webview_url)
|
|
||||||
.title(&title)
|
|
||||||
.inner_size(width, height)
|
|
||||||
.resizable(true)
|
|
||||||
.center()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to create window '{}': {}", label, e))?;
|
|
||||||
|
|
||||||
// Set the hash fragment after the window loads — this triggers App.vue's
|
|
||||||
// onMounted hash detection to render the correct tool/detached component.
|
|
||||||
if let Some(hash) = hash {
|
|
||||||
let _ = window.eval(&format!("window.location.hash = '{}';", hash));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
//! Tauri commands for workspace persistence.
|
|
||||||
|
|
||||||
use tauri::State;
|
|
||||||
use crate::AppState;
|
|
||||||
use crate::workspace::{WorkspaceSnapshot, WorkspaceTab};
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn save_workspace(tabs: Vec<WorkspaceTab>, state: State<'_, AppState>) -> Result<(), String> {
|
|
||||||
let snapshot = WorkspaceSnapshot { tabs };
|
|
||||||
state.workspace.save(&snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn load_workspace(state: State<'_, AppState>) -> Result<Option<WorkspaceSnapshot>, String> {
|
|
||||||
Ok(state.workspace.load())
|
|
||||||
}
|
|
||||||
@ -19,7 +19,6 @@ use crate::db::Database;
|
|||||||
// ── domain types ──────────────────────────────────────────────────────────────
|
// ── domain types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ConnectionGroup {
|
pub struct ConnectionGroup {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@ -430,54 +429,6 @@ impl ConnectionService {
|
|||||||
|
|
||||||
Ok(records)
|
Ok(records)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Batch-update sort_order for a list of connection IDs.
|
|
||||||
pub fn reorder_connections(&self, ids: &[i64]) -> Result<(), String> {
|
|
||||||
let conn = self.db.conn();
|
|
||||||
conn.execute_batch("BEGIN")
|
|
||||||
.map_err(|e| format!("Failed to begin reorder transaction: {e}"))?;
|
|
||||||
let result = (|| {
|
|
||||||
for (i, id) in ids.iter().enumerate() {
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE connections SET sort_order = ?1 WHERE id = ?2",
|
|
||||||
params![i as i64, id],
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Failed to reorder connection {id}: {e}"))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
if result.is_err() {
|
|
||||||
let _ = conn.execute_batch("ROLLBACK");
|
|
||||||
} else {
|
|
||||||
conn.execute_batch("COMMIT")
|
|
||||||
.map_err(|e| format!("Failed to commit reorder transaction: {e}"))?;
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Batch-update sort_order for a list of group IDs.
|
|
||||||
pub fn reorder_groups(&self, ids: &[i64]) -> Result<(), String> {
|
|
||||||
let conn = self.db.conn();
|
|
||||||
conn.execute_batch("BEGIN")
|
|
||||||
.map_err(|e| format!("Failed to begin reorder transaction: {e}"))?;
|
|
||||||
let result = (|| {
|
|
||||||
for (i, id) in ids.iter().enumerate() {
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE groups SET sort_order = ?1 WHERE id = ?2",
|
|
||||||
params![i as i64, id],
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Failed to reorder group {id}: {e}"))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
if result.is_err() {
|
|
||||||
let _ = conn.execute_batch("ROLLBACK");
|
|
||||||
} else {
|
|
||||||
conn.execute_batch("COMMIT")
|
|
||||||
.map_err(|e| format!("Failed to commit reorder transaction: {e}"))?;
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── private helpers ───────────────────────────────────────────────────────────
|
// ── private helpers ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -31,11 +31,10 @@ impl Database {
|
|||||||
|
|
||||||
/// Acquire a lock on the underlying connection.
|
/// Acquire a lock on the underlying connection.
|
||||||
///
|
///
|
||||||
/// Recovers gracefully from a poisoned mutex by taking the inner value.
|
/// Panics if the mutex was poisoned (which only happens if a thread
|
||||||
/// A poisoned mutex means a thread panicked while holding the lock; the
|
/// panicked while holding the lock — a non-recoverable situation anyway).
|
||||||
/// connection itself is still valid, so we can continue operating.
|
|
||||||
pub fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
|
pub fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
|
||||||
self.conn.lock().unwrap_or_else(|e| e.into_inner())
|
self.conn.lock().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run all embedded SQL migrations.
|
/// Run all embedded SQL migrations.
|
||||||
|
|||||||
@ -1,12 +1,3 @@
|
|||||||
// Global debug log macro — must be declared before modules that use it
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! wraith_log {
|
|
||||||
($($arg:tt)*) => {{
|
|
||||||
let msg = format!($($arg)*);
|
|
||||||
let _ = $crate::write_log(&$crate::data_directory().join("wraith.log"), &msg);
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
@ -21,9 +12,9 @@ pub mod pty;
|
|||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod scanner;
|
pub mod scanner;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod utils;
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use db::Database;
|
use db::Database;
|
||||||
use vault::VaultService;
|
use vault::VaultService;
|
||||||
@ -41,10 +32,10 @@ use mcp::error_watcher::ErrorWatcher;
|
|||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Database,
|
pub db: Database,
|
||||||
pub vault: tokio::sync::Mutex<Option<VaultService>>,
|
pub vault: Mutex<Option<VaultService>>,
|
||||||
pub settings: SettingsService,
|
pub settings: SettingsService,
|
||||||
pub connections: ConnectionService,
|
pub connections: ConnectionService,
|
||||||
pub credentials: tokio::sync::Mutex<Option<CredentialService>>,
|
pub credentials: Mutex<Option<CredentialService>>,
|
||||||
pub ssh: SshService,
|
pub ssh: SshService,
|
||||||
pub sftp: SftpService,
|
pub sftp: SftpService,
|
||||||
pub rdp: RdpService,
|
pub rdp: RdpService,
|
||||||
@ -60,18 +51,17 @@ impl AppState {
|
|||||||
std::fs::create_dir_all(&data_dir)?;
|
std::fs::create_dir_all(&data_dir)?;
|
||||||
let database = Database::open(&data_dir.join("wraith.db"))?;
|
let database = Database::open(&data_dir.join("wraith.db"))?;
|
||||||
database.migrate()?;
|
database.migrate()?;
|
||||||
let settings = SettingsService::new(database.clone());
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
db: database.clone(),
|
db: database.clone(),
|
||||||
vault: tokio::sync::Mutex::new(None),
|
vault: Mutex::new(None),
|
||||||
|
settings: SettingsService::new(database.clone()),
|
||||||
connections: ConnectionService::new(database.clone()),
|
connections: ConnectionService::new(database.clone()),
|
||||||
credentials: tokio::sync::Mutex::new(None),
|
credentials: Mutex::new(None),
|
||||||
ssh: SshService::new(database.clone()),
|
ssh: SshService::new(database.clone()),
|
||||||
sftp: SftpService::new(),
|
sftp: SftpService::new(),
|
||||||
rdp: RdpService::new(),
|
rdp: RdpService::new(),
|
||||||
theme: ThemeService::new(database),
|
theme: ThemeService::new(database.clone()),
|
||||||
workspace: WorkspaceService::new(settings.clone()),
|
workspace: WorkspaceService::new(SettingsService::new(database.clone())),
|
||||||
settings,
|
|
||||||
pty: PtyService::new(),
|
pty: PtyService::new(),
|
||||||
scrollback: ScrollbackRegistry::new(),
|
scrollback: ScrollbackRegistry::new(),
|
||||||
error_watcher: std::sync::Arc::new(ErrorWatcher::new()),
|
error_watcher: std::sync::Arc::new(ErrorWatcher::new()),
|
||||||
@ -86,8 +76,8 @@ impl AppState {
|
|||||||
self.settings.get("vault_salt").unwrap_or_default().is_empty()
|
self.settings.get("vault_salt").unwrap_or_default().is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_unlocked(&self) -> bool {
|
pub fn is_unlocked(&self) -> bool {
|
||||||
self.vault.lock().await.is_some()
|
self.vault.lock().unwrap().is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,52 +91,13 @@ pub fn data_directory() -> PathBuf {
|
|||||||
PathBuf::from(".")
|
PathBuf::from(".")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached log file handle — opened once on first use, reused for all subsequent
|
|
||||||
/// writes. Avoids the open/close syscall pair that the original implementation
|
|
||||||
/// paid on every `wraith_log!` invocation.
|
|
||||||
static LOG_FILE: std::sync::OnceLock<std::sync::Mutex<std::fs::File>> = std::sync::OnceLock::new();
|
|
||||||
|
|
||||||
fn write_log(path: &std::path::Path, msg: &str) -> std::io::Result<()> {
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
let handle = LOG_FILE.get_or_init(|| {
|
|
||||||
let file = std::fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open(path)
|
|
||||||
.expect("failed to open wraith.log");
|
|
||||||
std::sync::Mutex::new(file)
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut f = handle.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let elapsed = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
writeln!(f, "[{}] {}", elapsed, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
// Install rustls crypto provider before any TLS operations (RDP needs this)
|
let app_state = AppState::new(data_directory()).expect("Failed to init AppState");
|
||||||
let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
|
|
||||||
|
|
||||||
// Initialize file-based logging to data_dir/wraith.log
|
|
||||||
let log_path = data_directory().join("wraith.log");
|
|
||||||
let _ = write_log(&log_path, "=== Wraith starting ===");
|
|
||||||
|
|
||||||
let app_state = match AppState::new(data_directory()) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = write_log(&log_path, &format!("FATAL: AppState init failed: {}", e));
|
|
||||||
panic!("Failed to init AppState: {}", e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
app_state.theme.seed_builtins();
|
app_state.theme.seed_builtins();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
||||||
.manage(app_state)
|
.manage(app_state)
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
@ -158,59 +109,25 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start MCP and error watcher — completely non-fatal.
|
// Start MCP and error watcher — completely non-fatal.
|
||||||
|
// These are nice-to-have services that must never crash the app.
|
||||||
{
|
{
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
let log_file = data_directory().join("wraith.log");
|
if let Ok(state) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
let _ = write_log(&log_file, "Setup: starting MCP and error watcher");
|
|
||||||
|
|
||||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
||||||
app.state::<AppState>().inner().clone_services()
|
app.state::<AppState>().inner().clone_services()
|
||||||
})) {
|
})) {
|
||||||
Ok(state) => {
|
|
||||||
let (ssh, rdp, sftp, scrollback, watcher) = state;
|
let (ssh, rdp, sftp, scrollback, watcher) = state;
|
||||||
let _ = write_log(&log_file, "Setup: cloned services OK");
|
|
||||||
|
|
||||||
// Error watcher — std::thread, no tokio needed
|
|
||||||
let watcher_for_mcp = watcher.clone();
|
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
let app_handle_for_mcp = app.handle().clone();
|
|
||||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
||||||
mcp::error_watcher::start_error_watcher(watcher, scrollback.clone(), app_handle);
|
mcp::error_watcher::start_error_watcher(watcher, scrollback.clone(), app_handle);
|
||||||
}));
|
|
||||||
let _ = write_log(&log_file, "Setup: error watcher started");
|
|
||||||
|
|
||||||
// MCP HTTP server — needs async runtime
|
|
||||||
let log_file2 = log_file.clone();
|
|
||||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
match mcp::server::start_mcp_server(ssh, rdp, sftp, scrollback, app_handle_for_mcp, watcher_for_mcp).await {
|
match mcp::server::start_mcp_server(ssh, rdp, sftp, scrollback).await {
|
||||||
Ok(port) => { let _ = write_log(&log_file2, &format!("MCP server started on localhost:{}", port)); }
|
Ok(port) => log::info!("MCP server started on localhost:{}", port),
|
||||||
Err(e) => { let _ = write_log(&log_file2, &format!("MCP server FAILED: {}", e)); }
|
Err(e) => log::error!("Failed to start MCP server: {}", e),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}));
|
|
||||||
let _ = write_log(&log_file, "Setup: MCP spawn dispatched");
|
|
||||||
|
|
||||||
// Download/update MCP bridge binary if needed
|
|
||||||
let app_ver = app.config().version.clone().unwrap_or_else(|| "0.0.0".to_string());
|
|
||||||
let log_file3 = log_file.clone();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
match mcp::bridge_manager::ensure_bridge(&app_ver).await {
|
|
||||||
Ok(()) => { let _ = write_log(&log_file3, "Setup: MCP bridge binary OK"); }
|
|
||||||
Err(e) => { let _ = write_log(&log_file3, &format!("Setup: MCP bridge download failed: {}", e)); }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(panic) => {
|
|
||||||
let msg = if let Some(s) = panic.downcast_ref::<String>() {
|
|
||||||
s.clone()
|
|
||||||
} else if let Some(s) = panic.downcast_ref::<&str>() {
|
|
||||||
s.to_string()
|
|
||||||
} else {
|
} else {
|
||||||
format!("{:?}", panic.type_id())
|
log::error!("MCP/error watcher startup failed — continuing without MCP");
|
||||||
};
|
|
||||||
let _ = write_log(&log_file, &format!("MCP startup panicked: {}", msg));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,21 +137,17 @@ pub fn run() {
|
|||||||
commands::vault::is_first_run, commands::vault::create_vault, commands::vault::unlock, commands::vault::is_unlocked,
|
commands::vault::is_first_run, commands::vault::create_vault, commands::vault::unlock, commands::vault::is_unlocked,
|
||||||
commands::settings::get_setting, commands::settings::set_setting,
|
commands::settings::get_setting, commands::settings::set_setting,
|
||||||
commands::connections::list_connections, commands::connections::create_connection, commands::connections::get_connection, commands::connections::update_connection, commands::connections::delete_connection,
|
commands::connections::list_connections, commands::connections::create_connection, commands::connections::get_connection, commands::connections::update_connection, commands::connections::delete_connection,
|
||||||
commands::connections::list_groups, commands::connections::create_group, commands::connections::delete_group, commands::connections::rename_group, commands::connections::search_connections, commands::connections::reorder_connections, commands::connections::reorder_groups,
|
commands::connections::list_groups, commands::connections::create_group, commands::connections::delete_group, commands::connections::rename_group, commands::connections::search_connections,
|
||||||
commands::credentials::list_credentials, commands::credentials::create_password, commands::credentials::create_ssh_key, commands::credentials::delete_credential, commands::credentials::decrypt_password, commands::credentials::decrypt_ssh_key,
|
commands::credentials::list_credentials, commands::credentials::create_password, commands::credentials::create_ssh_key, commands::credentials::delete_credential, commands::credentials::decrypt_password, commands::credentials::decrypt_ssh_key,
|
||||||
commands::ssh_commands::connect_ssh, commands::ssh_commands::connect_ssh_with_key, commands::ssh_commands::ssh_write, commands::ssh_commands::ssh_resize, commands::ssh_commands::disconnect_ssh, commands::ssh_commands::disconnect_session, commands::ssh_commands::list_ssh_sessions,
|
commands::ssh_commands::connect_ssh, commands::ssh_commands::connect_ssh_with_key, commands::ssh_commands::ssh_write, commands::ssh_commands::ssh_resize, commands::ssh_commands::disconnect_ssh, commands::ssh_commands::disconnect_session, commands::ssh_commands::list_ssh_sessions,
|
||||||
commands::sftp_commands::sftp_list, commands::sftp_commands::sftp_read_file, commands::sftp_commands::sftp_write_file, commands::sftp_commands::sftp_mkdir, commands::sftp_commands::sftp_delete, commands::sftp_commands::sftp_rename,
|
commands::sftp_commands::sftp_list, commands::sftp_commands::sftp_read_file, commands::sftp_commands::sftp_write_file, commands::sftp_commands::sftp_mkdir, commands::sftp_commands::sftp_delete, commands::sftp_commands::sftp_rename,
|
||||||
commands::rdp_commands::connect_rdp, commands::rdp_commands::rdp_get_frame, commands::rdp_commands::rdp_force_refresh, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::rdp_send_clipboard, commands::rdp_commands::rdp_resize, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions,
|
commands::rdp_commands::connect_rdp, commands::rdp_commands::rdp_get_frame, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::rdp_send_clipboard, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions,
|
||||||
commands::theme_commands::list_themes, commands::theme_commands::get_theme,
|
commands::theme_commands::list_themes, commands::theme_commands::get_theme,
|
||||||
commands::pty_commands::list_available_shells, commands::pty_commands::spawn_local_shell, commands::pty_commands::pty_write, commands::pty_commands::pty_resize, commands::pty_commands::disconnect_pty,
|
commands::pty_commands::list_available_shells, commands::pty_commands::spawn_local_shell, commands::pty_commands::pty_write, commands::pty_commands::pty_resize, commands::pty_commands::disconnect_pty,
|
||||||
commands::mcp_commands::mcp_list_sessions, commands::mcp_commands::mcp_terminal_read, commands::mcp_commands::mcp_terminal_execute, commands::mcp_commands::mcp_get_session_context, commands::mcp_commands::mcp_bridge_path,
|
commands::mcp_commands::mcp_list_sessions, commands::mcp_commands::mcp_terminal_read, commands::mcp_commands::mcp_terminal_execute, commands::mcp_commands::mcp_get_session_context,
|
||||||
commands::scanner_commands::scan_network, commands::scanner_commands::scan_ports, commands::scanner_commands::quick_scan,
|
commands::scanner_commands::scan_network, commands::scanner_commands::scan_ports, commands::scanner_commands::quick_scan,
|
||||||
commands::tools_commands::tool_ping, commands::tools_commands::tool_traceroute, commands::tools_commands::tool_wake_on_lan, commands::tools_commands::tool_generate_ssh_key, commands::tools_commands::tool_generate_password,
|
commands::tools_commands::tool_ping, commands::tools_commands::tool_traceroute, commands::tools_commands::tool_wake_on_lan, commands::tools_commands::tool_generate_ssh_key, commands::tools_commands::tool_generate_password,
|
||||||
commands::tools_commands_r2::tool_dns_lookup, commands::tools_commands_r2::tool_whois, commands::tools_commands_r2::tool_bandwidth_iperf, commands::tools_commands_r2::tool_bandwidth_speedtest, commands::tools_commands_r2::tool_subnet_calc,
|
commands::tools_commands_r2::tool_dns_lookup, commands::tools_commands_r2::tool_whois, commands::tools_commands_r2::tool_bandwidth_iperf, commands::tools_commands_r2::tool_bandwidth_speedtest, commands::tools_commands_r2::tool_subnet_calc,
|
||||||
commands::updater::check_for_updates,
|
|
||||||
commands::workspace_commands::save_workspace, commands::workspace_commands::load_workspace,
|
|
||||||
commands::docker_commands::docker_list_containers, commands::docker_commands::docker_list_images, commands::docker_commands::docker_list_volumes, commands::docker_commands::docker_action,
|
|
||||||
commands::window_commands::open_child_window,
|
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@ -1,85 +0,0 @@
|
|||||||
//! MCP bridge binary self-management.
|
|
||||||
//!
|
|
||||||
//! On startup, checks if wraith-mcp-bridge exists in the data directory.
|
|
||||||
//! If missing or outdated, downloads the correct version from Gitea packages.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
/// Get the expected path for the bridge binary.
|
|
||||||
pub fn bridge_path() -> PathBuf {
|
|
||||||
let dir = crate::data_directory();
|
|
||||||
if cfg!(windows) {
|
|
||||||
dir.join("wraith-mcp-bridge.exe")
|
|
||||||
} else {
|
|
||||||
dir.join("wraith-mcp-bridge")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the bridge binary exists and is the correct version.
|
|
||||||
/// If not, download it from Gitea packages.
|
|
||||||
pub async fn ensure_bridge(app_version: &str) -> Result<(), String> {
|
|
||||||
let path = bridge_path();
|
|
||||||
let version_file = crate::data_directory().join("mcp-bridge-version");
|
|
||||||
|
|
||||||
// Check if bridge exists and version matches
|
|
||||||
if path.exists() {
|
|
||||||
if let Ok(installed_ver) = std::fs::read_to_string(&version_file) {
|
|
||||||
if installed_ver.trim() == app_version {
|
|
||||||
wraith_log!("[MCP Bridge] v{} already installed at {}", app_version, path.display());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wraith_log!("[MCP Bridge] Downloading v{} to {}", app_version, path.display());
|
|
||||||
|
|
||||||
let binary_name = if cfg!(windows) {
|
|
||||||
"wraith-mcp-bridge.exe"
|
|
||||||
} else {
|
|
||||||
"wraith-mcp-bridge"
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"https://files.command.vigilcyber.com/wraith/{}/{}",
|
|
||||||
app_version, binary_name
|
|
||||||
);
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("HTTP client error: {}", e))?;
|
|
||||||
|
|
||||||
let resp = client.get(&url).send().await
|
|
||||||
.map_err(|e| format!("Failed to download MCP bridge: {}", e))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("MCP bridge download failed: HTTP {}", resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = resp.bytes().await
|
|
||||||
.map_err(|e| format!("Failed to read MCP bridge response: {}", e))?;
|
|
||||||
|
|
||||||
// Write the binary
|
|
||||||
std::fs::write(&path, &bytes)
|
|
||||||
.map_err(|e| format!("Failed to write MCP bridge to {}: {}", path.display(), e))?;
|
|
||||||
|
|
||||||
// Make executable on Unix
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let mut perms = std::fs::metadata(&path)
|
|
||||||
.map_err(|e| format!("Failed to read permissions: {}", e))?
|
|
||||||
.permissions();
|
|
||||||
perms.set_mode(0o755);
|
|
||||||
std::fs::set_permissions(&path, perms)
|
|
||||||
.map_err(|e| format!("Failed to set execute permission: {}", e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write version marker
|
|
||||||
std::fs::write(&version_file, app_version)
|
|
||||||
.map_err(|e| format!("Failed to write version file: {}", e))?;
|
|
||||||
|
|
||||||
wraith_log!("[MCP Bridge] v{} installed successfully ({} bytes)", app_version, bytes.len());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@ -62,9 +62,9 @@ impl ErrorWatcher {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only scan bytes written since the last check — avoids
|
let raw = buf.read_raw();
|
||||||
// reading the entire 64 KB ring buffer on every 2-second tick.
|
let new_start = raw.len().saturating_sub(total - last_pos);
|
||||||
let new_content = buf.read_since(last_pos);
|
let new_content = &raw[new_start..];
|
||||||
|
|
||||||
for line in new_content.lines() {
|
for line in new_content.lines() {
|
||||||
for pattern in ERROR_PATTERNS {
|
for pattern in ERROR_PATTERNS {
|
||||||
@ -99,9 +99,9 @@ pub fn start_error_watcher(
|
|||||||
scrollback: ScrollbackRegistry,
|
scrollback: ScrollbackRegistry,
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
) {
|
) {
|
||||||
std::thread::spawn(move || {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
|
||||||
let alerts = watcher.scan(&scrollback);
|
let alerts = watcher.scan(&scrollback);
|
||||||
for (session_id, line) in alerts {
|
for (session_id, line) in alerts {
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
pub mod scrollback;
|
pub mod scrollback;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod error_watcher;
|
pub mod error_watcher;
|
||||||
pub mod bridge_manager;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@ -19,12 +18,12 @@ use crate::mcp::scrollback::ScrollbackBuffer;
|
|||||||
/// Shared between SSH/PTY output loops (writers) and MCP tools (readers).
|
/// Shared between SSH/PTY output loops (writers) and MCP tools (readers).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ScrollbackRegistry {
|
pub struct ScrollbackRegistry {
|
||||||
buffers: Arc<DashMap<String, Arc<ScrollbackBuffer>>>,
|
buffers: DashMap<String, Arc<ScrollbackBuffer>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScrollbackRegistry {
|
impl ScrollbackRegistry {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { buffers: Arc::new(DashMap::new()) }
|
Self { buffers: DashMap::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create and register a new scrollback buffer for a session.
|
/// Create and register a new scrollback buffer for a session.
|
||||||
@ -36,7 +35,7 @@ impl ScrollbackRegistry {
|
|||||||
|
|
||||||
/// Get the scrollback buffer for a session.
|
/// Get the scrollback buffer for a session.
|
||||||
pub fn get(&self, session_id: &str) -> Option<Arc<ScrollbackBuffer>> {
|
pub fn get(&self, session_id: &str) -> Option<Arc<ScrollbackBuffer>> {
|
||||||
self.buffers.get(session_id).map(|r| r.value().clone())
|
self.buffers.get(session_id).map(|entry| entry.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a session's scrollback buffer.
|
/// Remove a session's scrollback buffer.
|
||||||
|
|||||||
@ -40,25 +40,13 @@ impl ScrollbackBuffer {
|
|||||||
|
|
||||||
/// Append bytes to the buffer. Old data is overwritten when full.
|
/// Append bytes to the buffer. Old data is overwritten when full.
|
||||||
pub fn push(&self, bytes: &[u8]) {
|
pub fn push(&self, bytes: &[u8]) {
|
||||||
if bytes.is_empty() {
|
let mut buf = self.inner.lock().unwrap();
|
||||||
return;
|
for &b in bytes {
|
||||||
|
let pos = buf.write_pos;
|
||||||
|
buf.data[pos] = b;
|
||||||
|
buf.write_pos = (pos + 1) % buf.capacity;
|
||||||
|
buf.total_written += 1;
|
||||||
}
|
}
|
||||||
let mut buf = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let cap = buf.capacity;
|
|
||||||
// If input exceeds capacity, only keep the last `cap` bytes
|
|
||||||
let data = if bytes.len() > cap {
|
|
||||||
&bytes[bytes.len() - cap..]
|
|
||||||
} else {
|
|
||||||
bytes
|
|
||||||
};
|
|
||||||
let write_pos = buf.write_pos;
|
|
||||||
let first_len = (cap - write_pos).min(data.len());
|
|
||||||
buf.data[write_pos..write_pos + first_len].copy_from_slice(&data[..first_len]);
|
|
||||||
if first_len < data.len() {
|
|
||||||
buf.data[..data.len() - first_len].copy_from_slice(&data[first_len..]);
|
|
||||||
}
|
|
||||||
buf.write_pos = (write_pos + data.len()) % cap;
|
|
||||||
buf.total_written += bytes.len();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the last `n` lines from the buffer, with ANSI escape codes stripped.
|
/// Read the last `n` lines from the buffer, with ANSI escape codes stripped.
|
||||||
@ -72,7 +60,7 @@ impl ScrollbackBuffer {
|
|||||||
|
|
||||||
/// Read all buffered content as raw bytes (ordered oldest→newest).
|
/// Read all buffered content as raw bytes (ordered oldest→newest).
|
||||||
pub fn read_raw(&self) -> String {
|
pub fn read_raw(&self) -> String {
|
||||||
let buf = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let buf = self.inner.lock().unwrap();
|
||||||
let bytes = if buf.total_written >= buf.capacity {
|
let bytes = if buf.total_written >= buf.capacity {
|
||||||
// Buffer has wrapped — read from write_pos to end, then start to write_pos
|
// Buffer has wrapped — read from write_pos to end, then start to write_pos
|
||||||
let mut out = Vec::with_capacity(buf.capacity);
|
let mut out = Vec::with_capacity(buf.capacity);
|
||||||
@ -88,47 +76,7 @@ impl ScrollbackBuffer {
|
|||||||
|
|
||||||
/// Total bytes written since creation.
|
/// Total bytes written since creation.
|
||||||
pub fn total_written(&self) -> usize {
|
pub fn total_written(&self) -> usize {
|
||||||
self.inner.lock().unwrap_or_else(|e| e.into_inner()).total_written
|
self.inner.lock().unwrap().total_written
|
||||||
}
|
|
||||||
|
|
||||||
/// Read only the bytes written after `position` (total_written offset),
|
|
||||||
/// ordered oldest→newest, with ANSI codes stripped.
|
|
||||||
///
|
|
||||||
/// Returns an empty string when there is nothing new since `position`.
|
|
||||||
/// This is more efficient than `read_raw()` for incremental scanning because
|
|
||||||
/// it avoids copying the full 64 KB ring buffer when only a small delta exists.
|
|
||||||
pub fn read_since(&self, position: usize) -> String {
|
|
||||||
let buf = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let total = buf.total_written;
|
|
||||||
if total <= position {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
let new_bytes = total - position;
|
|
||||||
let cap = buf.capacity;
|
|
||||||
|
|
||||||
// How many bytes are actually stored in the ring (max = capacity)
|
|
||||||
let stored = total.min(cap);
|
|
||||||
// Clamp new_bytes to what's actually in the buffer
|
|
||||||
let readable = new_bytes.min(stored);
|
|
||||||
|
|
||||||
// Write position is where the *next* byte would go; reading backwards
|
|
||||||
// from write_pos gives us the most recent `readable` bytes.
|
|
||||||
let write_pos = buf.write_pos;
|
|
||||||
let bytes = if readable <= write_pos {
|
|
||||||
// Contiguous slice ending at write_pos
|
|
||||||
buf.data[write_pos - readable..write_pos].to_vec()
|
|
||||||
} else {
|
|
||||||
// Wraps around: tail of buffer + head up to write_pos
|
|
||||||
let tail_len = readable - write_pos;
|
|
||||||
let tail_start = cap - tail_len;
|
|
||||||
let mut out = Vec::with_capacity(readable);
|
|
||||||
out.extend_from_slice(&buf.data[tail_start..]);
|
|
||||||
out.extend_from_slice(&buf.data[..write_pos]);
|
|
||||||
out
|
|
||||||
};
|
|
||||||
|
|
||||||
let raw = String::from_utf8_lossy(&bytes).to_string();
|
|
||||||
strip_ansi(&raw)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,42 +192,4 @@ mod tests {
|
|||||||
buf.push(b"ABCD"); // 4 more, wraps
|
buf.push(b"ABCD"); // 4 more, wraps
|
||||||
assert_eq!(buf.total_written(), 12);
|
assert_eq!(buf.total_written(), 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn push_empty_is_noop() {
|
|
||||||
let buf = ScrollbackBuffer::with_capacity(8);
|
|
||||||
buf.push(b"hello");
|
|
||||||
buf.push(b"");
|
|
||||||
assert_eq!(buf.total_written(), 5);
|
|
||||||
assert!(buf.read_raw().contains("hello"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn push_larger_than_capacity() {
|
|
||||||
let buf = ScrollbackBuffer::with_capacity(4);
|
|
||||||
buf.push(b"ABCDEFGH"); // 8 bytes into 4-byte buffer
|
|
||||||
let raw = buf.read_raw();
|
|
||||||
assert_eq!(raw, "EFGH"); // only last 4 bytes kept
|
|
||||||
assert_eq!(buf.total_written(), 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn push_exact_capacity() {
|
|
||||||
let buf = ScrollbackBuffer::with_capacity(8);
|
|
||||||
buf.push(b"12345678");
|
|
||||||
let raw = buf.read_raw();
|
|
||||||
assert_eq!(raw, "12345678");
|
|
||||||
assert_eq!(buf.total_written(), 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn push_wrap_around_boundary() {
|
|
||||||
let buf = ScrollbackBuffer::with_capacity(8);
|
|
||||||
buf.push(b"123456"); // write_pos = 6
|
|
||||||
buf.push(b"ABCD"); // wraps: 2 at end, 2 at start
|
|
||||||
let raw = buf.read_raw();
|
|
||||||
// Buffer: [C, D, 3, 4, 5, 6, A, B], write_pos=2
|
|
||||||
// Read from pos 2: "3456AB" + wrap: no, read from write_pos to end then start
|
|
||||||
assert_eq!(raw, "3456ABCD");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,23 +5,14 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{
|
use axum::{extract::State as AxumState, routing::post, Json, Router};
|
||||||
extract::State as AxumState,
|
|
||||||
http::{Request, StatusCode},
|
|
||||||
middleware::{self, Next},
|
|
||||||
response::Response,
|
|
||||||
routing::post,
|
|
||||||
Json, Router,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use crate::mcp::ScrollbackRegistry;
|
use crate::mcp::ScrollbackRegistry;
|
||||||
use crate::rdp::RdpService;
|
use crate::rdp::RdpService;
|
||||||
use crate::sftp::SftpService;
|
use crate::sftp::SftpService;
|
||||||
use crate::ssh::exec::exec_on_session;
|
|
||||||
use crate::ssh::session::SshService;
|
use crate::ssh::session::SshService;
|
||||||
use crate::utils::shell_escape;
|
|
||||||
|
|
||||||
/// Shared state passed to axum handlers.
|
/// Shared state passed to axum handlers.
|
||||||
pub struct McpServerState {
|
pub struct McpServerState {
|
||||||
@ -29,29 +20,6 @@ pub struct McpServerState {
|
|||||||
pub rdp: RdpService,
|
pub rdp: RdpService,
|
||||||
pub sftp: SftpService,
|
pub sftp: SftpService,
|
||||||
pub scrollback: ScrollbackRegistry,
|
pub scrollback: ScrollbackRegistry,
|
||||||
pub app_handle: tauri::AppHandle,
|
|
||||||
pub error_watcher: std::sync::Arc<crate::mcp::error_watcher::ErrorWatcher>,
|
|
||||||
pub bearer_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Middleware that validates the `Authorization: Bearer <token>` header.
|
|
||||||
async fn auth_middleware(
|
|
||||||
AxumState(state): AxumState<Arc<McpServerState>>,
|
|
||||||
req: Request<axum::body::Body>,
|
|
||||||
next: Next,
|
|
||||||
) -> Result<Response, StatusCode> {
|
|
||||||
let auth_header = req
|
|
||||||
.headers()
|
|
||||||
.get("authorization")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let expected = format!("Bearer {}", state.bearer_token);
|
|
||||||
if auth_header != expected {
|
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(next.run(req).await)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@ -84,13 +52,6 @@ struct SftpWriteRequest {
|
|||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct TerminalTypeRequest {
|
|
||||||
session_id: String,
|
|
||||||
text: String,
|
|
||||||
press_enter: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct TerminalExecuteRequest {
|
struct TerminalExecuteRequest {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
@ -187,28 +148,12 @@ async fn handle_screenshot(
|
|||||||
AxumState(state): AxumState<Arc<McpServerState>>,
|
AxumState(state): AxumState<Arc<McpServerState>>,
|
||||||
Json(req): Json<ScreenshotRequest>,
|
Json(req): Json<ScreenshotRequest>,
|
||||||
) -> Json<McpResponse<String>> {
|
) -> Json<McpResponse<String>> {
|
||||||
match state.rdp.screenshot_png_base64(&req.session_id) {
|
match state.rdp.screenshot_png_base64(&req.session_id).await {
|
||||||
Ok(b64) => ok_response(b64),
|
Ok(b64) => ok_response(b64),
|
||||||
Err(e) => err_response(e),
|
Err(e) => err_response(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_terminal_type(
|
|
||||||
AxumState(state): AxumState<Arc<McpServerState>>,
|
|
||||||
Json(req): Json<TerminalTypeRequest>,
|
|
||||||
) -> Json<McpResponse<String>> {
|
|
||||||
let text = if req.press_enter.unwrap_or(true) {
|
|
||||||
format!("{}\r", req.text)
|
|
||||||
} else {
|
|
||||||
req.text.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
match state.ssh.write(&req.session_id, text.as_bytes()).await {
|
|
||||||
Ok(()) => ok_response("sent".to_string()),
|
|
||||||
Err(e) => err_response(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_terminal_read(
|
async fn handle_terminal_read(
|
||||||
AxumState(state): AxumState<Arc<McpServerState>>,
|
AxumState(state): AxumState<Arc<McpServerState>>,
|
||||||
Json(req): Json<TerminalReadRequest>,
|
Json(req): Json<TerminalReadRequest>,
|
||||||
@ -233,7 +178,7 @@ async fn handle_terminal_execute(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let before = buf.total_written();
|
let before = buf.total_written();
|
||||||
let full_cmd = format!("{}\recho {}\r", req.command, marker);
|
let full_cmd = format!("{}\necho {}\n", req.command, marker);
|
||||||
|
|
||||||
if let Err(e) = state.ssh.write(&req.session_id, full_cmd.as_bytes()).await {
|
if let Err(e) = state.ssh.write(&req.session_id, full_cmd.as_bytes()).await {
|
||||||
return err_response(e);
|
return err_response(e);
|
||||||
@ -309,32 +254,30 @@ struct ToolPassgenRequest { length: Option<usize>, uppercase: Option<bool>, lowe
|
|||||||
|
|
||||||
async fn handle_tool_ping(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
async fn handle_tool_ping(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||||
match exec_on_session(&session.handle, &format!("ping -c 4 {} 2>&1", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
match tool_exec(&session.handle, &format!("ping -c 4 {} 2>&1", req.target)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_tool_traceroute(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
async fn handle_tool_traceroute(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||||
let t = shell_escape(&req.target);
|
match tool_exec(&session.handle, &format!("traceroute {} 2>&1 || tracert {} 2>&1", req.target, req.target)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||||
match exec_on_session(&session.handle, &format!("traceroute {} 2>&1 || tracert {} 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_tool_dns(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolDnsRequest>) -> Json<McpResponse<String>> {
|
async fn handle_tool_dns(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolDnsRequest>) -> Json<McpResponse<String>> {
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||||
let rt = shell_escape(&req.record_type.unwrap_or_else(|| "A".to_string()));
|
let rt = req.record_type.unwrap_or_else(|| "A".to_string());
|
||||||
let d = shell_escape(&req.domain);
|
match tool_exec(&session.handle, &format!("dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null", req.domain, rt, rt, req.domain, rt, req.domain)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||||
match exec_on_session(&session.handle, &format!("dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null", d, rt, rt, d, rt, d)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_tool_whois(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
async fn handle_tool_whois(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||||
match exec_on_session(&session.handle, &format!("whois {} 2>&1 | head -80", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
match tool_exec(&session.handle, &format!("whois {} 2>&1 | head -80", req.target)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_tool_wol(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolWolRequest>) -> Json<McpResponse<String>> {
|
async fn handle_tool_wol(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolWolRequest>) -> Json<McpResponse<String>> {
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||||
let mac_clean = req.mac_address.replace([':', '-'], "");
|
let mac_clean = req.mac_address.replace([':', '-'], "");
|
||||||
let cmd = format!(r#"python3 -c "import socket;mac=bytes.fromhex({});pkt=b'\xff'*6+mac*16;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.setsockopt(socket.SOL_SOCKET,socket.SO_BROADCAST,1);s.sendto(pkt,('255.255.255.255',9));s.close();print('WoL sent to {}')" 2>&1"#, shell_escape(&mac_clean), shell_escape(&req.mac_address));
|
let cmd = format!(r#"python3 -c "import socket;mac=bytes.fromhex('{}');pkt=b'\xff'*6+mac*16;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.setsockopt(socket.SOL_SOCKET,socket.SO_BROADCAST,1);s.sendto(pkt,('255.255.255.255',9));s.close();print('WoL sent to {}')" 2>&1"#, mac_clean, req.mac_address);
|
||||||
match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
match tool_exec(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_tool_scan_network(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolScanNetworkRequest>) -> Json<McpResponse<serde_json::Value>> {
|
async fn handle_tool_scan_network(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolScanNetworkRequest>) -> Json<McpResponse<serde_json::Value>> {
|
||||||
@ -365,7 +308,7 @@ async fn handle_tool_subnet(_state: AxumState<Arc<McpServerState>>, Json(req): J
|
|||||||
async fn handle_tool_bandwidth(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionOnly>) -> Json<McpResponse<String>> {
|
async fn handle_tool_bandwidth(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionOnly>) -> Json<McpResponse<String>> {
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
||||||
let cmd = r#"if command -v speedtest-cli >/dev/null 2>&1; then speedtest-cli --simple 2>&1; elif command -v curl >/dev/null 2>&1; then curl -o /dev/null -w "Download: %{speed_download} bytes/sec\n" https://speed.cloudflare.com/__down?bytes=25000000 2>/dev/null; else echo "No speedtest tool found"; fi"#;
|
let cmd = r#"if command -v speedtest-cli >/dev/null 2>&1; then speedtest-cli --simple 2>&1; elif command -v curl >/dev/null 2>&1; then curl -o /dev/null -w "Download: %{speed_download} bytes/sec\n" https://speed.cloudflare.com/__down?bytes=25000000 2>/dev/null; else echo "No speedtest tool found"; fi"#;
|
||||||
match exec_on_session(&session.handle, cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
match tool_exec(&session.handle, cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_tool_keygen(_state: AxumState<Arc<McpServerState>>, Json(req): Json<ToolKeygenRequest>) -> Json<McpResponse<serde_json::Value>> {
|
async fn handle_tool_keygen(_state: AxumState<Arc<McpServerState>>, Json(req): Json<ToolKeygenRequest>) -> Json<McpResponse<serde_json::Value>> {
|
||||||
@ -382,166 +325,18 @@ async fn handle_tool_passgen(_state: AxumState<Arc<McpServerState>>, Json(req):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Docker handlers ──────────────────────────────────────────────────────────
|
async fn tool_exec(handle: &std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>, cmd: &str) -> Result<String, String> {
|
||||||
|
let mut channel = { let h = handle.lock().await; h.channel_open_session().await.map_err(|e| format!("Exec failed: {}", e))? };
|
||||||
#[derive(Deserialize)]
|
channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?;
|
||||||
struct DockerActionRequest { session_id: String, action: String, target: String }
|
let mut output = String::new();
|
||||||
|
loop {
|
||||||
#[derive(Deserialize)]
|
match channel.wait().await {
|
||||||
struct DockerListRequest { session_id: String }
|
Some(russh::ChannelMsg::Data { ref data }) => { if let Ok(t) = std::str::from_utf8(data.as_ref()) { output.push_str(t); } }
|
||||||
|
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
|
||||||
#[derive(Deserialize)]
|
_ => {}
|
||||||
struct DockerExecRequest { session_id: String, container: String, command: String }
|
|
||||||
|
|
||||||
async fn handle_docker_ps(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerListRequest>) -> Json<McpResponse<String>> {
|
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
||||||
match exec_on_session(&session.handle, "docker ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}' 2>&1").await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_docker_action(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerActionRequest>) -> Json<McpResponse<String>> {
|
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
||||||
let t = shell_escape(&req.target);
|
|
||||||
let cmd = match req.action.as_str() {
|
|
||||||
"start" => format!("docker start {} 2>&1", t),
|
|
||||||
"stop" => format!("docker stop {} 2>&1", t),
|
|
||||||
"restart" => format!("docker restart {} 2>&1", t),
|
|
||||||
"remove" => format!("docker rm -f {} 2>&1", t),
|
|
||||||
"logs" => format!("docker logs --tail 100 {} 2>&1", t),
|
|
||||||
"builder-prune" => "docker builder prune -f 2>&1".to_string(),
|
|
||||||
"system-prune" => "docker system prune -f 2>&1".to_string(),
|
|
||||||
_ => return err_response(format!("Unknown action: {}", req.action)),
|
|
||||||
};
|
|
||||||
match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_docker_exec(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<DockerExecRequest>) -> Json<McpResponse<String>> {
|
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
||||||
let cmd = format!("docker exec {} {} 2>&1", shell_escape(&req.container), shell_escape(&req.command));
|
|
||||||
match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Service/process handlers ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async fn handle_service_status(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
||||||
let t = shell_escape(&req.target);
|
|
||||||
match exec_on_session(&session.handle, &format!("systemctl status {} --no-pager 2>&1 || service {} status 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_process_list(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<ToolSessionTarget>) -> Json<McpResponse<String>> {
|
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
||||||
let filter = if req.target.is_empty() { "aux --sort=-%cpu | head -30".to_string() } else { format!("aux | grep -i {} | grep -v grep", shell_escape(&req.target)) };
|
|
||||||
match exec_on_session(&session.handle, &format!("ps {}", filter)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Git handlers ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct GitRequest { session_id: String, path: String }
|
|
||||||
|
|
||||||
async fn handle_git_status(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
|
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
||||||
match exec_on_session(&session.handle, &format!("cd {} && git status --short --branch 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_git_pull(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
|
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
||||||
match exec_on_session(&session.handle, &format!("cd {} && git pull 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_git_log(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<GitRequest>) -> Json<McpResponse<String>> {
|
|
||||||
let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) };
|
|
||||||
match exec_on_session(&session.handle, &format!("cd {} && git log --oneline -20 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Session creation handlers ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct SshConnectRequest {
|
|
||||||
hostname: String,
|
|
||||||
port: Option<u16>,
|
|
||||||
username: String,
|
|
||||||
password: Option<String>,
|
|
||||||
private_key_path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_ssh_connect(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<SshConnectRequest>) -> Json<McpResponse<String>> {
|
|
||||||
use crate::ssh::session::AuthMethod;
|
|
||||||
|
|
||||||
let port = req.port.unwrap_or(22);
|
|
||||||
let auth = if let Some(key_path) = req.private_key_path {
|
|
||||||
// Read key file
|
|
||||||
let pem = match std::fs::read_to_string(&key_path) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => return err_response(format!("Failed to read key file {}: {}", key_path, e)),
|
|
||||||
};
|
|
||||||
AuthMethod::Key { private_key_pem: pem, passphrase: req.password }
|
|
||||||
} else {
|
|
||||||
AuthMethod::Password(req.password.unwrap_or_default())
|
|
||||||
};
|
|
||||||
|
|
||||||
match state.ssh.connect(
|
|
||||||
state.app_handle.clone(),
|
|
||||||
&req.hostname,
|
|
||||||
port,
|
|
||||||
&req.username,
|
|
||||||
auth,
|
|
||||||
120, 40,
|
|
||||||
&state.sftp,
|
|
||||||
&state.scrollback,
|
|
||||||
&state.error_watcher,
|
|
||||||
).await {
|
|
||||||
Ok(session_id) => ok_response(session_id),
|
|
||||||
Err(e) => err_response(e),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(output)
|
||||||
// ── RDP interaction handlers ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct RdpClickRequest { session_id: String, x: u16, y: u16, button: Option<String> }
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct RdpTypeRequest { session_id: String, text: String }
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct RdpClipboardRequest { session_id: String, text: String }
|
|
||||||
|
|
||||||
async fn handle_rdp_click(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<RdpClickRequest>) -> Json<McpResponse<String>> {
|
|
||||||
use crate::rdp::input::mouse_flags;
|
|
||||||
let button_flag = match req.button.as_deref().unwrap_or("left") {
|
|
||||||
"right" => mouse_flags::BUTTON2,
|
|
||||||
"middle" => mouse_flags::BUTTON3,
|
|
||||||
_ => mouse_flags::BUTTON1,
|
|
||||||
};
|
|
||||||
// Move to position
|
|
||||||
if let Err(e) = state.rdp.send_mouse(&req.session_id, req.x, req.y, mouse_flags::MOVE) { return err_response(e); }
|
|
||||||
// Click down
|
|
||||||
if let Err(e) = state.rdp.send_mouse(&req.session_id, req.x, req.y, button_flag | mouse_flags::DOWN) { return err_response(e); }
|
|
||||||
// Click up
|
|
||||||
if let Err(e) = state.rdp.send_mouse(&req.session_id, req.x, req.y, button_flag) { return err_response(e); }
|
|
||||||
ok_response(format!("clicked ({}, {})", req.x, req.y))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_rdp_type(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<RdpTypeRequest>) -> Json<McpResponse<String>> {
|
|
||||||
// Set clipboard then simulate Ctrl+V to paste (most reliable for arbitrary text)
|
|
||||||
if let Err(e) = state.rdp.send_clipboard(&req.session_id, &req.text) { return err_response(e); }
|
|
||||||
// Small delay for clipboard to propagate, then Ctrl+V
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
||||||
// Ctrl down
|
|
||||||
let _ = state.rdp.send_key(&req.session_id, 0x001D, true);
|
|
||||||
// V down
|
|
||||||
let _ = state.rdp.send_key(&req.session_id, 0x002F, true);
|
|
||||||
// V up
|
|
||||||
let _ = state.rdp.send_key(&req.session_id, 0x002F, false);
|
|
||||||
// Ctrl up
|
|
||||||
let _ = state.rdp.send_key(&req.session_id, 0x001D, false);
|
|
||||||
ok_response(format!("typed {} chars via clipboard paste", req.text.len()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_rdp_clipboard(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<RdpClipboardRequest>) -> Json<McpResponse<String>> {
|
|
||||||
if let Err(e) = state.rdp.send_clipboard(&req.session_id, &req.text) { return err_response(e); }
|
|
||||||
ok_response("clipboard set".to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the MCP HTTP server and write the port to disk.
|
/// Start the MCP HTTP server and write the port to disk.
|
||||||
@ -550,22 +345,11 @@ pub async fn start_mcp_server(
|
|||||||
rdp: RdpService,
|
rdp: RdpService,
|
||||||
sftp: SftpService,
|
sftp: SftpService,
|
||||||
scrollback: ScrollbackRegistry,
|
scrollback: ScrollbackRegistry,
|
||||||
app_handle: tauri::AppHandle,
|
|
||||||
error_watcher: std::sync::Arc<crate::mcp::error_watcher::ErrorWatcher>,
|
|
||||||
) -> Result<u16, String> {
|
) -> Result<u16, String> {
|
||||||
// Generate a cryptographically random bearer token for authentication
|
let state = Arc::new(McpServerState { ssh, rdp, sftp, scrollback });
|
||||||
use rand::Rng;
|
|
||||||
let bearer_token: String = rand::rng()
|
|
||||||
.sample_iter(&rand::distr::Alphanumeric)
|
|
||||||
.take(64)
|
|
||||||
.map(char::from)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let state = Arc::new(McpServerState { ssh, rdp, sftp, scrollback, app_handle, error_watcher, bearer_token: bearer_token.clone() });
|
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/mcp/sessions", post(handle_list_sessions))
|
.route("/mcp/sessions", post(handle_list_sessions))
|
||||||
.route("/mcp/terminal/type", post(handle_terminal_type))
|
|
||||||
.route("/mcp/terminal/read", post(handle_terminal_read))
|
.route("/mcp/terminal/read", post(handle_terminal_read))
|
||||||
.route("/mcp/terminal/execute", post(handle_terminal_execute))
|
.route("/mcp/terminal/execute", post(handle_terminal_execute))
|
||||||
.route("/mcp/screenshot", post(handle_screenshot))
|
.route("/mcp/screenshot", post(handle_screenshot))
|
||||||
@ -583,19 +367,6 @@ pub async fn start_mcp_server(
|
|||||||
.route("/mcp/tool/bandwidth", post(handle_tool_bandwidth))
|
.route("/mcp/tool/bandwidth", post(handle_tool_bandwidth))
|
||||||
.route("/mcp/tool/keygen", post(handle_tool_keygen))
|
.route("/mcp/tool/keygen", post(handle_tool_keygen))
|
||||||
.route("/mcp/tool/passgen", post(handle_tool_passgen))
|
.route("/mcp/tool/passgen", post(handle_tool_passgen))
|
||||||
.route("/mcp/docker/ps", post(handle_docker_ps))
|
|
||||||
.route("/mcp/docker/action", post(handle_docker_action))
|
|
||||||
.route("/mcp/docker/exec", post(handle_docker_exec))
|
|
||||||
.route("/mcp/service/status", post(handle_service_status))
|
|
||||||
.route("/mcp/process/list", post(handle_process_list))
|
|
||||||
.route("/mcp/git/status", post(handle_git_status))
|
|
||||||
.route("/mcp/git/pull", post(handle_git_pull))
|
|
||||||
.route("/mcp/git/log", post(handle_git_log))
|
|
||||||
.route("/mcp/rdp/click", post(handle_rdp_click))
|
|
||||||
.route("/mcp/rdp/type", post(handle_rdp_type))
|
|
||||||
.route("/mcp/rdp/clipboard", post(handle_rdp_clipboard))
|
|
||||||
.route("/mcp/ssh/connect", post(handle_ssh_connect))
|
|
||||||
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware))
|
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await
|
let listener = TcpListener::bind("127.0.0.1:0").await
|
||||||
@ -606,23 +377,10 @@ pub async fn start_mcp_server(
|
|||||||
.port();
|
.port();
|
||||||
|
|
||||||
// Write port to well-known location
|
// Write port to well-known location
|
||||||
let data_dir = crate::data_directory();
|
let port_file = crate::data_directory().join("mcp-port");
|
||||||
let port_file = data_dir.join("mcp-port");
|
|
||||||
std::fs::write(&port_file, port.to_string())
|
std::fs::write(&port_file, port.to_string())
|
||||||
.map_err(|e| format!("Failed to write MCP port file: {}", e))?;
|
.map_err(|e| format!("Failed to write MCP port file: {}", e))?;
|
||||||
|
|
||||||
// Write bearer token to a separate file with restrictive permissions
|
|
||||||
let token_file = data_dir.join("mcp-token");
|
|
||||||
std::fs::write(&token_file, &bearer_token)
|
|
||||||
.map_err(|e| format!("Failed to write MCP token file: {}", e))?;
|
|
||||||
|
|
||||||
// Set owner-only read/write permissions (Unix)
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let _ = std::fs::set_permissions(&token_file, std::fs::Permissions::from_mode(0o600));
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
axum::serve(listener, app).await.ok();
|
axum::serve(listener, app).await.ok();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -89,14 +89,18 @@ impl PtyService {
|
|||||||
scrollback: &ScrollbackRegistry,
|
scrollback: &ScrollbackRegistry,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
wraith_log!("[PTY] Spawning shell: {} (session {})", shell_path, session_id);
|
|
||||||
let pty_system = native_pty_system();
|
let pty_system = native_pty_system();
|
||||||
|
|
||||||
let pair = pty_system
|
let pair = pty_system
|
||||||
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
||||||
.map_err(|e| format!("Failed to open PTY: {}", e))?;
|
.map_err(|e| format!("Failed to open PTY: {}", e))?;
|
||||||
|
|
||||||
let cmd = CommandBuilder::new(shell_path);
|
let mut cmd = CommandBuilder::new(shell_path);
|
||||||
|
|
||||||
|
// Auto-inject MCP server config so AI CLIs discover the bridge.
|
||||||
|
// Claude Code reads CLAUDE_MCP_SERVERS env var for server config.
|
||||||
|
let mcp_config = r#"{"wraith":{"command":"wraith-mcp-bridge","args":[]}}"#;
|
||||||
|
cmd.env("CLAUDE_MCP_SERVERS", mcp_config);
|
||||||
|
|
||||||
let child = pair.slave
|
let child = pair.slave
|
||||||
.spawn_command(cmd)
|
.spawn_command(cmd)
|
||||||
|
|||||||
@ -8,12 +8,11 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use tauri::Emitter;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::io::{AsyncRead, AsyncWrite};
|
use tokio::io::{AsyncRead, AsyncWrite};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
use ironrdp::connector::{self, ClientConnector, ConnectionResult, Credentials, DesktopSize};
|
use ironrdp::connector::{self, ClientConnector, ConnectionResult, Credentials, DesktopSize};
|
||||||
use ironrdp::graphics::image_processing::PixelFormat;
|
use ironrdp::graphics::image_processing::PixelFormat;
|
||||||
@ -63,47 +62,32 @@ enum InputEvent {
|
|||||||
pressed: bool,
|
pressed: bool,
|
||||||
},
|
},
|
||||||
Clipboard(String),
|
Clipboard(String),
|
||||||
Resize { width: u16, height: u16 },
|
|
||||||
Disconnect,
|
Disconnect,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dirty rectangle from the last GraphicsUpdate — used for partial frame transfer.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DirtyRect {
|
|
||||||
pub x: u16,
|
|
||||||
pub y: u16,
|
|
||||||
pub width: u16,
|
|
||||||
pub height: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RdpSessionHandle {
|
struct RdpSessionHandle {
|
||||||
id: String,
|
id: String,
|
||||||
hostname: String,
|
hostname: String,
|
||||||
width: u16,
|
width: u16,
|
||||||
height: u16,
|
height: u16,
|
||||||
/// Frame buffer: RDP thread writes via RwLock write, IPC reads via RwLock read.
|
frame_buffer: Arc<TokioMutex<Vec<u8>>>,
|
||||||
front_buffer: Arc<std::sync::RwLock<Vec<u8>>>,
|
|
||||||
/// Accumulated dirty region since last get_frame — union of all GraphicsUpdate rects.
|
|
||||||
dirty_region: Arc<std::sync::Mutex<Option<DirtyRect>>>,
|
|
||||||
frame_dirty: Arc<AtomicBool>,
|
frame_dirty: Arc<AtomicBool>,
|
||||||
input_tx: mpsc::UnboundedSender<InputEvent>,
|
input_tx: mpsc::UnboundedSender<InputEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RdpService {
|
pub struct RdpService {
|
||||||
sessions: Arc<DashMap<String, Arc<RdpSessionHandle>>>,
|
sessions: DashMap<String, Arc<RdpSessionHandle>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RdpService {
|
impl RdpService {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
sessions: Arc::new(DashMap::new()),
|
sessions: DashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn connect(&self, config: RdpConfig, app_handle: tauri::AppHandle) -> Result<String, String> {
|
pub fn connect(&self, config: RdpConfig) -> Result<String, String> {
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
wraith_log!("[RDP] Connecting to {}:{} as {} (session {})", config.hostname, config.port, config.username, session_id);
|
|
||||||
let width = config.width;
|
let width = config.width;
|
||||||
let height = config.height;
|
let height = config.height;
|
||||||
let hostname = config.hostname.clone();
|
let hostname = config.hostname.clone();
|
||||||
@ -113,8 +97,7 @@ impl RdpService {
|
|||||||
for pixel in initial_buf.chunks_exact_mut(4) {
|
for pixel in initial_buf.chunks_exact_mut(4) {
|
||||||
pixel[3] = 255;
|
pixel[3] = 255;
|
||||||
}
|
}
|
||||||
let front_buffer = Arc::new(std::sync::RwLock::new(initial_buf));
|
let frame_buffer = Arc::new(TokioMutex::new(initial_buf));
|
||||||
let dirty_region = Arc::new(std::sync::Mutex::new(None));
|
|
||||||
let frame_dirty = Arc::new(AtomicBool::new(false));
|
let frame_dirty = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
let (input_tx, input_rx) = mpsc::unbounded_channel();
|
let (input_tx, input_rx) = mpsc::unbounded_channel();
|
||||||
@ -124,8 +107,7 @@ impl RdpService {
|
|||||||
hostname: hostname.clone(),
|
hostname: hostname.clone(),
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
front_buffer: front_buffer.clone(),
|
frame_buffer: frame_buffer.clone(),
|
||||||
dirty_region: dirty_region.clone(),
|
|
||||||
frame_dirty: frame_dirty.clone(),
|
frame_dirty: frame_dirty.clone(),
|
||||||
input_tx,
|
input_tx,
|
||||||
});
|
});
|
||||||
@ -137,7 +119,6 @@ impl RdpService {
|
|||||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
@ -172,14 +153,11 @@ impl RdpService {
|
|||||||
if let Err(e) = run_active_session(
|
if let Err(e) = run_active_session(
|
||||||
connection_result,
|
connection_result,
|
||||||
framed,
|
framed,
|
||||||
front_buffer,
|
frame_buffer,
|
||||||
dirty_region,
|
|
||||||
frame_dirty,
|
frame_dirty,
|
||||||
input_rx,
|
input_rx,
|
||||||
width as u16,
|
width as u16,
|
||||||
height as u16,
|
height as u16,
|
||||||
app_handle,
|
|
||||||
sid.clone(),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@ -188,18 +166,6 @@ impl RdpService {
|
|||||||
info!("RDP session {} ended", sid);
|
info!("RDP session {} ended", sid);
|
||||||
sessions_ref.remove(&sid);
|
sessions_ref.remove(&sid);
|
||||||
});
|
});
|
||||||
}));
|
|
||||||
if let Err(panic) = result {
|
|
||||||
let msg = if let Some(s) = panic.downcast_ref::<String>() {
|
|
||||||
s.clone()
|
|
||||||
} else if let Some(s) = panic.downcast_ref::<&str>() {
|
|
||||||
s.to_string()
|
|
||||||
} else {
|
|
||||||
"unknown panic".to_string()
|
|
||||||
};
|
|
||||||
let _ = crate::write_log(&crate::data_directory().join("wraith.log"), &format!("RDP thread PANIC: {}", msg));
|
|
||||||
// ready_tx is dropped here, which triggers the "died unexpectedly" error
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
match ready_rx.recv() {
|
match ready_rx.recv() {
|
||||||
@ -210,72 +176,42 @@ impl RdpService {
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
self.sessions.remove(&session_id);
|
self.sessions.remove(&session_id);
|
||||||
return Err("RDP connection thread panicked — check wraith.log for details".into());
|
return Err("RDP connection thread died unexpectedly".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(session_id)
|
Ok(session_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the dirty region since the last call. Returns (region_metadata, pixel_bytes).
|
pub async fn get_frame(&self, session_id: &str) -> Result<String, String> {
|
||||||
/// The pixel bytes contain only the dirty rectangle in row-major RGBA order.
|
|
||||||
/// If nothing changed, returns empty bytes. If the dirty region covers >50% of the
|
|
||||||
/// frame, falls back to full frame for efficiency (avoids row-by-row extraction).
|
|
||||||
pub fn get_frame(&self, session_id: &str) -> Result<(Option<DirtyRect>, Vec<u8>), String> {
|
|
||||||
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||||
if !handle.frame_dirty.swap(false, Ordering::Acquire) {
|
if !handle.frame_dirty.swap(false, Ordering::Relaxed) {
|
||||||
return Ok((None, Vec::new()));
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
let buf = handle.frame_buffer.lock().await;
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&*buf);
|
||||||
|
Ok(encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
let region = handle.dirty_region.lock().unwrap_or_else(|e| e.into_inner()).take();
|
pub async fn get_frame_raw(&self, session_id: &str) -> Result<Vec<u8>, String> {
|
||||||
let buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let stride = handle.width as usize * 4;
|
|
||||||
let total_pixels = handle.width as usize * handle.height as usize;
|
|
||||||
|
|
||||||
match region {
|
|
||||||
Some(rect) if (rect.width as usize * rect.height as usize) < total_pixels / 2 => {
|
|
||||||
// Partial: extract only the dirty rectangle
|
|
||||||
let rw = rect.width as usize;
|
|
||||||
let rh = rect.height as usize;
|
|
||||||
let rx = rect.x as usize;
|
|
||||||
let ry = rect.y as usize;
|
|
||||||
let mut out = Vec::with_capacity(rw * rh * 4);
|
|
||||||
for row in ry..ry + rh {
|
|
||||||
let start = row * stride + rx * 4;
|
|
||||||
let end = start + rw * 4;
|
|
||||||
if end <= buf.len() {
|
|
||||||
out.extend_from_slice(&buf[start..end]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok((Some(rect), out))
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Full frame: dirty region covers most of the screen or is missing
|
|
||||||
Ok((None, buf.clone()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_frame_raw(&self, session_id: &str) -> Result<Vec<u8>, String> {
|
|
||||||
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||||
let buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner());
|
let buf = handle.frame_buffer.lock().await;
|
||||||
Ok(buf.clone())
|
Ok(buf.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Capture the current RDP frame as a base64-encoded PNG.
|
/// Capture the current RDP frame as a base64-encoded PNG.
|
||||||
pub fn screenshot_png_base64(&self, session_id: &str) -> Result<String, String> {
|
pub async fn screenshot_png_base64(&self, session_id: &str) -> Result<String, String> {
|
||||||
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||||
let width = handle.width as u32;
|
let width = handle.width as u32;
|
||||||
let height = handle.height as u32;
|
let height = handle.height as u32;
|
||||||
let buf = handle.front_buffer.read().unwrap_or_else(|e| e.into_inner());
|
let buf = handle.frame_buffer.lock().await;
|
||||||
|
|
||||||
// Encode RGBA raw bytes to PNG (fast compression for speed)
|
// Encode RGBA raw bytes to PNG
|
||||||
let mut png_data = Vec::new();
|
let mut png_data = Vec::new();
|
||||||
{
|
{
|
||||||
let mut encoder = png::Encoder::new(&mut png_data, width, height);
|
let mut encoder = png::Encoder::new(&mut png_data, width, height);
|
||||||
encoder.set_color(png::ColorType::Rgba);
|
encoder.set_color(png::ColorType::Rgba);
|
||||||
encoder.set_depth(png::BitDepth::Eight);
|
encoder.set_depth(png::BitDepth::Eight);
|
||||||
encoder.set_compression(png::Compression::Fast);
|
|
||||||
let mut writer = encoder.write_header()
|
let mut writer = encoder.write_header()
|
||||||
.map_err(|e| format!("PNG header error: {}", e))?;
|
.map_err(|e| format!("PNG header error: {}", e))?;
|
||||||
writer.write_image_data(&buf)
|
writer.write_image_data(&buf)
|
||||||
@ -300,19 +236,6 @@ impl RdpService {
|
|||||||
handle.input_tx.send(InputEvent::Key { scancode, pressed }).map_err(|_| format!("RDP session {} input channel closed", session_id))
|
handle.input_tx.send(InputEvent::Key { scancode, pressed }).map_err(|_| format!("RDP session {} input channel closed", session_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn force_refresh(&self, session_id: &str) -> Result<(), String> {
|
|
||||||
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
|
||||||
// Clear any accumulated dirty region so get_frame returns the full buffer
|
|
||||||
*handle.dirty_region.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
|
||||||
handle.frame_dirty.store(true, Ordering::Release);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resize(&self, session_id: &str, width: u16, height: u16) -> Result<(), String> {
|
|
||||||
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
|
||||||
handle.input_tx.send(InputEvent::Resize { width, height }).map_err(|_| format!("RDP session {} input channel closed", session_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
|
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
|
||||||
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
let handle = self.sessions.get(session_id).ok_or_else(|| format!("RDP session {} not found", session_id))?;
|
||||||
let _ = handle.input_tx.send(InputEvent::Disconnect);
|
let _ = handle.input_tx.send(InputEvent::Disconnect);
|
||||||
@ -332,7 +255,7 @@ impl RdpService {
|
|||||||
|
|
||||||
impl Clone for RdpService {
|
impl Clone for RdpService {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
Self { sessions: self.sessions.clone() }
|
unreachable!("RdpService should not be cloned — access via State<AppState>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,11 +289,7 @@ fn build_connector_config(config: &RdpConfig) -> Result<connector::Config, Strin
|
|||||||
request_data: None,
|
request_data: None,
|
||||||
autologon: false,
|
autologon: false,
|
||||||
enable_audio_playback: false,
|
enable_audio_playback: false,
|
||||||
performance_flags: PerformanceFlags::DISABLE_WALLPAPER
|
performance_flags: PerformanceFlags::default(),
|
||||||
| PerformanceFlags::DISABLE_MENUANIMATIONS
|
|
||||||
| PerformanceFlags::DISABLE_CURSOR_SHADOW
|
|
||||||
| PerformanceFlags::ENABLE_FONT_SMOOTHING
|
|
||||||
| PerformanceFlags::ENABLE_DESKTOP_COMPOSITION,
|
|
||||||
desktop_scale_factor: 0,
|
desktop_scale_factor: 0,
|
||||||
hardware_id: None,
|
hardware_id: None,
|
||||||
license_cache: None,
|
license_cache: None,
|
||||||
@ -400,7 +319,7 @@ async fn establish_connection(config: connector::Config, hostname: &str, port: u
|
|||||||
Ok((connection_result, upgraded_framed))
|
Ok((connection_result, upgraded_framed))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, front_buffer: Arc<std::sync::RwLock<Vec<u8>>>, dirty_region: Arc<std::sync::Mutex<Option<DirtyRect>>>, frame_dirty: Arc<AtomicBool>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, mut width: u16, mut height: u16, app_handle: tauri::AppHandle, session_id: String) -> Result<(), String> {
|
async fn run_active_session(connection_result: ConnectionResult, framed: UpgradedFramed, frame_buffer: Arc<TokioMutex<Vec<u8>>>, frame_dirty: Arc<AtomicBool>, mut input_rx: mpsc::UnboundedReceiver<InputEvent>, width: u16, height: u16) -> Result<(), String> {
|
||||||
let (mut reader, mut writer) = split_tokio_framed(framed);
|
let (mut reader, mut writer) = split_tokio_framed(framed);
|
||||||
let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height);
|
let mut image = DecodedImage::new(PixelFormat::RgbA32, width, height);
|
||||||
let mut active_stage = ActiveStage::new(connection_result);
|
let mut active_stage = ActiveStage::new(connection_result);
|
||||||
@ -452,68 +371,17 @@ async fn run_active_session(connection_result: ConnectionResult, framed: Upgrade
|
|||||||
}
|
}
|
||||||
all_outputs
|
all_outputs
|
||||||
}
|
}
|
||||||
Some(InputEvent::Resize { width: new_w, height: new_h }) => {
|
|
||||||
// Ensure dimensions are within RDP spec (200-8192, even width)
|
|
||||||
let w = (new_w.max(200).min(8192) & !1) as u32;
|
|
||||||
let h = new_h.max(200).min(8192) as u32;
|
|
||||||
if let Some(Ok(resize_frame)) = active_stage.encode_resize(w, h, None, None) {
|
|
||||||
writer.write_all(&resize_frame).await.map_err(|e| format!("Failed to send resize: {}", e))?;
|
|
||||||
// Reallocate image and front buffer for new dimensions
|
|
||||||
image = DecodedImage::new(PixelFormat::RgbA32, w as u16, h as u16);
|
|
||||||
let buf_size = w as usize * h as usize * 4;
|
|
||||||
let mut new_buf = vec![0u8; buf_size];
|
|
||||||
for pixel in new_buf.chunks_exact_mut(4) { pixel[3] = 255; }
|
|
||||||
*front_buffer.write().unwrap_or_else(|e| e.into_inner()) = new_buf;
|
|
||||||
width = w as u16;
|
|
||||||
height = h as u16;
|
|
||||||
info!("RDP session {} resized to {}x{}", session_id, width, height);
|
|
||||||
}
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
for out in outputs {
|
for out in outputs {
|
||||||
match out {
|
match out {
|
||||||
ActiveStageOutput::ResponseFrame(frame) => { writer.write_all(&frame).await.map_err(|e| format!("Failed to write RDP response frame: {}", e))?; }
|
ActiveStageOutput::ResponseFrame(frame) => { writer.write_all(&frame).await.map_err(|e| format!("Failed to write RDP response frame: {}", e))?; }
|
||||||
ActiveStageOutput::GraphicsUpdate(region) => {
|
ActiveStageOutput::GraphicsUpdate(_region) => {
|
||||||
let rx = region.left as usize;
|
let mut buf = frame_buffer.lock().await;
|
||||||
let ry = region.top as usize;
|
|
||||||
let rr = (region.right as usize).saturating_add(1).min(width as usize);
|
|
||||||
let rb = (region.bottom as usize).saturating_add(1).min(height as usize);
|
|
||||||
let stride = width as usize * 4;
|
|
||||||
|
|
||||||
// Copy only the dirty rectangle rows from decoded image → front buffer
|
|
||||||
{
|
|
||||||
let src = image.data();
|
let src = image.data();
|
||||||
let mut front = front_buffer.write().unwrap_or_else(|e| e.into_inner());
|
if src.len() == buf.len() { buf.copy_from_slice(src); } else { *buf = src.to_vec(); }
|
||||||
for row in ry..rb {
|
frame_dirty.store(true, Ordering::Relaxed);
|
||||||
let src_start = row * stride + rx * 4;
|
|
||||||
let src_end = row * stride + rr * 4;
|
|
||||||
if src_end <= src.len() && src_end <= front.len() {
|
|
||||||
front[src_start..src_end].copy_from_slice(&src[src_start..src_end]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accumulate dirty region (union of all rects since last get_frame)
|
|
||||||
{
|
|
||||||
let new_rect = DirtyRect { x: rx as u16, y: ry as u16, width: (rr - rx) as u16, height: (rb - ry) as u16 };
|
|
||||||
let mut dr = dirty_region.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
*dr = Some(match dr.take() {
|
|
||||||
None => new_rect,
|
|
||||||
Some(prev) => {
|
|
||||||
let x = prev.x.min(new_rect.x);
|
|
||||||
let y = prev.y.min(new_rect.y);
|
|
||||||
let r = (prev.x + prev.width).max(new_rect.x + new_rect.width);
|
|
||||||
let b = (prev.y + prev.height).max(new_rect.y + new_rect.height);
|
|
||||||
DirtyRect { x, y, width: r - x, height: b - y }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
frame_dirty.store(true, Ordering::Release);
|
|
||||||
let _ = app_handle.emit(&format!("rdp:frame:{}", session_id), ());
|
|
||||||
}
|
}
|
||||||
ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); }
|
ActiveStageOutput::Terminate(reason) => { info!("RDP session terminated: {:?}", reason); return Ok(()); }
|
||||||
ActiveStageOutput::DeactivateAll(_) => { warn!("RDP server sent DeactivateAll — reconnection not yet implemented"); return Ok(()); }
|
ActiveStageOutput::DeactivateAll(_) => { warn!("RDP server sent DeactivateAll — reconnection not yet implemented"); return Ok(()); }
|
||||||
|
|||||||
@ -12,7 +12,6 @@ use serde::Serialize;
|
|||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
use crate::ssh::session::SshClient;
|
use crate::ssh::session::SshClient;
|
||||||
use crate::utils::shell_escape;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -64,44 +63,18 @@ fn service_name(port: u16) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate that `subnet` contains exactly three dot-separated octet groups,
|
|
||||||
/// each consisting only of 1–3 ASCII digits (e.g. "192.168.1").
|
|
||||||
/// Returns an error string if the format is invalid.
|
|
||||||
fn validate_subnet(subnet: &str) -> Result<(), String> {
|
|
||||||
let parts: Vec<&str> = subnet.split('.').collect();
|
|
||||||
if parts.len() != 3 {
|
|
||||||
return Err(format!(
|
|
||||||
"Invalid subnet '{}': expected three octets (e.g. 192.168.1)",
|
|
||||||
subnet
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for part in &parts {
|
|
||||||
if part.is_empty() || part.len() > 3 || !part.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
return Err(format!(
|
|
||||||
"Invalid subnet '{}': each octet must be 1–3 decimal digits",
|
|
||||||
subnet
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Discover hosts on the remote network using ARP table and ping sweep.
|
/// Discover hosts on the remote network using ARP table and ping sweep.
|
||||||
pub async fn scan_network(
|
pub async fn scan_network(
|
||||||
handle: &Arc<TokioMutex<Handle<SshClient>>>,
|
handle: &Arc<TokioMutex<Handle<SshClient>>>,
|
||||||
subnet: &str,
|
subnet: &str,
|
||||||
) -> Result<Vec<DiscoveredHost>, String> {
|
) -> Result<Vec<DiscoveredHost>, String> {
|
||||||
// Validate subnet format before using it in remote shell commands.
|
|
||||||
validate_subnet(subnet)?;
|
|
||||||
|
|
||||||
// Script that works on Linux and macOS:
|
// Script that works on Linux and macOS:
|
||||||
// 1. Ping sweep the subnet to populate ARP cache
|
// 1. Ping sweep the subnet to populate ARP cache
|
||||||
// 2. Read ARP table for IP/MAC pairs
|
// 2. Read ARP table for IP/MAC pairs
|
||||||
// 3. Try reverse DNS for hostnames
|
// 3. Try reverse DNS for hostnames
|
||||||
let escaped_subnet = shell_escape(subnet);
|
|
||||||
let script = format!(r#"
|
let script = format!(r#"
|
||||||
OS=$(uname -s 2>/dev/null)
|
OS=$(uname -s 2>/dev/null)
|
||||||
SUBNET={escaped_subnet}
|
SUBNET="{subnet}"
|
||||||
|
|
||||||
# Ping sweep (background, fast)
|
# Ping sweep (background, fast)
|
||||||
if [ "$OS" = "Linux" ]; then
|
if [ "$OS" = "Linux" ]; then
|
||||||
@ -178,12 +151,6 @@ pub async fn scan_ports(
|
|||||||
target: &str,
|
target: &str,
|
||||||
ports: &[u16],
|
ports: &[u16],
|
||||||
) -> Result<Vec<PortResult>, String> {
|
) -> Result<Vec<PortResult>, String> {
|
||||||
// Validate target — /dev/tcp requires a bare hostname/IP, not a shell-quoted value.
|
|
||||||
// Only allow alphanumeric, dots, hyphens, and colons (for IPv6).
|
|
||||||
if !target.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':') {
|
|
||||||
return Err(format!("Invalid target for port scan: {}", target));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use bash /dev/tcp for port scanning — no nmap required
|
// Use bash /dev/tcp for port scanning — no nmap required
|
||||||
let port_checks: Vec<String> = ports.iter()
|
let port_checks: Vec<String> = ports.iter()
|
||||||
.map(|p| format!(
|
.map(|p| format!(
|
||||||
|
|||||||
@ -8,7 +8,6 @@ use crate::db::Database;
|
|||||||
///
|
///
|
||||||
/// All operations acquire the shared DB mutex for their duration and
|
/// All operations acquire the shared DB mutex for their duration and
|
||||||
/// return immediately — no async needed for a local SQLite store.
|
/// return immediately — no async needed for a local SQLite store.
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SettingsService {
|
pub struct SettingsService {
|
||||||
db: Database,
|
db: Database,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
//! provides all file operations needed by the frontend.
|
//! provides all file operations needed by the frontend.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, UNIX_EPOCH};
|
||||||
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use log::{debug, info};
|
use log::{debug, info};
|
||||||
@ -34,6 +35,9 @@ pub struct FileEntry {
|
|||||||
|
|
||||||
/// Format a Unix timestamp (seconds since epoch) as "Mon DD HH:MM".
|
/// Format a Unix timestamp (seconds since epoch) as "Mon DD HH:MM".
|
||||||
fn format_mtime(unix_secs: u32) -> String {
|
fn format_mtime(unix_secs: u32) -> String {
|
||||||
|
// Build a SystemTime from the raw epoch value.
|
||||||
|
let st = UNIX_EPOCH + Duration::from_secs(unix_secs as u64);
|
||||||
|
|
||||||
// Convert to seconds-since-epoch for manual formatting. We avoid pulling
|
// Convert to seconds-since-epoch for manual formatting. We avoid pulling
|
||||||
// in chrono just for this; a simple manual decomposition is sufficient for
|
// in chrono just for this; a simple manual decomposition is sufficient for
|
||||||
// the "Mar 17 14:30" display format expected by the frontend.
|
// the "Mar 17 14:30" display format expected by the frontend.
|
||||||
@ -50,10 +54,12 @@ fn format_mtime(unix_secs: u32) -> String {
|
|||||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||||
let doe = z - era * 146_097;
|
let doe = z - era * 146_097;
|
||||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
let mp = (5 * doy + 2) / 153;
|
let mp = (5 * doy + 2) / 153;
|
||||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let _y = if m <= 2 { y + 1 } else { y };
|
||||||
|
|
||||||
let month = match m {
|
let month = match m {
|
||||||
1 => "Jan",
|
1 => "Jan",
|
||||||
@ -71,6 +77,9 @@ fn format_mtime(unix_secs: u32) -> String {
|
|||||||
_ => "???",
|
_ => "???",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Suppress unused variable warning — st is only used as a sanity anchor.
|
||||||
|
let _ = st;
|
||||||
|
|
||||||
format!("{} {:2} {:02}:{:02}", month, d, hours, minutes)
|
format!("{} {:2} {:02}:{:02}", month, d, hours, minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,13 +99,13 @@ pub struct SftpService {
|
|||||||
/// One `SftpSession` per SSH session, behind a mutex so async commands can
|
/// One `SftpSession` per SSH session, behind a mutex so async commands can
|
||||||
/// take a shared reference to the `SftpService` and still mutably borrow
|
/// take a shared reference to the `SftpService` and still mutably borrow
|
||||||
/// individual sessions.
|
/// individual sessions.
|
||||||
clients: Arc<DashMap<String, Arc<TokioMutex<SftpSession>>>>,
|
clients: DashMap<String, Arc<TokioMutex<SftpSession>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SftpService {
|
impl SftpService {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
clients: Arc::new(DashMap::new()),
|
clients: DashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,7 +319,7 @@ impl SftpService {
|
|||||||
) -> Result<Arc<TokioMutex<SftpSession>>, String> {
|
) -> Result<Arc<TokioMutex<SftpSession>>, String> {
|
||||||
self.clients
|
self.clients
|
||||||
.get(session_id)
|
.get(session_id)
|
||||||
.map(|r| r.value().clone())
|
.map(|r| r.clone())
|
||||||
.ok_or_else(|| format!("No SFTP client for session {}", session_id))
|
.ok_or_else(|| format!("No SFTP client for session {}", session_id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,6 @@ use russh::ChannelMsg;
|
|||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
use crate::ssh::session::SshClient;
|
use crate::ssh::session::SshClient;
|
||||||
|
|
||||||
@ -40,15 +39,13 @@ impl CwdTracker {
|
|||||||
/// Spawn a background tokio task that polls `pwd` every 2 seconds on a
|
/// Spawn a background tokio task that polls `pwd` every 2 seconds on a
|
||||||
/// separate exec channel.
|
/// separate exec channel.
|
||||||
///
|
///
|
||||||
/// The task runs until cancelled via the `CancellationToken`, or until the
|
/// The task runs until the SSH connection is closed or the channel cannot
|
||||||
/// SSH connection is closed or the channel cannot be opened.
|
/// be opened. CWD changes are emitted as `ssh:cwd:{session_id}` events.
|
||||||
/// CWD changes are emitted as `ssh:cwd:{session_id}` events.
|
|
||||||
pub fn start(
|
pub fn start(
|
||||||
&self,
|
&self,
|
||||||
handle: Arc<TokioMutex<Handle<SshClient>>>,
|
handle: Arc<TokioMutex<Handle<SshClient>>>,
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
cancel: CancellationToken,
|
|
||||||
) {
|
) {
|
||||||
let sender = self._sender.clone();
|
let sender = self._sender.clone();
|
||||||
|
|
||||||
@ -59,10 +56,6 @@ impl CwdTracker {
|
|||||||
let mut previous_cwd = String::new();
|
let mut previous_cwd = String::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if cancel.is_cancelled() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open a fresh exec channel for each `pwd` invocation.
|
// Open a fresh exec channel for each `pwd` invocation.
|
||||||
// Some SSH servers do not allow multiple exec requests on a
|
// Some SSH servers do not allow multiple exec requests on a
|
||||||
// single channel, so we open a new one each time.
|
// single channel, so we open a new one each time.
|
||||||
@ -126,11 +119,8 @@ impl CwdTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait 2 seconds before the next poll, or cancel.
|
// Wait 2 seconds before the next poll.
|
||||||
tokio::select! {
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||||
_ = tokio::time::sleep(tokio::time::Duration::from_secs(2)) => {}
|
|
||||||
_ = cancel.cancelled() => { break; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("CWD tracker for session {} stopped", session_id);
|
debug!("CWD tracker for session {} stopped", session_id);
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
//! Shared SSH exec-channel helper used by commands, MCP handlers, and tools.
|
|
||||||
//!
|
|
||||||
//! Opens a one-shot exec channel on an existing SSH handle, runs `cmd`, collects
|
|
||||||
//! all stdout/stderr, and returns it as a `String`. The caller is responsible
|
|
||||||
//! for ensuring the session is still alive.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
|
||||||
|
|
||||||
use crate::ssh::session::SshClient;
|
|
||||||
|
|
||||||
/// Execute `cmd` on a separate exec channel and return all output as a `String`.
|
|
||||||
///
|
|
||||||
/// Locks the handle for only as long as it takes to open the channel, then
|
|
||||||
/// releases it before reading — this avoids holding the lock while waiting on
|
|
||||||
/// remote I/O.
|
|
||||||
pub async fn exec_on_session(
|
|
||||||
handle: &Arc<TokioMutex<russh::client::Handle<SshClient>>>,
|
|
||||||
cmd: &str,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let mut channel = {
|
|
||||||
let h = handle.lock().await;
|
|
||||||
h.channel_open_session()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Exec channel failed: {}", e))?
|
|
||||||
};
|
|
||||||
|
|
||||||
channel
|
|
||||||
.exec(true, cmd)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Exec failed: {}", e))?;
|
|
||||||
|
|
||||||
let mut output = String::new();
|
|
||||||
loop {
|
|
||||||
match channel.wait().await {
|
|
||||||
Some(russh::ChannelMsg::Data { ref data }) => {
|
|
||||||
if let Ok(text) = std::str::from_utf8(data.as_ref()) {
|
|
||||||
output.push_str(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(russh::ChannelMsg::Eof)
|
|
||||||
| Some(russh::ChannelMsg::Close)
|
|
||||||
| None => break,
|
|
||||||
Some(russh::ChannelMsg::ExitStatus { .. }) => {}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
@ -2,4 +2,3 @@ pub mod session;
|
|||||||
pub mod host_key;
|
pub mod host_key;
|
||||||
pub mod cwd;
|
pub mod cwd;
|
||||||
pub mod monitor;
|
pub mod monitor;
|
||||||
pub mod exec;
|
|
||||||
|
|||||||
@ -6,13 +6,11 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use log::warn;
|
|
||||||
use russh::client::Handle;
|
use russh::client::Handle;
|
||||||
use russh::ChannelMsg;
|
use russh::ChannelMsg;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
use crate::ssh::session::SshClient;
|
use crate::ssh::session::SshClient;
|
||||||
|
|
||||||
@ -32,53 +30,26 @@ pub struct SystemStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a background task that polls system stats every 5 seconds.
|
/// Spawn a background task that polls system stats every 5 seconds.
|
||||||
///
|
|
||||||
/// The task runs until cancelled via the `CancellationToken`, or until the
|
|
||||||
/// SSH connection is closed.
|
|
||||||
pub fn start_monitor(
|
pub fn start_monitor(
|
||||||
handle: Arc<TokioMutex<Handle<SshClient>>>,
|
handle: Arc<TokioMutex<Handle<SshClient>>>,
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
cancel: CancellationToken,
|
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Brief delay to let the shell start up
|
// Brief delay to let the shell start up
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||||
|
|
||||||
let mut consecutive_timeouts: u32 = 0;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if cancel.is_cancelled() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let stats = collect_stats(&handle).await;
|
let stats = collect_stats(&handle).await;
|
||||||
|
|
||||||
match stats {
|
if let Some(stats) = stats {
|
||||||
Some(stats) => {
|
|
||||||
consecutive_timeouts = 0;
|
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
&format!("ssh:monitor:{}", session_id),
|
&format!("ssh:monitor:{}", session_id),
|
||||||
&stats,
|
&stats,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
None => {
|
|
||||||
consecutive_timeouts += 1;
|
|
||||||
if consecutive_timeouts >= 3 {
|
|
||||||
warn!(
|
|
||||||
"SSH monitor for session {}: 3 consecutive failures, stopping",
|
|
||||||
session_id
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait 5 seconds before the next poll, or cancel.
|
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||||
tokio::select! {
|
|
||||||
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {}
|
|
||||||
_ = cancel.cancelled() => { break; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -154,24 +125,7 @@ fn parse_stats(raw: &str) -> Option<SystemStats> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a command on a separate exec channel with a 10-second timeout.
|
|
||||||
async fn exec_command(handle: &Arc<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> {
|
async fn exec_command(handle: &Arc<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> {
|
||||||
let result = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(10),
|
|
||||||
exec_command_inner(handle, cmd),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(output) => output,
|
|
||||||
Err(_) => {
|
|
||||||
warn!("SSH monitor exec_command timed out after 10s");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn exec_command_inner(handle: &Arc<TokioMutex<Handle<SshClient>>>, cmd: &str) -> Option<String> {
|
|
||||||
let mut channel = {
|
let mut channel = {
|
||||||
let h = handle.lock().await;
|
let h = handle.lock().await;
|
||||||
h.channel_open_session().await.ok()?
|
h.channel_open_session().await.ok()?
|
||||||
|
|||||||
@ -17,7 +17,6 @@ use crate::mcp::error_watcher::ErrorWatcher;
|
|||||||
use crate::sftp::SftpService;
|
use crate::sftp::SftpService;
|
||||||
use crate::ssh::cwd::CwdTracker;
|
use crate::ssh::cwd::CwdTracker;
|
||||||
use crate::ssh::host_key::{HostKeyResult, HostKeyStore};
|
use crate::ssh::host_key::{HostKeyResult, HostKeyStore};
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
pub enum AuthMethod {
|
pub enum AuthMethod {
|
||||||
Password(String),
|
Password(String),
|
||||||
@ -48,7 +47,6 @@ pub struct SshSession {
|
|||||||
pub handle: Arc<TokioMutex<Handle<SshClient>>>,
|
pub handle: Arc<TokioMutex<Handle<SshClient>>>,
|
||||||
pub command_tx: mpsc::UnboundedSender<ChannelCommand>,
|
pub command_tx: mpsc::UnboundedSender<ChannelCommand>,
|
||||||
pub cwd_tracker: Option<CwdTracker>,
|
pub cwd_tracker: Option<CwdTracker>,
|
||||||
pub cancel_token: CancellationToken,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SshClient {
|
pub struct SshClient {
|
||||||
@ -78,18 +76,17 @@ impl client::Handler for SshClient {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SshService {
|
pub struct SshService {
|
||||||
sessions: Arc<DashMap<String, Arc<SshSession>>>,
|
sessions: DashMap<String, Arc<SshSession>>,
|
||||||
db: Database,
|
db: Database,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SshService {
|
impl SshService {
|
||||||
pub fn new(db: Database) -> Self {
|
pub fn new(db: Database) -> Self {
|
||||||
Self { sessions: Arc::new(DashMap::new()), db }
|
Self { sessions: DashMap::new(), db }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn connect(&self, app_handle: AppHandle, hostname: &str, port: u16, username: &str, auth: AuthMethod, cols: u32, rows: u32, sftp_service: &SftpService, scrollback: &ScrollbackRegistry, error_watcher: &ErrorWatcher) -> Result<String, String> {
|
pub async fn connect(&self, app_handle: AppHandle, hostname: &str, port: u16, username: &str, auth: AuthMethod, cols: u32, rows: u32, sftp_service: &SftpService, scrollback: &ScrollbackRegistry, error_watcher: &ErrorWatcher) -> Result<String, String> {
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
wraith_log!("[SSH] Connecting to {}:{} as {} (session {})", hostname, port, username, session_id);
|
|
||||||
let config = Arc::new(russh::client::Config::default());
|
let config = Arc::new(russh::client::Config::default());
|
||||||
let handler = SshClient { host_key_store: HostKeyStore::new(self.db.clone()), hostname: hostname.to_string(), port };
|
let handler = SshClient { host_key_store: HostKeyStore::new(self.db.clone()), hostname: hostname.to_string(), port };
|
||||||
|
|
||||||
@ -137,11 +134,10 @@ impl SshService {
|
|||||||
let channel_id = channel.id();
|
let channel_id = channel.id();
|
||||||
let handle = Arc::new(TokioMutex::new(handle));
|
let handle = Arc::new(TokioMutex::new(handle));
|
||||||
let (command_tx, mut command_rx) = mpsc::unbounded_channel::<ChannelCommand>();
|
let (command_tx, mut command_rx) = mpsc::unbounded_channel::<ChannelCommand>();
|
||||||
let cancel_token = CancellationToken::new();
|
|
||||||
let cwd_tracker = CwdTracker::new();
|
let cwd_tracker = CwdTracker::new();
|
||||||
cwd_tracker.start(handle.clone(), app_handle.clone(), session_id.clone(), cancel_token.clone());
|
cwd_tracker.start(handle.clone(), app_handle.clone(), session_id.clone());
|
||||||
|
|
||||||
let session = Arc::new(SshSession { id: session_id.clone(), hostname: hostname.to_string(), port, username: username.to_string(), channel_id, handle: handle.clone(), command_tx: command_tx.clone(), cwd_tracker: Some(cwd_tracker), cancel_token: cancel_token.clone() });
|
let session = Arc::new(SshSession { id: session_id.clone(), hostname: hostname.to_string(), port, username: username.to_string(), channel_id, handle: handle.clone(), command_tx: command_tx.clone(), cwd_tracker: Some(cwd_tracker) });
|
||||||
self.sessions.insert(session_id.clone(), session);
|
self.sessions.insert(session_id.clone(), session);
|
||||||
|
|
||||||
{ let h = handle.lock().await;
|
{ let h = handle.lock().await;
|
||||||
@ -154,31 +150,12 @@ impl SshService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wraith_log!("[SSH] Connected and authenticated: {}", session_id);
|
|
||||||
|
|
||||||
// Create scrollback buffer for MCP terminal_read
|
// Create scrollback buffer for MCP terminal_read
|
||||||
let scrollback_buf = scrollback.create(&session_id);
|
let scrollback_buf = scrollback.create(&session_id);
|
||||||
error_watcher.watch(&session_id);
|
error_watcher.watch(&session_id);
|
||||||
|
|
||||||
// Start remote monitoring if enabled (runs on a separate exec channel)
|
// Start remote monitoring if enabled (runs on a separate exec channel)
|
||||||
crate::ssh::monitor::start_monitor(handle.clone(), app_handle.clone(), session_id.clone(), cancel_token.clone());
|
crate::ssh::monitor::start_monitor(handle.clone(), app_handle.clone(), session_id.clone());
|
||||||
|
|
||||||
// Inject OSC 7 CWD reporting hook into the user's shell.
|
|
||||||
// This enables SFTP CWD following on all platforms (Linux, macOS, FreeBSD).
|
|
||||||
// Sent via the PTY channel so it configures the interactive shell.
|
|
||||||
// Wrapped in stty -echo/echo so the command is invisible to the user,
|
|
||||||
// then clear the line with \r and overwrite with spaces.
|
|
||||||
{
|
|
||||||
let osc7_hook = concat!(
|
|
||||||
" stty -echo; ",
|
|
||||||
"__wraith_osc7() { printf '\\e]7;file://localhost/%s\\a' \"$(pwd | sed 's/ /%20/g')\"; }; ",
|
|
||||||
"if [ -n \"$ZSH_VERSION\" ]; then precmd() { __wraith_osc7; }; ",
|
|
||||||
"elif [ -n \"$BASH_VERSION\" ]; then PROMPT_COMMAND=__wraith_osc7; fi; ",
|
|
||||||
"stty echo; clear; cd ~\n"
|
|
||||||
);
|
|
||||||
let h = handle.lock().await;
|
|
||||||
let _ = h.data(channel_id, CryptoVec::from_slice(osc7_hook.as_bytes())).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output reader loop — owns the Channel exclusively.
|
// Output reader loop — owns the Channel exclusively.
|
||||||
// Writes go through Handle::data() so no shared mutex is needed.
|
// Writes go through Handle::data() so no shared mutex is needed.
|
||||||
@ -249,8 +226,6 @@ impl SshService {
|
|||||||
|
|
||||||
pub async fn disconnect(&self, session_id: &str, sftp_service: &SftpService) -> Result<(), String> {
|
pub async fn disconnect(&self, session_id: &str, sftp_service: &SftpService) -> Result<(), String> {
|
||||||
let (_, session) = self.sessions.remove(session_id).ok_or_else(|| format!("Session {} not found", session_id))?;
|
let (_, session) = self.sessions.remove(session_id).ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||||
// Cancel background tasks (CWD tracker, monitor) before tearing down the connection.
|
|
||||||
session.cancel_token.cancel();
|
|
||||||
let _ = session.command_tx.send(ChannelCommand::Shutdown);
|
let _ = session.command_tx.send(ChannelCommand::Shutdown);
|
||||||
{ let handle = session.handle.lock().await; let _ = handle.disconnect(Disconnect::ByApplication, "", "en").await; }
|
{ let handle = session.handle.lock().await; let _ = handle.disconnect(Disconnect::ByApplication, "", "en").await; }
|
||||||
sftp_service.remove_client(session_id);
|
sftp_service.remove_client(session_id);
|
||||||
@ -258,7 +233,7 @@ impl SshService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_session(&self, session_id: &str) -> Option<Arc<SshSession>> {
|
pub fn get_session(&self, session_id: &str) -> Option<Arc<SshSession>> {
|
||||||
self.sessions.get(session_id).map(|r| r.value().clone())
|
self.sessions.get(session_id).map(|entry| entry.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||||
@ -397,31 +372,27 @@ fn extract_osc7_cwd(data: &[u8]) -> Option<String> {
|
|||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
// URL-decode the path (spaces encoded as %20, etc.)
|
// URL-decode the path (spaces encoded as %20, etc.)
|
||||||
// Strip any stray quotes from shell printf output
|
Some(percent_decode(path))
|
||||||
let decoded = percent_decode(path);
|
|
||||||
let clean = decoded.trim_matches('"').trim_matches('\'').to_string();
|
|
||||||
if clean.is_empty() { None } else { Some(clean) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn percent_decode(input: &str) -> String {
|
fn percent_decode(input: &str) -> String {
|
||||||
let mut bytes: Vec<u8> = Vec::with_capacity(input.len());
|
let mut output = String::with_capacity(input.len());
|
||||||
let mut chars = input.chars();
|
let mut chars = input.chars();
|
||||||
while let Some(ch) = chars.next() {
|
while let Some(ch) = chars.next() {
|
||||||
if ch == '%' {
|
if ch == '%' {
|
||||||
let hex: String = chars.by_ref().take(2).collect();
|
let hex: String = chars.by_ref().take(2).collect();
|
||||||
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
|
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
|
||||||
bytes.push(byte);
|
output.push(byte as char);
|
||||||
} else {
|
} else {
|
||||||
bytes.extend_from_slice(b"%");
|
output.push('%');
|
||||||
bytes.extend_from_slice(hex.as_bytes());
|
output.push_str(&hex);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let mut buf = [0u8; 4];
|
output.push(ch);
|
||||||
bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
String::from_utf8_lossy(&bytes).into_owned()
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a private key string — if it looks like PEM content, return as-is.
|
/// Resolve a private key string — if it looks like PEM content, return as-is.
|
||||||
|
|||||||
@ -59,7 +59,6 @@ struct BuiltinTheme {
|
|||||||
|
|
||||||
// ── service ───────────────────────────────────────────────────────────────────
|
// ── service ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ThemeService {
|
pub struct ThemeService {
|
||||||
db: Database,
|
db: Database,
|
||||||
}
|
}
|
||||||
@ -254,7 +253,7 @@ impl ThemeService {
|
|||||||
t.bright_blue, t.bright_magenta, t.bright_cyan, t.bright_white,
|
t.bright_blue, t.bright_magenta, t.bright_cyan, t.bright_white,
|
||||||
],
|
],
|
||||||
) {
|
) {
|
||||||
wraith_log!("theme::seed_builtins: failed to seed '{}': {}", t.name, e);
|
eprintln!("theme::seed_builtins: failed to seed '{}': {}", t.name, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -273,7 +272,7 @@ impl ThemeService {
|
|||||||
) {
|
) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
wraith_log!("theme::list: failed to prepare query: {}", e);
|
eprintln!("theme::list: failed to prepare query: {}", e);
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -281,12 +280,12 @@ impl ThemeService {
|
|||||||
match stmt.query_map([], map_theme_row) {
|
match stmt.query_map([], map_theme_row) {
|
||||||
Ok(rows) => rows
|
Ok(rows) => rows
|
||||||
.filter_map(|r| {
|
.filter_map(|r| {
|
||||||
r.map_err(|e| wraith_log!("theme::list: row error: {}", e))
|
r.map_err(|e| eprintln!("theme::list: row error: {}", e))
|
||||||
.ok()
|
.ok()
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
wraith_log!("theme::list: query failed: {}", e);
|
eprintln!("theme::list: query failed: {}", e);
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
//! Shared utility functions.
|
|
||||||
|
|
||||||
/// Escape a string for safe interpolation into a POSIX shell command.
|
|
||||||
///
|
|
||||||
/// Wraps the input in single quotes and escapes any embedded single quotes
|
|
||||||
/// using the `'\''` technique. This prevents command injection when building
|
|
||||||
/// shell commands from user-supplied values.
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// # use wraith_lib::utils::shell_escape;
|
|
||||||
/// assert_eq!(shell_escape("hello"), "'hello'");
|
|
||||||
/// assert_eq!(shell_escape("it's"), "'it'\\''s'");
|
|
||||||
/// assert_eq!(shell_escape(";rm -rf /"), "';rm -rf /'");
|
|
||||||
/// ```
|
|
||||||
pub fn shell_escape(input: &str) -> String {
|
|
||||||
format!("'{}'", input.replace('\'', "'\\''"))
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@ use aes_gcm::{
|
|||||||
Aes256Gcm, Key, Nonce,
|
Aes256Gcm, Key, Nonce,
|
||||||
};
|
};
|
||||||
use argon2::{Algorithm, Argon2, Params, Version};
|
use argon2::{Algorithm, Argon2, Params, Version};
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// VaultService
|
// VaultService
|
||||||
@ -22,18 +21,18 @@ use zeroize::Zeroizing;
|
|||||||
/// The version prefix allows a future migration to a different algorithm
|
/// The version prefix allows a future migration to a different algorithm
|
||||||
/// without breaking existing stored blobs.
|
/// without breaking existing stored blobs.
|
||||||
pub struct VaultService {
|
pub struct VaultService {
|
||||||
key: Zeroizing<[u8; 32]>,
|
key: [u8; 32],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VaultService {
|
impl VaultService {
|
||||||
pub fn new(key: Zeroizing<[u8; 32]>) -> Self {
|
pub fn new(key: [u8; 32]) -> Self {
|
||||||
Self { key }
|
Self { key }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encrypt `plaintext` and return a `v1:{iv_hex}:{sealed_hex}` blob.
|
/// Encrypt `plaintext` and return a `v1:{iv_hex}:{sealed_hex}` blob.
|
||||||
pub fn encrypt(&self, plaintext: &str) -> Result<String, String> {
|
pub fn encrypt(&self, plaintext: &str) -> Result<String, String> {
|
||||||
// Build the AES-256-GCM cipher from our key.
|
// Build the AES-256-GCM cipher from our key.
|
||||||
let key = Key::<Aes256Gcm>::from_slice(&*self.key);
|
let key = Key::<Aes256Gcm>::from_slice(&self.key);
|
||||||
let cipher = Aes256Gcm::new(key);
|
let cipher = Aes256Gcm::new(key);
|
||||||
|
|
||||||
// Generate a random 12-byte nonce (96-bit is the GCM standard).
|
// Generate a random 12-byte nonce (96-bit is the GCM standard).
|
||||||
@ -72,7 +71,7 @@ impl VaultService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = Key::<Aes256Gcm>::from_slice(&*self.key);
|
let key = Key::<Aes256Gcm>::from_slice(&self.key);
|
||||||
let cipher = Aes256Gcm::new(key);
|
let cipher = Aes256Gcm::new(key);
|
||||||
let nonce = Nonce::from_slice(&iv_bytes);
|
let nonce = Nonce::from_slice(&iv_bytes);
|
||||||
|
|
||||||
@ -96,7 +95,7 @@ impl VaultService {
|
|||||||
/// t = 3 iterations
|
/// t = 3 iterations
|
||||||
/// m = 65536 KiB (64 MiB) memory
|
/// m = 65536 KiB (64 MiB) memory
|
||||||
/// p = 4 parallelism lanes
|
/// p = 4 parallelism lanes
|
||||||
pub fn derive_key(password: &str, salt: &[u8]) -> Zeroizing<[u8; 32]> {
|
pub fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] {
|
||||||
let params = Params::new(
|
let params = Params::new(
|
||||||
65536, // m_cost: 64 MiB
|
65536, // m_cost: 64 MiB
|
||||||
3, // t_cost: iterations
|
3, // t_cost: iterations
|
||||||
@ -107,9 +106,9 @@ pub fn derive_key(password: &str, salt: &[u8]) -> Zeroizing<[u8; 32]> {
|
|||||||
|
|
||||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||||
|
|
||||||
let mut output_key = Zeroizing::new([0u8; 32]);
|
let mut output_key = [0u8; 32];
|
||||||
argon2
|
argon2
|
||||||
.hash_password_into(password.as_bytes(), salt, &mut *output_key)
|
.hash_password_into(password.as_bytes(), salt, &mut output_key)
|
||||||
.expect("Argon2id key derivation failed");
|
.expect("Argon2id key derivation failed");
|
||||||
|
|
||||||
output_key
|
output_key
|
||||||
|
|||||||
@ -24,7 +24,6 @@ pub struct WorkspaceSnapshot {
|
|||||||
const SNAPSHOT_KEY: &str = "workspace_snapshot";
|
const SNAPSHOT_KEY: &str = "workspace_snapshot";
|
||||||
const CLEAN_SHUTDOWN_KEY: &str = "clean_shutdown";
|
const CLEAN_SHUTDOWN_KEY: &str = "clean_shutdown";
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WorkspaceService {
|
pub struct WorkspaceService {
|
||||||
settings: SettingsService,
|
settings: SettingsService,
|
||||||
}
|
}
|
||||||
@ -48,7 +47,7 @@ impl WorkspaceService {
|
|||||||
pub fn load(&self) -> Option<WorkspaceSnapshot> {
|
pub fn load(&self) -> Option<WorkspaceSnapshot> {
|
||||||
let json = self.settings.get(SNAPSHOT_KEY)?;
|
let json = self.settings.get(SNAPSHOT_KEY)?;
|
||||||
serde_json::from_str(&json)
|
serde_json::from_str(&json)
|
||||||
.map_err(|e| wraith_log!("workspace::load: failed to deserialize snapshot: {e}"))
|
.map_err(|e| eprintln!("workspace::load: failed to deserialize snapshot: {e}"))
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,14 +18,13 @@
|
|||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"decorations": true,
|
"decorations": true,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"dragDropEnabled": false,
|
"dragDropEnabled": false
|
||||||
"additionalBrowserArgs": "--enable-gpu-rasterization --enable-zero-copy --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null
|
||||||
},
|
},
|
||||||
"withGlobalTauri": false
|
"withGlobalTauri": true
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
@ -49,12 +48,6 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"shell": {
|
"shell": {
|
||||||
"open": true
|
"open": true
|
||||||
},
|
|
||||||
"updater": {
|
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNCRkQ2OUY2OEY0Q0ZFQkYKUldTLy9reVA5bW45T3dUQ1R5OFNCenVhL2srTXlLcHR4cFNaeCtJSmJUSTZKSUNHVTRIbWZwanEK",
|
|
||||||
"endpoints": [
|
|
||||||
"https://files.command.vigilcyber.com/wraith/update.json"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/App.vue
55
src/App.vue
@ -1,65 +1,40 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onErrorCaptured, defineAsyncComponent } from "vue";
|
import { ref, onMounted, defineAsyncComponent } from "vue";
|
||||||
import { useAppStore } from "@/stores/app.store";
|
import { useAppStore } from "@/stores/app.store";
|
||||||
import UnlockLayout from "@/layouts/UnlockLayout.vue";
|
import UnlockLayout from "@/layouts/UnlockLayout.vue";
|
||||||
import ToolWindow from "@/components/tools/ToolWindow.vue";
|
|
||||||
|
|
||||||
const MainLayout = defineAsyncComponent({
|
const MainLayout = defineAsyncComponent(
|
||||||
loader: () => import("@/layouts/MainLayout.vue"),
|
() => import("@/layouts/MainLayout.vue")
|
||||||
onError(error) { console.error("[App] MainLayout load failed:", error); },
|
);
|
||||||
});
|
const ToolWindow = defineAsyncComponent(
|
||||||
const DetachedSession = defineAsyncComponent({
|
() => import("@/components/tools/ToolWindow.vue")
|
||||||
loader: () => import("@/components/session/DetachedSession.vue"),
|
);
|
||||||
onError(error) { console.error("[App] DetachedSession load failed:", error); },
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = useAppStore();
|
const app = useAppStore();
|
||||||
const appError = ref<string | null>(null);
|
|
||||||
|
|
||||||
|
// Tool window mode — detected from URL hash: #/tool/network-scanner?sessionId=abc
|
||||||
const isToolMode = ref(false);
|
const isToolMode = ref(false);
|
||||||
const isDetachedMode = ref(false);
|
|
||||||
const toolName = ref("");
|
const toolName = ref("");
|
||||||
const toolSessionId = ref("");
|
const toolSessionId = ref("");
|
||||||
|
|
||||||
onErrorCaptured((err) => {
|
onMounted(async () => {
|
||||||
appError.value = err instanceof Error ? err.message : String(err);
|
const hash = window.location.hash;
|
||||||
console.error("[App] Uncaught error:", err);
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Parse hash and set mode flags. Called on mount and on hashchange. */
|
|
||||||
function applyHash(hash: string): void {
|
|
||||||
if (hash.startsWith("#/tool/")) {
|
if (hash.startsWith("#/tool/")) {
|
||||||
isToolMode.value = true;
|
isToolMode.value = true;
|
||||||
const rest = hash.substring(7);
|
const rest = hash.substring(7); // after "#/tool/"
|
||||||
const [name, query] = rest.split("?");
|
const [name, query] = rest.split("?");
|
||||||
toolName.value = name;
|
toolName.value = name;
|
||||||
toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || "";
|
toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || "";
|
||||||
} else if (hash.startsWith("#/detached-session")) {
|
} else {
|
||||||
isDetachedMode.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
// Check hash at load time (present if JS-side WebviewWindow set it in the URL)
|
|
||||||
applyHash(window.location.hash);
|
|
||||||
|
|
||||||
// Also listen for hash changes (Rust-side window sets hash via eval after load)
|
|
||||||
window.addEventListener("hashchange", () => applyHash(window.location.hash));
|
|
||||||
|
|
||||||
// Only init vault for the main app window (no hash)
|
|
||||||
if (!isToolMode.value && !isDetachedMode.value) {
|
|
||||||
await app.checkVaultState();
|
await app.checkVaultState();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="appError" class="fixed inset-0 z-50 flex items-center justify-center bg-[#0d1117] text-red-400 p-8 text-sm font-mono whitespace-pre-wrap">
|
<!-- Tool popup window mode -->
|
||||||
{{ appError }}
|
<ToolWindow v-if="isToolMode" :tool="toolName" :session-id="toolSessionId" />
|
||||||
</div>
|
<!-- Normal app mode -->
|
||||||
<DetachedSession v-else-if="isDetachedMode" />
|
|
||||||
<ToolWindow v-else-if="isToolMode" :tool="toolName" :session-id="toolSessionId" />
|
|
||||||
<div v-else class="app-root">
|
<div v-else class="app-root">
|
||||||
<UnlockLayout v-if="!app.isUnlocked" />
|
<UnlockLayout v-if="!app.isUnlocked" />
|
||||||
<MainLayout v-else />
|
<MainLayout v-else />
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
.terminal-container {
|
.terminal-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--wraith-bg-primary);
|
background: var(--wraith-bg-primary);
|
||||||
@ -20,16 +20,14 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* WKWebView focus fix: xterm.js hides its helper textarea with opacity: 0,
|
/* Selection styling */
|
||||||
width/height: 0, left: -9999em. macOS WKWebView doesn't reliably focus
|
.terminal-container .xterm-selection div {
|
||||||
elements with zero dimensions positioned off-screen. Override to keep it
|
background-color: rgba(88, 166, 255, 0.3) !important;
|
||||||
within the viewport with non-zero dimensions so focus events fire. */
|
}
|
||||||
.terminal-container .xterm .xterm-helper-textarea {
|
|
||||||
left: 0 !important;
|
/* Cursor styling */
|
||||||
top: 0 !important;
|
.terminal-container .xterm-cursor-layer {
|
||||||
width: 1px !important;
|
z-index: 4;
|
||||||
height: 1px !important;
|
|
||||||
opacity: 0.01 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar inside terminal */
|
/* Scrollbar inside terminal */
|
||||||
|
|||||||
@ -37,14 +37,6 @@
|
|||||||
>
|
>
|
||||||
Kill
|
Kill
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="connected"
|
|
||||||
class="px-2 py-0.5 text-[10px] rounded border border-[var(--wraith-border)] text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] cursor-pointer"
|
|
||||||
title="Inject available MCP tools into the chat"
|
|
||||||
@click="injectTools"
|
|
||||||
>
|
|
||||||
Tools
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -164,30 +156,11 @@ async function launchPreset(preset: LaunchPreset): Promise<void> {
|
|||||||
if (!shell) return;
|
if (!shell) return;
|
||||||
selectedShell.value = shell;
|
selectedShell.value = shell;
|
||||||
await launch();
|
await launch();
|
||||||
// Wait for the shell prompt before sending the command.
|
// After shell spawns, send the preset command
|
||||||
// Poll the scrollback for a prompt indicator (PS>, $, #, %, >)
|
|
||||||
if (sessionId && connected.value) {
|
if (sessionId && connected.value) {
|
||||||
const maxWait = 5000;
|
setTimeout(() => {
|
||||||
const start = Date.now();
|
invoke("pty_write", { sessionId, data: preset.command + "\n" }).catch(() => {});
|
||||||
const poll = setInterval(async () => {
|
}, 300);
|
||||||
if (Date.now() - start > maxWait) {
|
|
||||||
clearInterval(poll);
|
|
||||||
// Send anyway after timeout
|
|
||||||
invoke("pty_write", { sessionId, data: preset.command + "\r" }).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const lines = await invoke<string>("mcp_terminal_read", { sessionId, lines: 3 });
|
|
||||||
const lastLine = lines.split("\n").pop()?.trim() || "";
|
|
||||||
// Detect common shell prompts
|
|
||||||
if (lastLine.endsWith("$") || lastLine.endsWith("#") || lastLine.endsWith("%") || lastLine.endsWith(">") || lastLine.endsWith("PS>")) {
|
|
||||||
clearInterval(poll);
|
|
||||||
invoke("pty_write", { sessionId, data: preset.command + "\r" }).catch(() => {});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Scrollback not ready yet, keep polling
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,12 +176,10 @@ async function launch(): Promise<void> {
|
|||||||
});
|
});
|
||||||
connected.value = true;
|
connected.value = true;
|
||||||
|
|
||||||
// Instantiate terminal synchronously (before any further awaits) now that
|
await nextTick();
|
||||||
// sessionId is known. Cleanup is owned by this component's onBeforeUnmount.
|
|
||||||
terminalInstance = useTerminal(sessionId, "pty");
|
|
||||||
|
|
||||||
nextTick(() => {
|
if (containerRef.value) {
|
||||||
if (containerRef.value && terminalInstance) {
|
terminalInstance = useTerminal(sessionId, "pty");
|
||||||
terminalInstance.mount(containerRef.value);
|
terminalInstance.mount(containerRef.value);
|
||||||
|
|
||||||
// Fit after mount to get real dimensions, then resize the PTY
|
// Fit after mount to get real dimensions, then resize the PTY
|
||||||
@ -224,7 +195,6 @@ async function launch(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for shell exit
|
// Listen for shell exit
|
||||||
closeUnlisten = await listen(`pty:close:${sessionId}`, () => {
|
closeUnlisten = await listen(`pty:close:${sessionId}`, () => {
|
||||||
@ -237,44 +207,6 @@ async function launch(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectTools(): void {
|
|
||||||
if (!sessionId || !connected.value) return;
|
|
||||||
const toolsPrompt = [
|
|
||||||
"You have access to these Wraith MCP tools via the wraith-mcp-bridge:",
|
|
||||||
"",
|
|
||||||
"SESSION MANAGEMENT:",
|
|
||||||
" list_sessions — List all active SSH/RDP/PTY sessions",
|
|
||||||
"",
|
|
||||||
"TERMINAL:",
|
|
||||||
" terminal_read(session_id, lines?) — Read recent terminal output (ANSI stripped)",
|
|
||||||
" terminal_execute(session_id, command, timeout_ms?) — Run a command and capture output",
|
|
||||||
" terminal_screenshot(session_id) — Capture RDP session as PNG",
|
|
||||||
"",
|
|
||||||
"SFTP:",
|
|
||||||
" sftp_list(session_id, path) — List remote directory",
|
|
||||||
" sftp_read(session_id, path) — Read remote file",
|
|
||||||
" sftp_write(session_id, path, content) — Write remote file",
|
|
||||||
"",
|
|
||||||
"NETWORK:",
|
|
||||||
" network_scan(session_id, subnet) — Discover devices on subnet (ARP + ping sweep)",
|
|
||||||
" port_scan(session_id, target, ports?) — Scan TCP ports",
|
|
||||||
" ping(session_id, target) — Ping a host",
|
|
||||||
" traceroute(session_id, target) — Traceroute to host",
|
|
||||||
" dns_lookup(session_id, domain, record_type?) — DNS lookup",
|
|
||||||
" whois(session_id, target) — Whois lookup",
|
|
||||||
" wake_on_lan(session_id, mac_address) — Send WoL magic packet",
|
|
||||||
" bandwidth_test(session_id) — Internet speed test",
|
|
||||||
"",
|
|
||||||
"UTILITIES (no session needed):",
|
|
||||||
" subnet_calc(cidr) — Calculate subnet details",
|
|
||||||
" generate_ssh_key(key_type, comment?) — Generate SSH key pair",
|
|
||||||
" generate_password(length?, uppercase?, lowercase?, digits?, symbols?) — Generate password",
|
|
||||||
"",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
invoke("pty_write", { sessionId, data: toolsPrompt + "\r" }).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function kill(): void {
|
function kill(): void {
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
invoke("disconnect_pty", { sessionId }).catch(() => {});
|
invoke("disconnect_pty", { sessionId }).catch(() => {});
|
||||||
|
|||||||
@ -116,9 +116,9 @@ const connectionStore = useConnectionStore();
|
|||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"open-import": [];
|
(e: "open-import"): void;
|
||||||
"open-settings": [];
|
(e: "open-settings"): void;
|
||||||
"open-new-connection": [protocol?: "ssh" | "rdp"];
|
(e: "open-new-connection", protocol?: "ssh" | "rdp"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const actions: PaletteAction[] = [
|
const actions: PaletteAction[] = [
|
||||||
|
|||||||
@ -233,30 +233,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Update check -->
|
|
||||||
<div class="pt-2">
|
|
||||||
<button
|
|
||||||
class="w-full px-3 py-2 text-xs font-bold rounded bg-[var(--wraith-accent-blue)] text-black cursor-pointer disabled:opacity-40"
|
|
||||||
:disabled="updateChecking"
|
|
||||||
@click="checkUpdates"
|
|
||||||
>
|
|
||||||
{{ updateChecking ? "Checking..." : "Check for Updates" }}
|
|
||||||
</button>
|
|
||||||
<div v-if="updateInfo" class="mt-2 p-3 rounded bg-[#0d1117] border border-[#30363d]">
|
|
||||||
<template v-if="updateInfo.updateAvailable">
|
|
||||||
<p class="text-xs text-[#3fb950] mb-1">Update available: v{{ updateInfo.latestVersion }}</p>
|
|
||||||
<p v-if="updateInfo.releaseNotes" class="text-[10px] text-[var(--wraith-text-muted)] mb-2 max-h-20 overflow-auto">{{ updateInfo.releaseNotes }}</p>
|
|
||||||
<button
|
|
||||||
class="w-full px-3 py-1.5 text-xs font-bold rounded bg-[#238636] text-white cursor-pointer"
|
|
||||||
@click="downloadUpdate"
|
|
||||||
>
|
|
||||||
Download v{{ updateInfo.latestVersion }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<p v-else class="text-xs text-[var(--wraith-text-muted)]">You're on the latest version.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="flex gap-2 pt-2">
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
@ -305,36 +281,6 @@ interface CopilotPreset { name: string; shell: string; command: string; }
|
|||||||
const visible = ref(false);
|
const visible = ref(false);
|
||||||
const activeSection = ref<Section>("general");
|
const activeSection = ref<Section>("general");
|
||||||
const copilotPresets = ref<CopilotPreset[]>([]);
|
const copilotPresets = ref<CopilotPreset[]>([]);
|
||||||
|
|
||||||
interface UpdateCheckInfo {
|
|
||||||
currentVersion: string;
|
|
||||||
latestVersion: string;
|
|
||||||
updateAvailable: boolean;
|
|
||||||
downloadUrl: string;
|
|
||||||
releaseNotes: string;
|
|
||||||
}
|
|
||||||
const updateChecking = ref(false);
|
|
||||||
const updateInfo = ref<UpdateCheckInfo | null>(null);
|
|
||||||
|
|
||||||
async function checkUpdates(): Promise<void> {
|
|
||||||
updateChecking.value = true;
|
|
||||||
updateInfo.value = null;
|
|
||||||
try {
|
|
||||||
updateInfo.value = await invoke<UpdateCheckInfo>("check_for_updates");
|
|
||||||
} catch (err) {
|
|
||||||
alert(`Update check failed: ${err}`);
|
|
||||||
}
|
|
||||||
updateChecking.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadUpdate(): Promise<void> {
|
|
||||||
if (!updateInfo.value?.downloadUrl) return;
|
|
||||||
try {
|
|
||||||
await shellOpen(updateInfo.value.downloadUrl);
|
|
||||||
} catch {
|
|
||||||
window.open(updateInfo.value.downloadUrl, "_blank");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const currentVersion = ref("loading...");
|
const currentVersion = ref("loading...");
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
@ -422,16 +368,9 @@ watch(
|
|||||||
() => settings.value.defaultProtocol,
|
() => settings.value.defaultProtocol,
|
||||||
(val) => invoke("set_setting", { key: "default_protocol", value: val }).catch(console.error),
|
(val) => invoke("set_setting", { key: "default_protocol", value: val }).catch(console.error),
|
||||||
);
|
);
|
||||||
let sidebarWidthDebounce: ReturnType<typeof setTimeout>;
|
|
||||||
watch(
|
watch(
|
||||||
() => settings.value.sidebarWidth,
|
() => settings.value.sidebarWidth,
|
||||||
(val) => {
|
(val) => invoke("set_setting", { key: "sidebar_width", value: String(val) }).catch(console.error),
|
||||||
clearTimeout(sidebarWidthDebounce);
|
|
||||||
sidebarWidthDebounce = setTimeout(
|
|
||||||
() => invoke("set_setting", { key: "sidebar_width", value: String(val) }).catch(console.error),
|
|
||||||
300,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
watch(
|
watch(
|
||||||
() => settings.value.terminalTheme,
|
() => settings.value.terminalTheme,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-[48px] flex items-center justify-between px-6 bg-[var(--wraith-bg-secondary)] border-t border-[var(--wraith-border)] text-base text-[var(--wraith-text-muted)] shrink-0">
|
<div class="h-6 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-t border-[var(--wraith-border)] text-[10px] text-[var(--wraith-text-muted)] shrink-0">
|
||||||
<!-- Left: connection info -->
|
<!-- Left: connection info -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<template v-if="sessionStore.activeSession">
|
<template v-if="sessionStore.activeSession">
|
||||||
@ -47,7 +47,7 @@ const connectionStore = useConnectionStore();
|
|||||||
const activeThemeName = ref("Default");
|
const activeThemeName = ref("Default");
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"open-theme-picker": [];
|
(e: "open-theme-picker"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const connectionInfo = computed(() => {
|
const connectionInfo = computed(() => {
|
||||||
|
|||||||
@ -112,8 +112,6 @@ export interface ThemeDefinition {
|
|||||||
brightMagenta: string;
|
brightMagenta: string;
|
||||||
brightCyan: string;
|
brightCyan: string;
|
||||||
brightWhite: string;
|
brightWhite: string;
|
||||||
selectionBackground?: string;
|
|
||||||
selectionForeground?: string;
|
|
||||||
isBuiltin?: boolean;
|
isBuiltin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,8 +28,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { useRdp, MouseFlag } from "@/composables/useRdp";
|
import { useRdp, MouseFlag } from "@/composables/useRdp";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -43,8 +42,8 @@ const containerRef = ref<HTMLElement | null>(null);
|
|||||||
const canvasWrapper = ref<HTMLElement | null>(null);
|
const canvasWrapper = ref<HTMLElement | null>(null);
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
const rdpWidth = computed(() => props.width ?? 1920);
|
const rdpWidth = props.width ?? 1920;
|
||||||
const rdpHeight = computed(() => props.height ?? 1080);
|
const rdpHeight = props.height ?? 1080;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
connected,
|
connected,
|
||||||
@ -77,8 +76,8 @@ function toRdpCoords(e: MouseEvent): { x: number; y: number } | null {
|
|||||||
if (!canvas) return null;
|
if (!canvas) return null;
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const scaleX = canvas.width / rect.width;
|
const scaleX = rdpWidth / rect.width;
|
||||||
const scaleY = canvas.height / rect.height;
|
const scaleY = rdpHeight / rect.height;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: Math.floor((e.clientX - rect.left) * scaleX),
|
x: Math.floor((e.clientX - rect.left) * scaleX),
|
||||||
@ -154,95 +153,25 @@ function handleKeyUp(e: KeyboardEvent): void {
|
|||||||
sendKey(props.sessionId, e.code, false);
|
sendKey(props.sessionId, e.code, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
|
||||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (canvasRef.value) {
|
if (canvasRef.value) {
|
||||||
startFrameLoop(props.sessionId, canvasRef.value, rdpWidth.value, rdpHeight.value);
|
startFrameLoop(props.sessionId, canvasRef.value, rdpWidth, rdpHeight);
|
||||||
}
|
|
||||||
|
|
||||||
// Watch container size and request server-side RDP resize (debounced 500ms)
|
|
||||||
if (canvasWrapper.value) {
|
|
||||||
resizeObserver = new ResizeObserver((entries) => {
|
|
||||||
const entry = entries[0];
|
|
||||||
if (!entry || !connected.value) return;
|
|
||||||
const { width: cw, height: ch } = entry.contentRect;
|
|
||||||
if (cw < 200 || ch < 200) return;
|
|
||||||
|
|
||||||
// Round to even width (RDP spec requirement)
|
|
||||||
const newW = Math.round(cw) & ~1;
|
|
||||||
const newH = Math.round(ch);
|
|
||||||
|
|
||||||
if (resizeTimeout) clearTimeout(resizeTimeout);
|
|
||||||
resizeTimeout = setTimeout(() => {
|
|
||||||
invoke("rdp_resize", {
|
|
||||||
sessionId: props.sessionId,
|
|
||||||
width: newW,
|
|
||||||
height: newH,
|
|
||||||
}).then(() => {
|
|
||||||
if (canvasRef.value) {
|
|
||||||
canvasRef.value.width = newW;
|
|
||||||
canvasRef.value.height = newH;
|
|
||||||
}
|
|
||||||
// Force full frame after resize so canvas gets a clean repaint
|
|
||||||
setTimeout(() => {
|
|
||||||
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
|
|
||||||
}, 200);
|
|
||||||
}).catch((err: unknown) => {
|
|
||||||
console.warn("[RdpView] resize failed:", err);
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
resizeObserver.observe(canvasWrapper.value);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopFrameLoop();
|
stopFrameLoop();
|
||||||
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; }
|
|
||||||
if (resizeTimeout) { clearTimeout(resizeTimeout); resizeTimeout = null; }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Focus canvas, re-check dimensions, and force full frame on tab switch.
|
// Focus canvas when this tab becomes active and keyboard is grabbed
|
||||||
// Uses 300ms delay to let the flex layout fully settle (copilot panel toggle, etc.)
|
|
||||||
watch(
|
watch(
|
||||||
() => props.isActive,
|
() => props.isActive,
|
||||||
(active) => {
|
(active) => {
|
||||||
if (!active || !canvasRef.value) return;
|
if (active && keyboardGrabbed.value && canvasRef.value) {
|
||||||
|
|
||||||
// Immediate focus so keyboard works right away
|
|
||||||
if (keyboardGrabbed.value) canvasRef.value.focus();
|
|
||||||
|
|
||||||
// Immediate force refresh to show SOMETHING while we check dimensions
|
|
||||||
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
|
|
||||||
|
|
||||||
// Delayed dimension check — layout needs time to settle
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const wrapper = canvasWrapper.value;
|
canvasRef.value?.focus();
|
||||||
const canvas = canvasRef.value;
|
}, 0);
|
||||||
if (!wrapper || !canvas) return;
|
|
||||||
|
|
||||||
const { width: cw, height: ch } = wrapper.getBoundingClientRect();
|
|
||||||
const newW = Math.round(cw) & ~1;
|
|
||||||
const newH = Math.round(ch);
|
|
||||||
|
|
||||||
if (newW >= 200 && newH >= 200 && (newW !== canvas.width || newH !== canvas.height)) {
|
|
||||||
invoke("rdp_resize", {
|
|
||||||
sessionId: props.sessionId,
|
|
||||||
width: newW,
|
|
||||||
height: newH,
|
|
||||||
}).then(() => {
|
|
||||||
if (canvas) {
|
|
||||||
canvas.width = newW;
|
|
||||||
canvas.height = newH;
|
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
|
||||||
invoke("rdp_force_refresh", { sessionId: props.sessionId }).catch(() => {});
|
|
||||||
}, 500);
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@ -267,8 +196,9 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rdp-canvas {
|
.rdp-canvas {
|
||||||
width: 100%;
|
max-width: 100%;
|
||||||
height: 100%;
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
outline: none;
|
outline: none;
|
||||||
image-rendering: auto;
|
image-rendering: auto;
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="h-screen w-screen flex flex-col bg-[#0d1117]">
|
|
||||||
<!-- Minimal title bar -->
|
|
||||||
<div class="h-8 flex items-center justify-between px-3 bg-[#161b22] border-b border-[#30363d] shrink-0" data-tauri-drag-region>
|
|
||||||
<span class="text-xs text-[#8b949e]">{{ sessionName }}</span>
|
|
||||||
<span class="text-[10px] text-[#484f58]">Detached — close to reattach</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Terminal -->
|
|
||||||
<div ref="containerRef" class="flex-1 min-h-0" />
|
|
||||||
|
|
||||||
<!-- Monitor bar for SSH sessions -->
|
|
||||||
<MonitorBar v-if="protocol === 'ssh'" :session-id="sessionId" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { useTerminal } from "@/composables/useTerminal";
|
|
||||||
import MonitorBar from "@/components/terminal/MonitorBar.vue";
|
|
||||||
|
|
||||||
const sessionId = ref("");
|
|
||||||
const sessionName = ref("Detached Session");
|
|
||||||
const protocol = ref("ssh");
|
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
// Parse session info from URL hash synchronously so backend type is known at setup time
|
|
||||||
const hash = window.location.hash;
|
|
||||||
const params = new URLSearchParams(hash.split("?")[1] || "");
|
|
||||||
const _initialSessionId = params.get("sessionId") || "";
|
|
||||||
const _initialProtocol = params.get("protocol") || "ssh";
|
|
||||||
const _backend = (_initialProtocol === "local" ? "pty" : "ssh") as 'ssh' | 'pty';
|
|
||||||
|
|
||||||
const terminalInstance = useTerminal(_initialSessionId, _backend);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
sessionId.value = _initialSessionId;
|
|
||||||
sessionName.value = decodeURIComponent(params.get("name") || "Detached Session");
|
|
||||||
protocol.value = _initialProtocol;
|
|
||||||
|
|
||||||
if (!sessionId.value || !containerRef.value) return;
|
|
||||||
|
|
||||||
terminalInstance.mount(containerRef.value);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
terminalInstance.fit();
|
|
||||||
terminalInstance.terminal.focus();
|
|
||||||
|
|
||||||
const resizeCmd = _backend === "ssh" ? "ssh_resize" : "pty_resize";
|
|
||||||
invoke(resizeCmd, {
|
|
||||||
sessionId: sessionId.value,
|
|
||||||
cols: terminalInstance.terminal.cols,
|
|
||||||
rows: terminalInstance.terminal.rows,
|
|
||||||
}).catch(() => {});
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
// On window close, emit event so main window reattaches the tab
|
|
||||||
const appWindow = getCurrentWindow();
|
|
||||||
appWindow.onCloseRequested(async () => {
|
|
||||||
// Emit a custom event that the main window listens for
|
|
||||||
const { emit } = await import("@tauri-apps/api/event");
|
|
||||||
await emit("session:reattach", {
|
|
||||||
sessionId: sessionId.value,
|
|
||||||
name: sessionName.value,
|
|
||||||
protocol: protocol.value,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
terminalInstance.destroy();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@ -91,17 +91,16 @@ function setTerminalRef(sessionId: string, el: unknown): void {
|
|||||||
|
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
|
|
||||||
// Only render sessions that are active (not detached to separate windows)
|
|
||||||
const sshSessions = computed(() =>
|
const sshSessions = computed(() =>
|
||||||
sessionStore.sessions.filter((s) => s.protocol === "ssh" && s.active),
|
sessionStore.sessions.filter((s) => s.protocol === "ssh"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const localSessions = computed(() =>
|
const localSessions = computed(() =>
|
||||||
sessionStore.sessions.filter((s) => s.protocol === "local" && s.active),
|
sessionStore.sessions.filter((s) => s.protocol === "local"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const rdpSessions = computed(() =>
|
const rdpSessions = computed(() =>
|
||||||
sessionStore.sessions.filter((s) => s.protocol === "rdp" && s.active),
|
sessionStore.sessions.filter((s) => s.protocol === "rdp"),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -14,8 +14,6 @@
|
|||||||
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] hover:bg-[var(--wraith-bg-tertiary)]',
|
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] hover:bg-[var(--wraith-bg-tertiary)]',
|
||||||
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
|
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
|
||||||
dragOverIndex === index ? 'border-l-2 border-l-[var(--wraith-accent-blue)]' : '',
|
dragOverIndex === index ? 'border-l-2 border-l-[var(--wraith-accent-blue)]' : '',
|
||||||
session.hasActivity && session.id !== sessionStore.activeSessionId ? 'animate-pulse text-[var(--wraith-accent-blue)]' : '',
|
|
||||||
!session.active ? 'opacity-40 italic' : '',
|
|
||||||
]"
|
]"
|
||||||
@click="sessionStore.activateSession(session.id)"
|
@click="sessionStore.activateSession(session.id)"
|
||||||
@dragstart="onDragStart(index, $event)"
|
@dragstart="onDragStart(index, $event)"
|
||||||
@ -23,7 +21,6 @@
|
|||||||
@dragleave="dragOverIndex = -1"
|
@dragleave="dragOverIndex = -1"
|
||||||
@drop.prevent="onDrop(index)"
|
@drop.prevent="onDrop(index)"
|
||||||
@dragend="draggedIndex = -1; dragOverIndex = -1"
|
@dragend="draggedIndex = -1; dragOverIndex = -1"
|
||||||
@contextmenu.prevent="showTabMenu($event, session)"
|
|
||||||
>
|
>
|
||||||
<!-- Badge: protocol dot + root dot + env pills -->
|
<!-- Badge: protocol dot + root dot + env pills -->
|
||||||
<TabBadge
|
<TabBadge
|
||||||
@ -72,23 +69,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tab context menu -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="tabMenu.visible" class="fixed z-[100] w-44 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden py-1"
|
|
||||||
:style="{ top: tabMenu.y + 'px', left: tabMenu.x + 'px' }">
|
|
||||||
<button class="w-full px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
|
|
||||||
@click="detachTab">Detach to Window</button>
|
|
||||||
<div class="border-t border-[#30363d] my-1" />
|
|
||||||
<button class="w-full px-4 py-2 text-xs text-left text-[var(--wraith-accent-red)] hover:bg-[#30363d] cursor-pointer"
|
|
||||||
@click="closeMenuTab">Close</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="tabMenu.visible" class="fixed inset-0 z-[99]" @click="tabMenu.visible = false" @contextmenu.prevent="tabMenu.visible = false" />
|
|
||||||
</Teleport>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useSessionStore, type Session } from "@/stores/session.store";
|
import { useSessionStore, type Session } from "@/stores/session.store";
|
||||||
import { useConnectionStore } from "@/stores/connection.store";
|
import { useConnectionStore } from "@/stores/connection.store";
|
||||||
@ -115,64 +100,12 @@ async function spawnShell(shell: ShellInfo): Promise<void> {
|
|||||||
await sessionStore.spawnLocalTab(shell.name, shell.path);
|
await sessionStore.spawnLocalTab(shell.name, shell.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab right-click context menu
|
|
||||||
const tabMenu = ref<{ visible: boolean; x: number; y: number; session: Session | null }>({
|
|
||||||
visible: false, x: 0, y: 0, session: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
function showTabMenu(event: MouseEvent, session: Session): void {
|
|
||||||
tabMenu.value = { visible: true, x: event.clientX, y: event.clientY, session };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function detachTab(): Promise<void> {
|
|
||||||
const session = tabMenu.value.session;
|
|
||||||
tabMenu.value.visible = false;
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
// Mark as detached in the store
|
|
||||||
session.active = false;
|
|
||||||
|
|
||||||
// Open a new Tauri window for this session
|
|
||||||
try {
|
|
||||||
await invoke("open_child_window", {
|
|
||||||
label: `detached-${session.id.substring(0, 8)}-${Date.now()}`,
|
|
||||||
title: `${session.name} — Wraith`,
|
|
||||||
url: `index.html#/detached-session?sessionId=${session.id}&name=${encodeURIComponent(session.name)}&protocol=${session.protocol}`,
|
|
||||||
width: 900, height: 600,
|
|
||||||
});
|
|
||||||
} catch (err) { console.error("Detach window error:", err); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMenuTab(): void {
|
|
||||||
const session = tabMenu.value.session;
|
|
||||||
tabMenu.value.visible = false;
|
|
||||||
if (session) sessionStore.closeSession(session.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
|
||||||
|
|
||||||
let unlistenReattach: UnlistenFn | null = null;
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
availableShells.value = await invoke<ShellInfo[]>("list_available_shells");
|
availableShells.value = await invoke<ShellInfo[]>("list_available_shells");
|
||||||
} catch {
|
} catch {
|
||||||
availableShells.value = [];
|
availableShells.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
unlistenReattach = await listen<{ sessionId: string; name: string; protocol: string }>("session:reattach", (event) => {
|
|
||||||
const { sessionId } = event.payload;
|
|
||||||
const session = sessionStore.sessions.find(s => s.id === sessionId);
|
|
||||||
if (session) {
|
|
||||||
session.active = true;
|
|
||||||
sessionStore.activateSession(sessionId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
unlistenReattach?.();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drag-and-drop tab reordering
|
// Drag-and-drop tab reordering
|
||||||
|
|||||||
@ -208,7 +208,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, toRef } from "vue";
|
import { ref } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useSftp, type FileEntry } from "@/composables/useSftp";
|
import { useSftp, type FileEntry } from "@/composables/useSftp";
|
||||||
import { useTransfers } from "@/composables/useTransfers";
|
import { useTransfers } from "@/composables/useTransfers";
|
||||||
@ -221,7 +221,7 @@ const emit = defineEmits<{
|
|||||||
openFile: [entry: FileEntry];
|
openFile: [entry: FileEntry];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { currentPath, entries, isLoading, followTerminal, navigateTo, goUp, refresh } = useSftp(toRef(props, 'sessionId'));
|
const { currentPath, entries, isLoading, followTerminal, navigateTo, goUp, refresh } = useSftp(props.sessionId);
|
||||||
const { addTransfer, completeTransfer, failTransfer } = useTransfers();
|
const { addTransfer, completeTransfer, failTransfer } = useTransfers();
|
||||||
|
|
||||||
/** Currently selected entry (single-click to select, double-click to open/navigate). */
|
/** Currently selected entry (single-click to select, double-click to open/navigate). */
|
||||||
@ -371,31 +371,6 @@ function handleFileSelected(event: Event): void {
|
|||||||
failTransfer(transferId);
|
failTransfer(transferId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Guard: the backend sftp_write_file command accepts a UTF-8 string only.
|
|
||||||
// Binary files (images, archives, executables, etc.) will be corrupted if
|
|
||||||
// sent as text. Warn and abort for known binary extensions or large files.
|
|
||||||
const BINARY_EXTENSIONS = new Set([
|
|
||||||
"png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tiff", "svg",
|
|
||||||
"zip", "tar", "gz", "bz2", "xz", "7z", "rar", "zst",
|
|
||||||
"exe", "dll", "so", "dylib", "bin", "elf",
|
|
||||||
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
|
|
||||||
"mp3", "mp4", "avi", "mkv", "mov", "flac", "wav", "ogg",
|
|
||||||
"ttf", "otf", "woff", "woff2",
|
|
||||||
"db", "sqlite", "sqlite3",
|
|
||||||
]);
|
|
||||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
|
||||||
const isBinary = BINARY_EXTENSIONS.has(ext);
|
|
||||||
const isLarge = file.size > 1 * 1024 * 1024; // 1 MB
|
|
||||||
|
|
||||||
if (isBinary || isLarge) {
|
|
||||||
const reason = isBinary
|
|
||||||
? `"${ext}" files are binary and cannot be safely uploaded as text`
|
|
||||||
: `file is ${(file.size / (1024 * 1024)).toFixed(1)} MB — only text files under 1 MB are supported`;
|
|
||||||
alert(`Upload blocked: ${reason}.\n\nBinary file upload support will be added in a future release.`);
|
|
||||||
failTransfer(transferId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,15 +52,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { ref } from "vue";
|
||||||
import { useTransfers } from "@/composables/useTransfers";
|
import { useTransfers } from "@/composables/useTransfers";
|
||||||
|
|
||||||
const expanded = ref(false);
|
const expanded = ref(false);
|
||||||
const { transfers } = useTransfers();
|
|
||||||
|
|
||||||
// Auto-expand when transfers become active, collapse when all are gone
|
// Auto-expand when transfers become active, collapse when all are gone
|
||||||
watch(() => transfers.value.length, (newLen, oldLen) => {
|
const { transfers } = useTransfers();
|
||||||
if (newLen > 0 && oldLen === 0) expanded.value = true;
|
|
||||||
if (newLen === 0) expanded.value = false;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -28,17 +28,10 @@
|
|||||||
<!-- Only show groups that have matching connections during search -->
|
<!-- Only show groups that have matching connections during search -->
|
||||||
<div v-if="!connectionStore.searchQuery || connectionStore.groupHasResults(group.id)">
|
<div v-if="!connectionStore.searchQuery || connectionStore.groupHasResults(group.id)">
|
||||||
<!-- Group header -->
|
<!-- Group header -->
|
||||||
<div
|
<button
|
||||||
class="w-full flex items-center gap-1.5 px-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer select-none"
|
class="w-full flex items-center gap-1.5 px-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer"
|
||||||
:class="{ 'border-t-2 border-t-[var(--wraith-accent-blue)]': dragOverGroupId === group.id }"
|
|
||||||
draggable="true"
|
|
||||||
@click="toggleGroup(group.id)"
|
@click="toggleGroup(group.id)"
|
||||||
@contextmenu.prevent="showGroupMenu($event, group)"
|
@contextmenu.prevent="showGroupMenu($event, group)"
|
||||||
@dragstart="onGroupDragStart(group, $event)"
|
|
||||||
@dragover.prevent="onGroupDragOver(group)"
|
|
||||||
@dragleave="dragOverGroupId = null"
|
|
||||||
@drop.prevent="onGroupDrop(group)"
|
|
||||||
@dragend="resetDragState"
|
|
||||||
>
|
>
|
||||||
<!-- Chevron -->
|
<!-- Chevron -->
|
||||||
<svg
|
<svg
|
||||||
@ -65,23 +58,16 @@
|
|||||||
<span class="ml-auto text-[var(--wraith-text-muted)] text-[10px]">
|
<span class="ml-auto text-[var(--wraith-text-muted)] text-[10px]">
|
||||||
{{ connectionStore.connectionsByGroup(group.id).length }}
|
{{ connectionStore.connectionsByGroup(group.id).length }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<!-- Connections in group -->
|
<!-- Connections in group -->
|
||||||
<div v-if="expandedGroups.has(group.id)">
|
<div v-if="expandedGroups.has(group.id)">
|
||||||
<div
|
<button
|
||||||
v-for="conn in connectionStore.connectionsByGroup(group.id)"
|
v-for="conn in connectionStore.connectionsByGroup(group.id)"
|
||||||
:key="conn.id"
|
:key="conn.id"
|
||||||
draggable="true"
|
class="w-full flex items-center gap-2 pl-8 pr-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer"
|
||||||
class="w-full flex items-center gap-2 pl-8 pr-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer select-none"
|
|
||||||
:class="{ 'border-t-2 border-t-[var(--wraith-accent-blue)]': dragOverConnId === conn.id }"
|
|
||||||
@dblclick="handleConnect(conn)"
|
@dblclick="handleConnect(conn)"
|
||||||
@contextmenu.prevent="showConnectionMenu($event, conn)"
|
@contextmenu.prevent="showConnectionMenu($event, conn)"
|
||||||
@dragstart="onConnDragStart(conn, group.id, $event)"
|
|
||||||
@dragover.prevent="onConnDragOver(conn)"
|
|
||||||
@dragleave="dragOverConnId = null"
|
|
||||||
@drop.prevent="onConnDrop(conn, group.id)"
|
|
||||||
@dragend="resetDragState"
|
|
||||||
>
|
>
|
||||||
<!-- Protocol dot -->
|
<!-- Protocol dot -->
|
||||||
<span
|
<span
|
||||||
@ -96,7 +82,7 @@
|
|||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -110,7 +96,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { ref } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useConnectionStore, type Connection, type Group } from "@/stores/connection.store";
|
import { useConnectionStore, type Connection, type Group } from "@/stores/connection.store";
|
||||||
import { useSessionStore } from "@/stores/session.store";
|
import { useSessionStore } from "@/stores/session.store";
|
||||||
@ -132,107 +118,11 @@ const sessionStore = useSessionStore();
|
|||||||
const contextMenu = ref<InstanceType<typeof ContextMenu> | null>(null);
|
const contextMenu = ref<InstanceType<typeof ContextMenu> | null>(null);
|
||||||
const editDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
|
const editDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
|
||||||
|
|
||||||
// ── Drag and drop reordering ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const dragOverGroupId = ref<number | null>(null);
|
|
||||||
const dragOverConnId = ref<number | null>(null);
|
|
||||||
let draggedGroup: Group | null = null;
|
|
||||||
let draggedConn: { conn: Connection; fromGroupId: number } | null = null;
|
|
||||||
|
|
||||||
function onGroupDragStart(group: Group, event: DragEvent): void {
|
|
||||||
draggedGroup = group;
|
|
||||||
draggedConn = null;
|
|
||||||
event.dataTransfer?.setData("text/plain", `group:${group.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onGroupDragOver(target: Group): void {
|
|
||||||
if (draggedGroup && draggedGroup.id !== target.id) {
|
|
||||||
dragOverGroupId.value = target.id;
|
|
||||||
}
|
|
||||||
// Allow dropping connections onto groups to move them
|
|
||||||
if (draggedConn) {
|
|
||||||
dragOverGroupId.value = target.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onGroupDrop(target: Group): Promise<void> {
|
|
||||||
if (draggedGroup && draggedGroup.id !== target.id) {
|
|
||||||
const groups = connectionStore.groups;
|
|
||||||
const fromIdx = groups.findIndex(g => g.id === draggedGroup!.id);
|
|
||||||
const toIdx = groups.findIndex(g => g.id === target.id);
|
|
||||||
if (fromIdx !== -1 && toIdx !== -1) {
|
|
||||||
const [moved] = groups.splice(fromIdx, 1);
|
|
||||||
groups.splice(toIdx, 0, moved);
|
|
||||||
// Persist new order
|
|
||||||
const ids = groups.map(g => g.id);
|
|
||||||
invoke("reorder_groups", { ids }).catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (draggedConn && draggedConn.fromGroupId !== target.id) {
|
|
||||||
try {
|
|
||||||
await invoke("update_connection", { id: draggedConn.conn.id, input: { groupId: target.id } });
|
|
||||||
await connectionStore.loadAll();
|
|
||||||
} catch (err) { console.error("Failed to move connection:", err); }
|
|
||||||
}
|
|
||||||
resetDragState();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onConnDragStart(conn: Connection, groupId: number, event: DragEvent): void {
|
|
||||||
draggedConn = { conn, fromGroupId: groupId };
|
|
||||||
draggedGroup = null;
|
|
||||||
event.dataTransfer?.setData("text/plain", `conn:${conn.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onConnDragOver(target: Connection): void {
|
|
||||||
if (draggedConn && draggedConn.conn.id !== target.id) {
|
|
||||||
dragOverConnId.value = target.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onConnDrop(target: Connection, targetGroupId: number): Promise<void> {
|
|
||||||
if (draggedConn && draggedConn.conn.id !== target.id) {
|
|
||||||
if (draggedConn.fromGroupId !== targetGroupId) {
|
|
||||||
try {
|
|
||||||
await invoke("update_connection", { id: draggedConn.conn.id, input: { groupId: targetGroupId } });
|
|
||||||
await connectionStore.loadAll();
|
|
||||||
} catch (err) { console.error("Failed to move connection:", err); }
|
|
||||||
} else {
|
|
||||||
const conns = connectionStore.connectionsByGroup(targetGroupId);
|
|
||||||
const fromIdx = conns.findIndex(c => c.id === draggedConn!.conn.id);
|
|
||||||
const toIdx = conns.findIndex(c => c.id === target.id);
|
|
||||||
if (fromIdx !== -1 && toIdx !== -1) {
|
|
||||||
const [moved] = conns.splice(fromIdx, 1);
|
|
||||||
conns.splice(toIdx, 0, moved);
|
|
||||||
// Persist new order
|
|
||||||
const ids = conns.map(c => c.id);
|
|
||||||
invoke("reorder_connections", { ids }).catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resetDragState();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDragState(): void {
|
|
||||||
draggedGroup = null;
|
|
||||||
draggedConn = null;
|
|
||||||
dragOverGroupId.value = null;
|
|
||||||
dragOverConnId.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// All groups expanded by default
|
// All groups expanded by default
|
||||||
const expandedGroups = ref<Set<number>>(
|
const expandedGroups = ref<Set<number>>(
|
||||||
new Set(connectionStore.groups.map((g) => g.id)),
|
new Set(connectionStore.groups.map((g) => g.id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-expand groups added after initial load
|
|
||||||
watch(() => connectionStore.groups, (newGroups) => {
|
|
||||||
for (const group of newGroups) {
|
|
||||||
if (!expandedGroups.value.has(group.id)) {
|
|
||||||
expandedGroups.value.add(group.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
function toggleGroup(groupId: number): void {
|
function toggleGroup(groupId: number): void {
|
||||||
if (expandedGroups.value.has(groupId)) {
|
if (expandedGroups.value.has(groupId)) {
|
||||||
expandedGroups.value.delete(groupId);
|
expandedGroups.value.delete(groupId);
|
||||||
|
|||||||
@ -5,11 +5,11 @@
|
|||||||
:key="tab.id"
|
:key="tab.id"
|
||||||
class="flex-1 py-2 text-xs font-medium text-center transition-colors cursor-pointer"
|
class="flex-1 py-2 text-xs font-medium text-center transition-colors cursor-pointer"
|
||||||
:class="
|
:class="
|
||||||
model === tab.id
|
modelValue === tab.id
|
||||||
? 'text-[var(--wraith-accent-blue)] border-b-2 border-[var(--wraith-accent-blue)]'
|
? 'text-[var(--wraith-accent-blue)] border-b-2 border-[var(--wraith-accent-blue)]'
|
||||||
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)]'
|
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)]'
|
||||||
"
|
"
|
||||||
@click="model = tab.id"
|
@click="emit('update:modelValue', tab.id)"
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</button>
|
</button>
|
||||||
@ -24,5 +24,11 @@ const tabs = [
|
|||||||
{ id: "sftp" as const, label: "SFTP" },
|
{ id: "sftp" as const, label: "SFTP" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const model = defineModel<SidebarTab>();
|
defineProps<{
|
||||||
|
modelValue: SidebarTab;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [tab: SidebarTab];
|
||||||
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -9,10 +9,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
|
import { ref, onMounted, watch } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useTerminal } from "@/composables/useTerminal";
|
import { useTerminal } from "@/composables/useTerminal";
|
||||||
import { useSessionStore } from "@/stores/session.store";
|
|
||||||
import "@/assets/css/terminal.css";
|
import "@/assets/css/terminal.css";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -20,57 +19,13 @@ const props = defineProps<{
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const sessionStore = useSessionStore();
|
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
const { terminal, mount, fit, destroy } = useTerminal(props.sessionId, "pty");
|
const { terminal, mount, fit } = useTerminal(props.sessionId, "pty");
|
||||||
|
|
||||||
/** Apply the session store's active theme to this local terminal instance. */
|
|
||||||
function applyTheme(): void {
|
|
||||||
const theme = sessionStore.activeTheme;
|
|
||||||
if (!theme) return;
|
|
||||||
terminal.options.theme = {
|
|
||||||
background: theme.background,
|
|
||||||
foreground: theme.foreground,
|
|
||||||
cursor: theme.cursor,
|
|
||||||
cursorAccent: theme.background,
|
|
||||||
selectionBackground: theme.selectionBackground ?? "#264f78",
|
|
||||||
selectionForeground: theme.selectionForeground ?? "#ffffff",
|
|
||||||
selectionInactiveBackground: theme.selectionBackground ?? "#264f78",
|
|
||||||
black: theme.black,
|
|
||||||
red: theme.red,
|
|
||||||
green: theme.green,
|
|
||||||
yellow: theme.yellow,
|
|
||||||
blue: theme.blue,
|
|
||||||
magenta: theme.magenta,
|
|
||||||
cyan: theme.cyan,
|
|
||||||
white: theme.white,
|
|
||||||
brightBlack: theme.brightBlack,
|
|
||||||
brightRed: theme.brightRed,
|
|
||||||
brightGreen: theme.brightGreen,
|
|
||||||
brightYellow: theme.brightYellow,
|
|
||||||
brightBlue: theme.brightBlue,
|
|
||||||
brightMagenta: theme.brightMagenta,
|
|
||||||
brightCyan: theme.brightCyan,
|
|
||||||
brightWhite: theme.brightWhite,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (containerRef.value) {
|
|
||||||
containerRef.value.style.backgroundColor = theme.background;
|
|
||||||
}
|
|
||||||
|
|
||||||
terminal.refresh(0, terminal.rows - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (containerRef.value) {
|
if (containerRef.value) {
|
||||||
mount(containerRef.value);
|
mount(containerRef.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply current theme immediately if one is already active
|
|
||||||
if (sessionStore.activeTheme) {
|
|
||||||
applyTheme();
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fit();
|
fit();
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
@ -86,27 +41,11 @@ watch(
|
|||||||
() => props.isActive,
|
() => props.isActive,
|
||||||
(active) => {
|
(active) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
requestAnimationFrame(() => {
|
setTimeout(() => {
|
||||||
requestAnimationFrame(() => {
|
|
||||||
fit();
|
fit();
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
invoke("pty_resize", {
|
}, 0);
|
||||||
sessionId: props.sessionId,
|
|
||||||
cols: terminal.cols,
|
|
||||||
rows: terminal.rows,
|
|
||||||
}).catch(() => {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Watch for theme changes and apply to this local terminal
|
|
||||||
watch(() => sessionStore.activeTheme, (newTheme) => {
|
|
||||||
if (newTheme) applyTheme();
|
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
destroy();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="stats"
|
v-if="stats"
|
||||||
class="flex items-center gap-4 px-6 h-[48px] bg-[var(--wraith-bg-tertiary)] border-t border-[var(--wraith-border)] text-base font-mono shrink-0 select-none"
|
class="flex items-center gap-4 px-3 h-6 bg-[var(--wraith-bg-tertiary)] border-t border-[var(--wraith-border)] text-[10px] font-mono shrink-0 select-none"
|
||||||
>
|
>
|
||||||
<!-- CPU -->
|
<!-- CPU -->
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
@ -55,7 +55,6 @@ interface SystemStats {
|
|||||||
|
|
||||||
const stats = ref<SystemStats | null>(null);
|
const stats = ref<SystemStats | null>(null);
|
||||||
let unlistenFn: UnlistenFn | null = null;
|
let unlistenFn: UnlistenFn | null = null;
|
||||||
let subscribeGeneration = 0;
|
|
||||||
|
|
||||||
function colorClass(value: number, warnThreshold: number, critThreshold: number): string {
|
function colorClass(value: number, warnThreshold: number, critThreshold: number): string {
|
||||||
if (value >= critThreshold) return "text-[#f85149]"; // red
|
if (value >= critThreshold) return "text-[#f85149]"; // red
|
||||||
@ -71,17 +70,10 @@ function formatBytes(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function subscribe(): Promise<void> {
|
async function subscribe(): Promise<void> {
|
||||||
const gen = ++subscribeGeneration;
|
|
||||||
if (unlistenFn) unlistenFn();
|
if (unlistenFn) unlistenFn();
|
||||||
const fn = await listen<SystemStats>(`ssh:monitor:${props.sessionId}`, (event) => {
|
unlistenFn = await listen<SystemStats>(`ssh:monitor:${props.sessionId}`, (event) => {
|
||||||
stats.value = event.payload;
|
stats.value = event.payload;
|
||||||
});
|
});
|
||||||
if (gen !== subscribeGeneration) {
|
|
||||||
// A newer subscribe() call has already taken over — discard this listener
|
|
||||||
fn();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
unlistenFn = fn;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(subscribe);
|
onMounted(subscribe);
|
||||||
|
|||||||
@ -59,12 +59,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick, onMounted, onBeforeUnmount, watch } from "vue";
|
import { ref, nextTick, onMounted, watch } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { useTerminal } from "@/composables/useTerminal";
|
import { useTerminal } from "@/composables/useTerminal";
|
||||||
import { useSessionStore } from "@/stores/session.store";
|
import { useSessionStore } from "@/stores/session.store";
|
||||||
import MonitorBar from "@/components/terminal/MonitorBar.vue";
|
import MonitorBar from "@/components/terminal/MonitorBar.vue";
|
||||||
import type { IDisposable } from "@xterm/xterm";
|
|
||||||
import "@/assets/css/terminal.css";
|
import "@/assets/css/terminal.css";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -75,11 +73,6 @@ const props = defineProps<{
|
|||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
const { terminal, searchAddon, mount, fit } = useTerminal(props.sessionId);
|
const { terminal, searchAddon, mount, fit } = useTerminal(props.sessionId);
|
||||||
let resizeDisposable: IDisposable | null = null;
|
|
||||||
|
|
||||||
function handleFocus(): void {
|
|
||||||
terminal.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Search state ---
|
// --- Search state ---
|
||||||
const searchVisible = ref(false);
|
const searchVisible = ref(false);
|
||||||
@ -145,7 +138,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Track terminal dimensions in the session store
|
// Track terminal dimensions in the session store
|
||||||
resizeDisposable = terminal.onResize(({ cols, rows }) => {
|
terminal.onResize(({ cols, rows }) => {
|
||||||
sessionStore.setTerminalDimensions(props.sessionId, cols, rows);
|
sessionStore.setTerminalDimensions(props.sessionId, cols, rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -156,27 +149,15 @@ onMounted(() => {
|
|||||||
}, 50);
|
}, 50);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-fit and focus terminal when switching back to this tab.
|
// Re-fit and focus terminal when switching back to this tab
|
||||||
// Must wait for the container to have real dimensions after becoming visible.
|
|
||||||
watch(
|
watch(
|
||||||
() => props.isActive,
|
() => props.isActive,
|
||||||
(active) => {
|
(active) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
// Double rAF ensures the container has been laid out by the browser
|
setTimeout(() => {
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
fit();
|
fit();
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
// Also notify the backend of the correct size
|
}, 0);
|
||||||
const session = sessionStore.sessions.find(s => s.id === props.sessionId);
|
|
||||||
const resizeCmd = session?.protocol === "local" ? "pty_resize" : "ssh_resize";
|
|
||||||
invoke(resizeCmd, {
|
|
||||||
sessionId: props.sessionId,
|
|
||||||
cols: terminal.cols,
|
|
||||||
rows: terminal.rows,
|
|
||||||
}).catch(() => {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -189,10 +170,6 @@ function applyTheme(): void {
|
|||||||
background: theme.background,
|
background: theme.background,
|
||||||
foreground: theme.foreground,
|
foreground: theme.foreground,
|
||||||
cursor: theme.cursor,
|
cursor: theme.cursor,
|
||||||
cursorAccent: theme.background,
|
|
||||||
selectionBackground: theme.selectionBackground ?? "#264f78",
|
|
||||||
selectionForeground: theme.selectionForeground ?? "#ffffff",
|
|
||||||
selectionInactiveBackground: theme.selectionBackground ?? "#264f78",
|
|
||||||
black: theme.black,
|
black: theme.black,
|
||||||
red: theme.red,
|
red: theme.red,
|
||||||
green: theme.green,
|
green: theme.green,
|
||||||
@ -210,27 +187,14 @@ function applyTheme(): void {
|
|||||||
brightCyan: theme.brightCyan,
|
brightCyan: theme.brightCyan,
|
||||||
brightWhite: theme.brightWhite,
|
brightWhite: theme.brightWhite,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync the container background so areas outside the canvas match the theme
|
|
||||||
if (containerRef.value) {
|
|
||||||
containerRef.value.style.backgroundColor = theme.background;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force xterm.js to repaint all visible rows with the new theme colors
|
|
||||||
terminal.refresh(0, terminal.rows - 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for theme changes in the session store and apply to this terminal.
|
// Watch for theme changes in the session store and apply to this terminal
|
||||||
// Uses deep comparison because the theme is an object — a shallow watch may miss
|
|
||||||
// updates if Pinia returns the same reactive proxy wrapper after reassignment.
|
|
||||||
watch(() => sessionStore.activeTheme, (newTheme) => {
|
watch(() => sessionStore.activeTheme, (newTheme) => {
|
||||||
if (newTheme) applyTheme();
|
if (newTheme) applyTheme();
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (resizeDisposable) {
|
|
||||||
resizeDisposable.dispose();
|
|
||||||
resizeDisposable = null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleFocus(): void {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<ToolShell ref="shell" placeholder="Select a mode and click Run Test">
|
<div class="flex flex-col h-full p-4 gap-3">
|
||||||
<template #default="{ running }">
|
<div class="flex items-center gap-2">
|
||||||
<select v-model="mode" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer">
|
<select v-model="mode" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer">
|
||||||
<option value="speedtest">Internet Speed Test</option>
|
<option value="speedtest">Internet Speed Test</option>
|
||||||
<option value="iperf">iperf3 (LAN)</option>
|
<option value="iperf">iperf3 (LAN)</option>
|
||||||
@ -13,31 +13,32 @@
|
|||||||
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="run">
|
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="run">
|
||||||
{{ running ? "Testing..." : "Run Test" }}
|
{{ running ? "Testing..." : "Run Test" }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</div>
|
||||||
</ToolShell>
|
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Select a mode and click Run Test" }}</pre>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import ToolShell from "./ToolShell.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{ sessionId: string }>();
|
const props = defineProps<{ sessionId: string }>();
|
||||||
const mode = ref("speedtest");
|
const mode = ref("speedtest");
|
||||||
const server = ref("");
|
const server = ref("");
|
||||||
const duration = ref(5);
|
const duration = ref(5);
|
||||||
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
|
const output = ref("");
|
||||||
|
const running = ref(false);
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
if (mode.value === "iperf" && !server.value) {
|
running.value = true;
|
||||||
shell.value?.setOutput("Enter an iperf3 server IP");
|
output.value = mode.value === "iperf" ? `Running iperf3 to ${server.value}...\n` : "Running speed test...\n";
|
||||||
return;
|
try {
|
||||||
}
|
|
||||||
shell.value?.execute(() => {
|
|
||||||
if (mode.value === "iperf") {
|
if (mode.value === "iperf") {
|
||||||
return invoke<string>("tool_bandwidth_iperf", { sessionId: props.sessionId, server: server.value, duration: duration.value });
|
if (!server.value) { output.value = "Enter an iperf3 server IP"; running.value = false; return; }
|
||||||
|
output.value = await invoke<string>("tool_bandwidth_iperf", { sessionId: props.sessionId, server: server.value, duration: duration.value });
|
||||||
|
} else {
|
||||||
|
output.value = await invoke<string>("tool_bandwidth_speedtest", { sessionId: props.sessionId });
|
||||||
}
|
}
|
||||||
return invoke<string>("tool_bandwidth_speedtest", { sessionId: props.sessionId });
|
} catch (err) { output.value = String(err); }
|
||||||
});
|
running.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,29 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<ToolShell ref="shell" placeholder="Enter a domain and click Lookup">
|
<div class="flex flex-col h-full p-4 gap-3">
|
||||||
<template #default="{ running }">
|
<div class="flex items-center gap-2">
|
||||||
<input v-model="domain" type="text" placeholder="Domain name" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="lookup" />
|
<input v-model="domain" type="text" placeholder="Domain name" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="lookup" />
|
||||||
<select v-model="recordType" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer">
|
<select v-model="recordType" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none cursor-pointer">
|
||||||
<option v-for="t in ['A','AAAA','MX','NS','TXT','CNAME','SOA','SRV','PTR']" :key="t" :value="t">{{ t }}</option>
|
<option v-for="t in ['A','AAAA','MX','NS','TXT','CNAME','SOA','SRV','PTR']" :key="t" :value="t">{{ t }}</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Lookup</button>
|
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Lookup</button>
|
||||||
</template>
|
</div>
|
||||||
</ToolShell>
|
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a domain and click Lookup" }}</pre>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import ToolShell from "./ToolShell.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{ sessionId: string }>();
|
const props = defineProps<{ sessionId: string }>();
|
||||||
const domain = ref("");
|
const domain = ref("");
|
||||||
const recordType = ref("A");
|
const recordType = ref("A");
|
||||||
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
|
const output = ref("");
|
||||||
|
const running = ref(false);
|
||||||
|
|
||||||
async function lookup(): Promise<void> {
|
async function lookup(): Promise<void> {
|
||||||
if (!domain.value) return;
|
if (!domain.value) return;
|
||||||
shell.value?.execute(() =>
|
running.value = true;
|
||||||
invoke<string>("tool_dns_lookup", { sessionId: props.sessionId, domain: domain.value, recordType: recordType.value })
|
try {
|
||||||
);
|
output.value = await invoke<string>("tool_dns_lookup", { sessionId: props.sessionId, domain: domain.value, recordType: recordType.value });
|
||||||
|
} catch (err) { output.value = String(err); }
|
||||||
|
running.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,117 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col h-full p-4 gap-3">
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button v-for="t in ['containers','images','volumes']" :key="t"
|
|
||||||
class="px-3 py-1 text-xs rounded cursor-pointer transition-colors"
|
|
||||||
:class="tab === t ? 'bg-[#58a6ff] text-black font-bold' : 'bg-[#21262d] text-[#8b949e] hover:text-white'"
|
|
||||||
@click="tab = t; refresh()"
|
|
||||||
>{{ t.charAt(0).toUpperCase() + t.slice(1) }}</button>
|
|
||||||
|
|
||||||
<div class="ml-auto flex gap-1">
|
|
||||||
<button class="px-2 py-1 text-[10px] rounded bg-[#21262d] text-[#8b949e] hover:text-white cursor-pointer" @click="refresh">Refresh</button>
|
|
||||||
<button class="px-2 py-1 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('builder-prune', '')">Builder Prune</button>
|
|
||||||
<button class="px-2 py-1 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('system-prune', '')">System Prune</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Containers -->
|
|
||||||
<div v-if="tab === 'containers'" class="flex-1 overflow-auto border border-[#30363d] rounded">
|
|
||||||
<table class="w-full text-xs"><thead class="bg-[#161b22] sticky top-0"><tr>
|
|
||||||
<th class="text-left px-3 py-2 text-[#8b949e]">Name</th>
|
|
||||||
<th class="text-left px-3 py-2 text-[#8b949e]">Image</th>
|
|
||||||
<th class="text-left px-3 py-2 text-[#8b949e]">Status</th>
|
|
||||||
<th class="text-left px-3 py-2 text-[#8b949e]">Actions</th>
|
|
||||||
</tr></thead><tbody>
|
|
||||||
<tr v-for="c in containers" :key="c.id" class="border-t border-[#21262d] hover:bg-[#161b22]">
|
|
||||||
<td class="px-3 py-1.5 font-mono">{{ c.name }}</td>
|
|
||||||
<td class="px-3 py-1.5 text-[#8b949e]">{{ c.image }}</td>
|
|
||||||
<td class="px-3 py-1.5" :class="c.status.startsWith('Up') ? 'text-[#3fb950]' : 'text-[#8b949e]'">{{ c.status }}</td>
|
|
||||||
<td class="px-3 py-1.5 flex gap-1">
|
|
||||||
<button v-if="!c.status.startsWith('Up')" class="px-1.5 py-0.5 text-[10px] rounded bg-[#238636] text-white cursor-pointer" @click="action('start', c.name)">Start</button>
|
|
||||||
<button v-if="c.status.startsWith('Up')" class="px-1.5 py-0.5 text-[10px] rounded bg-[#e3b341] text-black cursor-pointer" @click="action('stop', c.name)">Stop</button>
|
|
||||||
<button class="px-1.5 py-0.5 text-[10px] rounded bg-[#1f6feb] text-white cursor-pointer" @click="action('restart', c.name)">Restart</button>
|
|
||||||
<button class="px-1.5 py-0.5 text-[10px] rounded bg-[#21262d] text-[#8b949e] cursor-pointer" @click="viewLogs(c.name)">Logs</button>
|
|
||||||
<button class="px-1.5 py-0.5 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('remove', c.name)">Remove</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Images -->
|
|
||||||
<div v-if="tab === 'images'" class="flex-1 overflow-auto border border-[#30363d] rounded">
|
|
||||||
<table class="w-full text-xs"><thead class="bg-[#161b22] sticky top-0"><tr>
|
|
||||||
<th class="text-left px-3 py-2 text-[#8b949e]">Repository</th>
|
|
||||||
<th class="text-left px-3 py-2 text-[#8b949e]">Tag</th>
|
|
||||||
<th class="text-left px-3 py-2 text-[#8b949e]">Size</th>
|
|
||||||
<th class="text-left px-3 py-2 text-[#8b949e]">Actions</th>
|
|
||||||
</tr></thead><tbody>
|
|
||||||
<tr v-for="img in images" :key="img.id" class="border-t border-[#21262d] hover:bg-[#161b22]">
|
|
||||||
<td class="px-3 py-1.5 font-mono">{{ img.repository }}</td>
|
|
||||||
<td class="px-3 py-1.5">{{ img.tag }}</td>
|
|
||||||
<td class="px-3 py-1.5 text-[#8b949e]">{{ img.size }}</td>
|
|
||||||
<td class="px-3 py-1.5"><button class="px-1.5 py-0.5 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('remove-image', img.id)">Remove</button></td>
|
|
||||||
</tr>
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Volumes -->
|
|
||||||
<div v-if="tab === 'volumes'" class="flex-1 overflow-auto border border-[#30363d] rounded">
|
|
||||||
<table class="w-full text-xs"><thead class="bg-[#161b22] sticky top-0"><tr>
|
|
||||||
<th class="text-left px-3 py-2 text-[#8b949e]">Name</th>
|
|
||||||
<th class="text-left px-3 py-2 text-[#8b949e]">Driver</th>
|
|
||||||
<th class="text-left px-3 py-2 text-[#8b949e]">Actions</th>
|
|
||||||
</tr></thead><tbody>
|
|
||||||
<tr v-for="v in volumes" :key="v.name" class="border-t border-[#21262d] hover:bg-[#161b22]">
|
|
||||||
<td class="px-3 py-1.5 font-mono">{{ v.name }}</td>
|
|
||||||
<td class="px-3 py-1.5 text-[#8b949e]">{{ v.driver }}</td>
|
|
||||||
<td class="px-3 py-1.5"><button class="px-1.5 py-0.5 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('remove-volume', v.name)">Remove</button></td>
|
|
||||||
</tr>
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Output -->
|
|
||||||
<pre v-if="output" class="max-h-32 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-2 text-[10px] font-mono text-[#e0e0e0]">{{ output }}</pre>
|
|
||||||
|
|
||||||
<div class="text-[10px] text-[#484f58]">{{ containers.length }} containers · {{ images.length }} images · {{ volumes.length }} volumes</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from "vue";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
|
|
||||||
interface DockerContainer { id: string; name: string; image: string; status: string; ports: string; }
|
|
||||||
interface DockerImage { repository: string; tag: string; id: string; size: string; }
|
|
||||||
interface DockerVolume { name: string; driver: string; mountpoint: string; }
|
|
||||||
|
|
||||||
const props = defineProps<{ sessionId: string }>();
|
|
||||||
|
|
||||||
const tab = ref("containers");
|
|
||||||
const containers = ref<DockerContainer[]>([]);
|
|
||||||
const images = ref<DockerImage[]>([]);
|
|
||||||
const volumes = ref<DockerVolume[]>([]);
|
|
||||||
const output = ref("");
|
|
||||||
|
|
||||||
async function refresh(): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (tab.value === "containers") containers.value = await invoke("docker_list_containers", { sessionId: props.sessionId, all: true });
|
|
||||||
if (tab.value === "images") images.value = await invoke("docker_list_images", { sessionId: props.sessionId });
|
|
||||||
if (tab.value === "volumes") volumes.value = await invoke("docker_list_volumes", { sessionId: props.sessionId });
|
|
||||||
} catch (err) { output.value = String(err); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function action(act: string, target: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
output.value = await invoke<string>("docker_action", { sessionId: props.sessionId, action: act, target });
|
|
||||||
await refresh();
|
|
||||||
} catch (err) { output.value = String(err); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function viewLogs(name: string): Promise<void> {
|
|
||||||
try { output.value = await invoke<string>("docker_action", { sessionId: props.sessionId, action: "logs", target: name }); }
|
|
||||||
catch (err) { output.value = String(err); }
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(refresh);
|
|
||||||
</script>
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col h-full bg-[#0d1117]">
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<div class="flex items-center gap-2 px-3 py-2 bg-[#161b22] border-b border-[#30363d] shrink-0">
|
|
||||||
<span class="text-xs text-[#8b949e] font-mono truncate flex-1">{{ filePath }}</span>
|
|
||||||
<span v-if="modified" class="text-[10px] text-[#e3b341]">modified</span>
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 text-xs font-bold rounded bg-[#238636] text-white cursor-pointer disabled:opacity-40"
|
|
||||||
:disabled="saving || !modified"
|
|
||||||
@click="save"
|
|
||||||
>
|
|
||||||
{{ saving ? "Saving..." : "Save" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Editor area -->
|
|
||||||
<div ref="editorContainer" class="flex-1 min-h-0" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from "vue";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
sessionId: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const filePath = ref("");
|
|
||||||
const content = ref("");
|
|
||||||
const modified = ref(false);
|
|
||||||
const saving = ref(false);
|
|
||||||
const editorContainer = ref<HTMLElement | null>(null);
|
|
||||||
let editorContent = "";
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
// Parse path from URL
|
|
||||||
const params = new URLSearchParams(window.location.hash.split("?")[1] || "");
|
|
||||||
filePath.value = decodeURIComponent(params.get("path") || "");
|
|
||||||
|
|
||||||
if (!filePath.value || !props.sessionId) return;
|
|
||||||
|
|
||||||
// Load file content
|
|
||||||
try {
|
|
||||||
content.value = await invoke<string>("sftp_read_file", {
|
|
||||||
sessionId: props.sessionId,
|
|
||||||
path: filePath.value,
|
|
||||||
});
|
|
||||||
editorContent = content.value;
|
|
||||||
} catch (err) {
|
|
||||||
content.value = `Error loading file: ${err}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a simple textarea editor (CodeMirror can be added later)
|
|
||||||
if (editorContainer.value) {
|
|
||||||
const textarea = document.createElement("textarea");
|
|
||||||
textarea.value = content.value;
|
|
||||||
textarea.spellcheck = false;
|
|
||||||
textarea.style.cssText = `
|
|
||||||
width: 100%; height: 100%; resize: none; border: none; outline: none;
|
|
||||||
background: #0d1117; color: #e0e0e0; padding: 12px; font-size: 13px;
|
|
||||||
font-family: 'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', monospace;
|
|
||||||
line-height: 1.5; tab-size: 4;
|
|
||||||
`;
|
|
||||||
textarea.addEventListener("input", () => {
|
|
||||||
editorContent = textarea.value;
|
|
||||||
modified.value = editorContent !== content.value;
|
|
||||||
});
|
|
||||||
textarea.addEventListener("keydown", (e) => {
|
|
||||||
// Ctrl+S to save
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
||||||
e.preventDefault();
|
|
||||||
save();
|
|
||||||
}
|
|
||||||
// Tab inserts spaces
|
|
||||||
if (e.key === "Tab") {
|
|
||||||
e.preventDefault();
|
|
||||||
const start = textarea.selectionStart;
|
|
||||||
const end = textarea.selectionEnd;
|
|
||||||
textarea.value = textarea.value.substring(0, start) + " " + textarea.value.substring(end);
|
|
||||||
textarea.selectionStart = textarea.selectionEnd = start + 4;
|
|
||||||
editorContent = textarea.value;
|
|
||||||
modified.value = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
editorContainer.value.appendChild(textarea);
|
|
||||||
textarea.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function save(): Promise<void> {
|
|
||||||
if (!modified.value || saving.value) return;
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
await invoke("sftp_write_file", {
|
|
||||||
sessionId: props.sessionId,
|
|
||||||
path: filePath.value,
|
|
||||||
content: editorContent,
|
|
||||||
});
|
|
||||||
content.value = editorContent;
|
|
||||||
modified.value = false;
|
|
||||||
} catch (err) {
|
|
||||||
alert(`Save failed: ${err}`);
|
|
||||||
}
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@ -1,219 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="flex items-center gap-1 px-4 py-2 bg-[#161b22] border-b border-[#30363d] shrink-0">
|
|
||||||
<button v-for="t in tabs" :key="t.id"
|
|
||||||
class="px-3 py-1 text-xs rounded cursor-pointer transition-colors"
|
|
||||||
:class="activeTab === t.id ? 'bg-[#58a6ff] text-black font-bold' : 'text-[#8b949e] hover:text-white'"
|
|
||||||
@click="activeTab = t.id"
|
|
||||||
>{{ t.label }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-auto p-6">
|
|
||||||
<!-- Getting Started -->
|
|
||||||
<div v-if="activeTab === 'guide'" class="prose-wraith">
|
|
||||||
<h2>Getting Started with Wraith</h2>
|
|
||||||
<p>Wraith is a native desktop SSH/SFTP/RDP client with an integrated AI copilot.</p>
|
|
||||||
|
|
||||||
<h3>Creating a Connection</h3>
|
|
||||||
<ol>
|
|
||||||
<li>Click <strong>File → New Connection</strong> or the <strong>+ Host</strong> button in the sidebar</li>
|
|
||||||
<li>Enter hostname, port, and protocol (SSH or RDP)</li>
|
|
||||||
<li>Optionally link a credential from the vault</li>
|
|
||||||
<li>Double-click the connection to connect</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3>Quick Connect</h3>
|
|
||||||
<p>Type <code>user@host:port</code> in the Quick Connect bar and press Enter.</p>
|
|
||||||
|
|
||||||
<h3>AI Copilot</h3>
|
|
||||||
<p>Press <strong>Ctrl+Shift+G</strong> to open the AI copilot panel. Select a shell, click Launch, and run your AI CLI (Claude Code, Gemini, Codex).</p>
|
|
||||||
<p>Configure one-click launch presets in <strong>Settings → AI Copilot</strong>.</p>
|
|
||||||
|
|
||||||
<h3>Local Terminals</h3>
|
|
||||||
<p>Click the <strong>+</strong> button in the tab bar to open a local shell (PowerShell, CMD, Git Bash, WSL, bash, zsh).</p>
|
|
||||||
|
|
||||||
<h3>SFTP Browser</h3>
|
|
||||||
<p>Switch to the <strong>SFTP</strong> tab in the sidebar. It follows the active SSH session and tracks the current working directory.</p>
|
|
||||||
<p>Right-click files for Edit, Download, Rename, Delete.</p>
|
|
||||||
|
|
||||||
<h3>Tab Management</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Drag tabs</strong> to reorder</li>
|
|
||||||
<li><strong>Right-click tab</strong> → Detach to Window (pop out to separate window)</li>
|
|
||||||
<li>Close the detached window to reattach</li>
|
|
||||||
<li>Tabs pulse blue when there's new activity in the background</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Remote Monitoring</h3>
|
|
||||||
<p>Every SSH session shows a monitoring bar at the bottom with CPU, RAM, disk, and network stats — polled every 5 seconds. No agent needed.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Keyboard Shortcuts -->
|
|
||||||
<div v-if="activeTab === 'shortcuts'" class="prose-wraith">
|
|
||||||
<h2>Keyboard Shortcuts</h2>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Shortcut</th><th>Action</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><kbd>Ctrl+K</kbd></td><td>Command Palette</td></tr>
|
|
||||||
<tr><td><kbd>Ctrl+Shift+G</kbd></td><td>Toggle AI Copilot</td></tr>
|
|
||||||
<tr><td><kbd>Ctrl+B</kbd></td><td>Toggle Sidebar</td></tr>
|
|
||||||
<tr><td><kbd>Ctrl+W</kbd></td><td>Close Active Tab</td></tr>
|
|
||||||
<tr><td><kbd>Ctrl+Tab</kbd></td><td>Next Tab</td></tr>
|
|
||||||
<tr><td><kbd>Ctrl+Shift+Tab</kbd></td><td>Previous Tab</td></tr>
|
|
||||||
<tr><td><kbd>Ctrl+1-9</kbd></td><td>Switch to Tab N</td></tr>
|
|
||||||
<tr><td><kbd>Ctrl+F</kbd></td><td>Find in Terminal</td></tr>
|
|
||||||
<tr><td><kbd>Ctrl+S</kbd></td><td>Save (in editor windows)</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Terminal</h3>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Action</th><th>How</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>Copy</td><td>Select text (auto-copies)</td></tr>
|
|
||||||
<tr><td>Paste</td><td>Right-click</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- MCP Integration -->
|
|
||||||
<div v-if="activeTab === 'mcp'" class="prose-wraith">
|
|
||||||
<h2>MCP Integration (AI Tool Access)</h2>
|
|
||||||
<p>Wraith includes an MCP (Model Context Protocol) server that gives AI CLI tools programmatic access to your active sessions.</p>
|
|
||||||
|
|
||||||
<h3>Setup</h3>
|
|
||||||
<p>The MCP bridge binary is automatically downloaded to:</p>
|
|
||||||
<pre>{{ bridgePath }}</pre>
|
|
||||||
<p>Register with Claude Code:</p>
|
|
||||||
<pre>claude mcp add wraith -- "{{ bridgePath }}"</pre>
|
|
||||||
|
|
||||||
<h3>Available MCP Tools (18)</h3>
|
|
||||||
|
|
||||||
<h4>Session Management</h4>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><code>list_sessions</code></td><td>List all active SSH/RDP/PTY sessions</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h4>Terminal</h4>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><code>terminal_read</code></td><td>Read recent terminal output (ANSI stripped)</td></tr>
|
|
||||||
<tr><td><code>terminal_execute</code></td><td>Run a command and capture output</td></tr>
|
|
||||||
<tr><td><code>terminal_screenshot</code></td><td>Capture RDP frame as PNG</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h4>SFTP</h4>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><code>sftp_list</code></td><td>List remote directory</td></tr>
|
|
||||||
<tr><td><code>sftp_read</code></td><td>Read remote file</td></tr>
|
|
||||||
<tr><td><code>sftp_write</code></td><td>Write remote file</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h4>Network</h4>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><code>network_scan</code></td><td>ARP + ping sweep subnet discovery</td></tr>
|
|
||||||
<tr><td><code>port_scan</code></td><td>TCP port scan</td></tr>
|
|
||||||
<tr><td><code>ping</code></td><td>Ping a host</td></tr>
|
|
||||||
<tr><td><code>traceroute</code></td><td>Traceroute to host</td></tr>
|
|
||||||
<tr><td><code>dns_lookup</code></td><td>DNS query (A, MX, TXT, etc.)</td></tr>
|
|
||||||
<tr><td><code>whois</code></td><td>Whois lookup</td></tr>
|
|
||||||
<tr><td><code>wake_on_lan</code></td><td>Send WoL magic packet</td></tr>
|
|
||||||
<tr><td><code>bandwidth_test</code></td><td>Internet speed test</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h4>Utilities (no session needed)</h4>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td><code>subnet_calc</code></td><td>Subnet calculator</td></tr>
|
|
||||||
<tr><td><code>generate_ssh_key</code></td><td>Generate SSH key pair</td></tr>
|
|
||||||
<tr><td><code>generate_password</code></td><td>Generate secure password</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>How It Works</h3>
|
|
||||||
<ol>
|
|
||||||
<li>Wraith starts an HTTP server on <code>localhost</code> (random port)</li>
|
|
||||||
<li>Port written to <code>mcp-port</code> in data directory</li>
|
|
||||||
<li>Bridge binary reads the port and proxies JSON-RPC over stdio</li>
|
|
||||||
<li>AI CLI spawns the bridge as an MCP server</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- About -->
|
|
||||||
<div v-if="activeTab === 'about'" class="prose-wraith">
|
|
||||||
<h2>About Wraith</h2>
|
|
||||||
<p class="text-2xl font-bold tracking-widest text-[#58a6ff]">WRAITH</p>
|
|
||||||
<p>Exists everywhere, all at once.</p>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>Version</td><td>{{ version }}</td></tr>
|
|
||||||
<tr><td>Runtime</td><td>Tauri v2 + Rust</td></tr>
|
|
||||||
<tr><td>Frontend</td><td>Vue 3 + TypeScript</td></tr>
|
|
||||||
<tr><td>Terminal</td><td>xterm.js 6</td></tr>
|
|
||||||
<tr><td>SSH</td><td>russh 0.48</td></tr>
|
|
||||||
<tr><td>RDP</td><td>ironrdp 0.14</td></tr>
|
|
||||||
<tr><td>License</td><td>Proprietary</td></tr>
|
|
||||||
<tr><td>Publisher</td><td>Vigilance Cyber / Vigilsynth</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from "vue";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: "guide", label: "Getting Started" },
|
|
||||||
{ id: "shortcuts", label: "Shortcuts" },
|
|
||||||
{ id: "mcp", label: "MCP Integration" },
|
|
||||||
{ id: "about", label: "About" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeTab = ref("guide");
|
|
||||||
const bridgePath = ref("loading...");
|
|
||||||
const version = ref("loading...");
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
// Read initial tab from URL
|
|
||||||
const params = new URLSearchParams(window.location.hash.split("?")[1] || "");
|
|
||||||
const page = params.get("page");
|
|
||||||
if (page && tabs.some(t => t.id === page)) activeTab.value = page;
|
|
||||||
|
|
||||||
try { version.value = await getVersion(); } catch { version.value = "unknown"; }
|
|
||||||
try { bridgePath.value = await invoke<string>("mcp_bridge_path"); } catch { bridgePath.value = "unknown"; }
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.prose-wraith h2 { font-size: 16px; font-weight: 700; color: #e0e0e0; margin-bottom: 12px; }
|
|
||||||
.prose-wraith h3 { font-size: 13px; font-weight: 600; color: #8b949e; margin-top: 20px; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
||||||
.prose-wraith h4 { font-size: 12px; font-weight: 600; color: #58a6ff; margin-top: 16px; margin-bottom: 6px; }
|
|
||||||
.prose-wraith p { font-size: 12px; color: #8b949e; margin-bottom: 8px; line-height: 1.6; }
|
|
||||||
.prose-wraith ol, .prose-wraith ul { font-size: 12px; color: #8b949e; margin-bottom: 8px; padding-left: 20px; }
|
|
||||||
.prose-wraith li { margin-bottom: 4px; line-height: 1.5; }
|
|
||||||
.prose-wraith code { background: #161b22; border: 1px solid #30363d; border-radius: 4px; padding: 1px 5px; font-size: 11px; color: #e0e0e0; }
|
|
||||||
.prose-wraith pre { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 10px 14px; font-size: 11px; color: #e0e0e0; overflow-x: auto; margin-bottom: 8px; font-family: 'Cascadia Mono', monospace; }
|
|
||||||
.prose-wraith kbd { background: #21262d; border: 1px solid #484f58; border-radius: 3px; padding: 1px 5px; font-size: 10px; color: #e0e0e0; }
|
|
||||||
.prose-wraith table { width: 100%; font-size: 12px; border-collapse: collapse; margin-bottom: 12px; }
|
|
||||||
.prose-wraith th { text-align: left; padding: 6px 10px; background: #161b22; color: #8b949e; font-weight: 500; border-bottom: 1px solid #30363d; }
|
|
||||||
.prose-wraith td { padding: 5px 10px; color: #e0e0e0; border-bottom: 1px solid #21262d; }
|
|
||||||
.prose-wraith strong { color: #e0e0e0; }
|
|
||||||
</style>
|
|
||||||
@ -85,6 +85,5 @@ function exportCsv(): void {
|
|||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = `wraith-scan-${subnet.value}-${Date.now()}.csv`;
|
a.download = `wraith-scan-${subnet.value}-${Date.now()}.csv`;
|
||||||
a.click();
|
a.click();
|
||||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,28 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<ToolShell ref="shell" placeholder="Enter a host and click Ping">
|
<div class="flex flex-col h-full p-4 gap-3">
|
||||||
<template #default="{ running }">
|
<div class="flex items-center gap-2">
|
||||||
<input v-model="target" type="text" placeholder="Host to ping" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="ping" />
|
<input v-model="target" type="text" placeholder="Host to ping" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="ping" />
|
||||||
<input v-model.number="count" type="number" min="1" max="100" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-16" />
|
<input v-model.number="count" type="number" min="1" max="100" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] w-16" />
|
||||||
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="ping">Ping</button>
|
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="ping">Ping</button>
|
||||||
</template>
|
</div>
|
||||||
</ToolShell>
|
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a host and click Ping" }}</pre>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import ToolShell from "./ToolShell.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{ sessionId: string }>();
|
const props = defineProps<{ sessionId: string }>();
|
||||||
const target = ref("");
|
const target = ref("");
|
||||||
const count = ref(4);
|
const count = ref(4);
|
||||||
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
|
const output = ref("");
|
||||||
|
const running = ref(false);
|
||||||
|
|
||||||
async function ping(): Promise<void> {
|
async function ping(): Promise<void> {
|
||||||
if (!target.value) return;
|
if (!target.value) return;
|
||||||
shell.value?.execute(async () => {
|
running.value = true;
|
||||||
|
output.value = `Pinging ${target.value}...\n`;
|
||||||
|
try {
|
||||||
const result = await invoke<{ target: string; output: string }>("tool_ping", { sessionId: props.sessionId, target: target.value, count: count.value });
|
const result = await invoke<{ target: string; output: string }>("tool_ping", { sessionId: props.sessionId, target: target.value, count: count.value });
|
||||||
return result.output;
|
output.value = result.output;
|
||||||
});
|
} catch (err) { output.value = String(err); }
|
||||||
|
running.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -36,13 +36,9 @@
|
|||||||
<textarea readonly :value="key.privateKey" rows="8" class="w-full px-3 py-2 text-xs font-mono rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] resize-none" />
|
<textarea readonly :value="key.privateKey" rows="8" class="w-full px-3 py-2 text-xs font-mono rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] resize-none" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="text-xs text-[#8b949e]">
|
<div class="text-xs text-[#8b949e]">
|
||||||
Fingerprint: <span class="font-mono text-[#e0e0e0]">{{ key.fingerprint }}</span>
|
Fingerprint: <span class="font-mono text-[#e0e0e0]">{{ key.fingerprint }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="px-3 py-1 text-xs rounded bg-[#58a6ff] text-black font-bold cursor-pointer" @click="savePrivateKey">Save Private Key</button>
|
|
||||||
<button class="px-3 py-1 text-xs rounded border border-[#30363d] text-[#8b949e] hover:text-white cursor-pointer" @click="savePublicKey">Save Public Key</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -66,25 +62,4 @@ async function generate(): Promise<void> {
|
|||||||
function copy(text: string): void {
|
function copy(text: string): void {
|
||||||
navigator.clipboard.writeText(text).catch(() => {});
|
navigator.clipboard.writeText(text).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveFile(content: string, filename: string): void {
|
|
||||||
const blob = new Blob([content], { type: "text/plain" });
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(a.href);
|
|
||||||
}
|
|
||||||
|
|
||||||
function savePrivateKey(): void {
|
|
||||||
if (!key.value) return;
|
|
||||||
const ext = key.value.keyType === "ed25519" ? "id_ed25519" : "id_rsa";
|
|
||||||
saveFile(key.value.privateKey, ext);
|
|
||||||
}
|
|
||||||
|
|
||||||
function savePublicKey(): void {
|
|
||||||
if (!key.value) return;
|
|
||||||
const ext = key.value.keyType === "ed25519" ? "id_ed25519.pub" : "id_rsa.pub";
|
|
||||||
saveFile(key.value.publicKey, ext);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from "vue";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
placeholder?: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const output = ref("");
|
|
||||||
const running = ref(false);
|
|
||||||
|
|
||||||
async function execute(fn: () => Promise<string>): Promise<void> {
|
|
||||||
running.value = true;
|
|
||||||
output.value = "";
|
|
||||||
try {
|
|
||||||
output.value = await fn();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
output.value = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
||||||
} finally {
|
|
||||||
running.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setOutput(value: string): void {
|
|
||||||
output.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ execute, setOutput, output, running });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col h-full p-4 gap-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<slot :running="running" />
|
|
||||||
</div>
|
|
||||||
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || placeholder || "Ready." }}</pre>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -9,11 +9,8 @@
|
|||||||
<WhoisTool v-else-if="tool === 'whois'" :session-id="sessionId" />
|
<WhoisTool v-else-if="tool === 'whois'" :session-id="sessionId" />
|
||||||
<BandwidthTest v-else-if="tool === 'bandwidth'" :session-id="sessionId" />
|
<BandwidthTest v-else-if="tool === 'bandwidth'" :session-id="sessionId" />
|
||||||
<SubnetCalc v-else-if="tool === 'subnet-calc'" />
|
<SubnetCalc v-else-if="tool === 'subnet-calc'" />
|
||||||
<DockerPanel v-else-if="tool === 'docker'" :session-id="sessionId" />
|
|
||||||
<FileEditor v-else-if="tool === 'editor'" :session-id="sessionId" />
|
|
||||||
<SshKeyGen v-else-if="tool === 'ssh-keygen'" />
|
<SshKeyGen v-else-if="tool === 'ssh-keygen'" />
|
||||||
<PasswordGen v-else-if="tool === 'password-gen'" />
|
<PasswordGen v-else-if="tool === 'password-gen'" />
|
||||||
<HelpWindow v-else-if="tool === 'help'" />
|
|
||||||
<div v-else class="flex-1 flex items-center justify-center text-sm text-[#484f58]">
|
<div v-else class="flex-1 flex items-center justify-center text-sm text-[#484f58]">
|
||||||
Unknown tool: {{ tool }}
|
Unknown tool: {{ tool }}
|
||||||
</div>
|
</div>
|
||||||
@ -30,11 +27,8 @@ import DnsLookup from "./DnsLookup.vue";
|
|||||||
import WhoisTool from "./WhoisTool.vue";
|
import WhoisTool from "./WhoisTool.vue";
|
||||||
import BandwidthTest from "./BandwidthTest.vue";
|
import BandwidthTest from "./BandwidthTest.vue";
|
||||||
import SubnetCalc from "./SubnetCalc.vue";
|
import SubnetCalc from "./SubnetCalc.vue";
|
||||||
import DockerPanel from "./DockerPanel.vue";
|
|
||||||
import FileEditor from "./FileEditor.vue";
|
|
||||||
import SshKeyGen from "./SshKeyGen.vue";
|
import SshKeyGen from "./SshKeyGen.vue";
|
||||||
import PasswordGen from "./PasswordGen.vue";
|
import PasswordGen from "./PasswordGen.vue";
|
||||||
import HelpWindow from "./HelpWindow.vue";
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
tool: string;
|
tool: string;
|
||||||
|
|||||||
@ -1,25 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<ToolShell ref="shell" placeholder="Enter a host and click Trace">
|
<div class="flex flex-col h-full p-4 gap-3">
|
||||||
<template #default="{ running }">
|
<div class="flex items-center gap-2">
|
||||||
<input v-model="target" type="text" placeholder="Host to trace" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="trace" />
|
<input v-model="target" type="text" placeholder="Host to trace" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="trace" />
|
||||||
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="trace">Trace</button>
|
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="trace">Trace</button>
|
||||||
</template>
|
</div>
|
||||||
</ToolShell>
|
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a host and click Trace" }}</pre>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import ToolShell from "./ToolShell.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{ sessionId: string }>();
|
const props = defineProps<{ sessionId: string }>();
|
||||||
const target = ref("");
|
const target = ref("");
|
||||||
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
|
const output = ref("");
|
||||||
|
const running = ref(false);
|
||||||
|
|
||||||
async function trace(): Promise<void> {
|
async function trace(): Promise<void> {
|
||||||
if (!target.value) return;
|
if (!target.value) return;
|
||||||
shell.value?.execute(() =>
|
running.value = true;
|
||||||
invoke<string>("tool_traceroute", { sessionId: props.sessionId, target: target.value })
|
output.value = `Tracing route to ${target.value}...\n`;
|
||||||
);
|
try {
|
||||||
|
output.value = await invoke<string>("tool_traceroute", { sessionId: props.sessionId, target: target.value });
|
||||||
|
} catch (err) { output.value = String(err); }
|
||||||
|
running.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,25 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<ToolShell ref="shell" placeholder="Enter a domain or IP and click Whois">
|
<div class="flex flex-col h-full p-4 gap-3">
|
||||||
<template #default="{ running }">
|
<div class="flex items-center gap-2">
|
||||||
<input v-model="target" type="text" placeholder="Domain or IP" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="lookup" />
|
<input v-model="target" type="text" placeholder="Domain or IP" class="px-3 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] flex-1" @keydown.enter="lookup" />
|
||||||
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Whois</button>
|
<button class="px-4 py-1.5 text-sm font-bold rounded bg-[#58a6ff] text-black cursor-pointer disabled:opacity-40" :disabled="running" @click="lookup">Whois</button>
|
||||||
</template>
|
</div>
|
||||||
</ToolShell>
|
<pre class="flex-1 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-3 text-xs font-mono whitespace-pre-wrap text-[#e0e0e0]">{{ output || "Enter a domain or IP and click Whois" }}</pre>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import ToolShell from "./ToolShell.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{ sessionId: string }>();
|
const props = defineProps<{ sessionId: string }>();
|
||||||
const target = ref("");
|
const target = ref("");
|
||||||
const shell = ref<InstanceType<typeof ToolShell> | null>(null);
|
const output = ref("");
|
||||||
|
const running = ref(false);
|
||||||
|
|
||||||
async function lookup(): Promise<void> {
|
async function lookup(): Promise<void> {
|
||||||
if (!target.value) return;
|
if (!target.value) return;
|
||||||
shell.value?.execute(() =>
|
running.value = true;
|
||||||
invoke<string>("tool_whois", { sessionId: props.sessionId, target: target.value })
|
try { output.value = await invoke<string>("tool_whois", { sessionId: props.sessionId, target: target.value }); }
|
||||||
);
|
catch (err) { output.value = String(err); }
|
||||||
|
running.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,106 +0,0 @@
|
|||||||
import { onMounted, onBeforeUnmount } from "vue";
|
|
||||||
import type { Ref } from "vue";
|
|
||||||
import type { useSessionStore } from "@/stores/session.store";
|
|
||||||
|
|
||||||
interface KeyboardShortcutActions {
|
|
||||||
sessionStore: ReturnType<typeof useSessionStore>;
|
|
||||||
sidebarVisible: Ref<boolean>;
|
|
||||||
copilotVisible: Ref<boolean>;
|
|
||||||
openCommandPalette: () => void;
|
|
||||||
openActiveSearch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeyboardShortcuts(actions: KeyboardShortcutActions): void {
|
|
||||||
const { sessionStore, sidebarVisible, copilotVisible, openCommandPalette, openActiveSearch } = actions;
|
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent): void {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
const isInputFocused =
|
|
||||||
target.tagName === "INPUT" ||
|
|
||||||
target.tagName === "TEXTAREA" ||
|
|
||||||
target.tagName === "SELECT";
|
|
||||||
const ctrl = event.ctrlKey || event.metaKey;
|
|
||||||
|
|
||||||
// Ctrl+K — command palette (fires even when input is focused)
|
|
||||||
if (ctrl && event.key === "k") {
|
|
||||||
event.preventDefault();
|
|
||||||
openCommandPalette();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInputFocused) return;
|
|
||||||
|
|
||||||
// Ctrl+W — close active tab
|
|
||||||
if (ctrl && event.key === "w") {
|
|
||||||
event.preventDefault();
|
|
||||||
const active = sessionStore.activeSession;
|
|
||||||
if (active) sessionStore.closeSession(active.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Tab — next tab
|
|
||||||
if (ctrl && event.key === "Tab" && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
const sessions = sessionStore.sessions;
|
|
||||||
if (sessions.length < 2) return;
|
|
||||||
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
|
|
||||||
const next = sessions[(idx + 1) % sessions.length];
|
|
||||||
sessionStore.activateSession(next.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Shift+Tab — previous tab
|
|
||||||
if (ctrl && event.key === "Tab" && event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
const sessions = sessionStore.sessions;
|
|
||||||
if (sessions.length < 2) return;
|
|
||||||
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
|
|
||||||
const prev = sessions[(idx - 1 + sessions.length) % sessions.length];
|
|
||||||
sessionStore.activateSession(prev.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+1-9 — jump to tab by index
|
|
||||||
if (ctrl && event.key >= "1" && event.key <= "9") {
|
|
||||||
const tabIndex = parseInt(event.key, 10) - 1;
|
|
||||||
const sessions = sessionStore.sessions;
|
|
||||||
if (tabIndex < sessions.length) {
|
|
||||||
event.preventDefault();
|
|
||||||
sessionStore.activateSession(sessions[tabIndex].id);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+B — toggle sidebar
|
|
||||||
if (ctrl && event.key === "b") {
|
|
||||||
event.preventDefault();
|
|
||||||
sidebarVisible.value = !sidebarVisible.value;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Shift+G — toggle AI copilot
|
|
||||||
if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") {
|
|
||||||
event.preventDefault();
|
|
||||||
copilotVisible.value = !copilotVisible.value;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+F — terminal search (SSH sessions only)
|
|
||||||
if (ctrl && event.key === "f") {
|
|
||||||
const active = sessionStore.activeSession;
|
|
||||||
if (active?.protocol === "ssh") {
|
|
||||||
event.preventDefault();
|
|
||||||
openActiveSearch();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener("keydown", handleKeydown);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener("keydown", handleKeydown);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { ref, onBeforeUnmount } from "vue";
|
import { ref, onBeforeUnmount } from "vue";
|
||||||
import type { Ref } from "vue";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -153,13 +152,13 @@ export function jsKeyToScancode(code: string): number | null {
|
|||||||
|
|
||||||
export interface UseRdpReturn {
|
export interface UseRdpReturn {
|
||||||
/** Whether the RDP session is connected (first frame received) */
|
/** Whether the RDP session is connected (first frame received) */
|
||||||
connected: Ref<boolean>;
|
connected: ReturnType<typeof ref<boolean>>;
|
||||||
/** Whether keyboard capture is enabled */
|
/** Whether keyboard capture is enabled */
|
||||||
keyboardGrabbed: Ref<boolean>;
|
keyboardGrabbed: ReturnType<typeof ref<boolean>>;
|
||||||
/** Whether clipboard sync is enabled */
|
/** Whether clipboard sync is enabled */
|
||||||
clipboardSync: Ref<boolean>;
|
clipboardSync: ReturnType<typeof ref<boolean>>;
|
||||||
/** Fetch and render the dirty region directly to a canvas context */
|
/** Fetch the current frame as RGBA ImageData */
|
||||||
fetchAndRender: (sessionId: string, width: number, height: number, ctx: CanvasRenderingContext2D) => Promise<boolean>;
|
fetchFrame: (sessionId: string, width: number, height: number) => Promise<ImageData | null>;
|
||||||
/** Send a mouse event to the backend */
|
/** Send a mouse event to the backend */
|
||||||
sendMouse: (sessionId: string, x: number, y: number, flags: number) => void;
|
sendMouse: (sessionId: string, x: number, y: number, flags: number) => void;
|
||||||
/** Send a key event to the backend */
|
/** Send a key event to the backend */
|
||||||
@ -185,7 +184,7 @@ export interface UseRdpReturn {
|
|||||||
* Composable that manages an RDP session's rendering and input.
|
* Composable that manages an RDP session's rendering and input.
|
||||||
*
|
*
|
||||||
* Uses Tauri's invoke() to call Rust commands:
|
* Uses Tauri's invoke() to call Rust commands:
|
||||||
* rdp_get_frame → raw RGBA ArrayBuffer (binary IPC)
|
* rdp_get_frame → base64 RGBA string
|
||||||
* rdp_send_mouse → fire-and-forget
|
* rdp_send_mouse → fire-and-forget
|
||||||
* rdp_send_key → fire-and-forget
|
* rdp_send_key → fire-and-forget
|
||||||
* rdp_send_clipboard → fire-and-forget
|
* rdp_send_clipboard → fire-and-forget
|
||||||
@ -196,53 +195,47 @@ export function useRdp(): UseRdpReturn {
|
|||||||
const clipboardSync = ref(false);
|
const clipboardSync = ref(false);
|
||||||
|
|
||||||
let animFrameId: number | null = null;
|
let animFrameId: number | null = null;
|
||||||
let unlistenFrame: (() => void) | null = null;
|
let frameCount = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the dirty region from the Rust RDP backend and apply it to the canvas.
|
* Fetch the current frame from the Rust RDP backend.
|
||||||
*
|
*
|
||||||
* Binary format from backend: 8-byte header + pixel data
|
* rdp_get_frame returns raw RGBA bytes (width*height*4) serialised as a
|
||||||
* Header: [x: u16, y: u16, w: u16, h: u16] (little-endian)
|
* base64 string over Tauri's IPC bridge. We decode it to Uint8ClampedArray
|
||||||
* If header is all zeros → full frame (width*height*4 bytes)
|
* and wrap in an ImageData for putImageData().
|
||||||
* If header is non-zero → dirty rectangle (w*h*4 bytes)
|
|
||||||
*
|
|
||||||
* Returns true if a frame was rendered, false if nothing changed.
|
|
||||||
*/
|
*/
|
||||||
async function fetchAndRender(
|
async function fetchFrame(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
ctx: CanvasRenderingContext2D,
|
): Promise<ImageData | null> {
|
||||||
): Promise<boolean> {
|
let raw: string;
|
||||||
let raw: ArrayBuffer;
|
|
||||||
try {
|
try {
|
||||||
raw = await invoke<ArrayBuffer>("rdp_get_frame", { sessionId });
|
raw = await invoke<string>("rdp_get_frame", { sessionId });
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
// Session may not be connected yet or backend returned an error — skip frame
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!raw || raw.byteLength <= 8) return false;
|
if (!raw || raw.length === 0) return null;
|
||||||
|
|
||||||
const view = new DataView(raw);
|
// Decode base64 → binary string → Uint8ClampedArray
|
||||||
const rx = view.getUint16(0, true);
|
const binaryStr = atob(raw);
|
||||||
const ry = view.getUint16(2, true);
|
const bytes = new Uint8ClampedArray(binaryStr.length);
|
||||||
const rw = view.getUint16(4, true);
|
for (let i = 0; i < binaryStr.length; i++) {
|
||||||
const rh = view.getUint16(6, true);
|
bytes[i] = binaryStr.charCodeAt(i);
|
||||||
const pixelData = new Uint8ClampedArray(raw, 8);
|
}
|
||||||
|
|
||||||
if (rx === 0 && ry === 0 && rw === 0 && rh === 0) {
|
// Validate: RGBA requires exactly width * height * 4 bytes
|
||||||
// Full frame
|
|
||||||
const expected = width * height * 4;
|
const expected = width * height * 4;
|
||||||
if (pixelData.length !== expected) return false;
|
if (bytes.length !== expected) {
|
||||||
ctx.putImageData(new ImageData(pixelData, width, height), 0, 0);
|
console.warn(
|
||||||
} else {
|
`[useRdp] Frame size mismatch: got ${bytes.length}, expected ${expected}`,
|
||||||
// Dirty rectangle — apply at offset
|
);
|
||||||
const expected = rw * rh * 4;
|
return null;
|
||||||
if (pixelData.length !== expected) return false;
|
|
||||||
ctx.putImageData(new ImageData(pixelData, rw, rh), rx, ry);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return new ImageData(bytes, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -310,36 +303,26 @@ export function useRdp(): UseRdpReturn {
|
|||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
|
|
||||||
let fetchPending = false;
|
function renderLoop(): void {
|
||||||
let rafScheduled = false;
|
frameCount++;
|
||||||
|
|
||||||
// Fetch and render dirty region when backend signals new frame data.
|
// Throttle to ~30fps by skipping odd-numbered rAF ticks
|
||||||
// Uses rAF to coalesce rapid events into one fetch per display frame.
|
if (frameCount % 2 === 0) {
|
||||||
function scheduleFrameFetch(): void {
|
fetchFrame(sessionId, width, height).then((imageData) => {
|
||||||
if (rafScheduled) return;
|
if (imageData && ctx) {
|
||||||
rafScheduled = true;
|
ctx.putImageData(imageData, 0, 0);
|
||||||
animFrameId = requestAnimationFrame(async () => {
|
// Mark connected on first successful frame
|
||||||
rafScheduled = false;
|
if (!connected.value) {
|
||||||
if (fetchPending) return;
|
connected.value = true;
|
||||||
fetchPending = true;
|
}
|
||||||
if (!ctx) return;
|
}
|
||||||
const rendered = await fetchAndRender(sessionId, width, height, ctx);
|
|
||||||
fetchPending = false;
|
|
||||||
if (rendered && !connected.value) connected.value = true;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for frame events from the backend (push model)
|
animFrameId = requestAnimationFrame(renderLoop);
|
||||||
import("@tauri-apps/api/event").then(({ listen }) => {
|
}
|
||||||
listen(`rdp:frame:${sessionId}`, () => {
|
|
||||||
scheduleFrameFetch();
|
|
||||||
}).then((unlisten) => {
|
|
||||||
unlistenFrame = unlisten;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial poll in case frames arrived before listener was set up
|
animFrameId = requestAnimationFrame(renderLoop);
|
||||||
scheduleFrameFetch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -350,11 +333,8 @@ export function useRdp(): UseRdpReturn {
|
|||||||
cancelAnimationFrame(animFrameId);
|
cancelAnimationFrame(animFrameId);
|
||||||
animFrameId = null;
|
animFrameId = null;
|
||||||
}
|
}
|
||||||
if (unlistenFrame !== null) {
|
|
||||||
unlistenFrame();
|
|
||||||
unlistenFrame = null;
|
|
||||||
}
|
|
||||||
connected.value = false;
|
connected.value = false;
|
||||||
|
frameCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleKeyboardGrab(): void {
|
function toggleKeyboardGrab(): void {
|
||||||
@ -373,7 +353,7 @@ export function useRdp(): UseRdpReturn {
|
|||||||
connected,
|
connected,
|
||||||
keyboardGrabbed,
|
keyboardGrabbed,
|
||||||
clipboardSync,
|
clipboardSync,
|
||||||
fetchAndRender,
|
fetchFrame,
|
||||||
sendMouse,
|
sendMouse,
|
||||||
sendKey,
|
sendKey,
|
||||||
sendClipboard,
|
sendClipboard,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ref, watch, onBeforeUnmount, type Ref } from "vue";
|
import { ref, onBeforeUnmount, type Ref } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
@ -21,29 +21,20 @@ export interface UseSftpReturn {
|
|||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the last browsed path per session so switching tabs restores position
|
|
||||||
const sessionPaths: Record<string, string> = {};
|
|
||||||
|
|
||||||
/** Remove a session's saved path from the module-level cache. Call on session close. */
|
|
||||||
export function cleanupSession(sessionId: string): void {
|
|
||||||
delete sessionPaths[sessionId];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable that manages SFTP file browsing state.
|
* Composable that manages SFTP file browsing state.
|
||||||
* Accepts a reactive session ID ref so it reinitializes on tab switch
|
* Calls the Rust SFTP commands via Tauri invoke.
|
||||||
* without destroying the component.
|
|
||||||
*/
|
*/
|
||||||
export function useSftp(sessionIdRef: Ref<string>): UseSftpReturn {
|
export function useSftp(sessionId: string): UseSftpReturn {
|
||||||
const currentPath = ref("/");
|
const currentPath = ref("/");
|
||||||
const entries = ref<FileEntry[]>([]);
|
const entries = ref<FileEntry[]>([]);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const followTerminal = ref(true);
|
const followTerminal = ref(true);
|
||||||
|
|
||||||
|
// Holds the unlisten function returned by listen() — called on cleanup.
|
||||||
let unlistenCwd: UnlistenFn | null = null;
|
let unlistenCwd: UnlistenFn | null = null;
|
||||||
let currentSessionId = "";
|
|
||||||
|
|
||||||
async function listDirectory(sessionId: string, path: string): Promise<FileEntry[]> {
|
async function listDirectory(path: string): Promise<FileEntry[]> {
|
||||||
try {
|
try {
|
||||||
const result = await invoke<FileEntry[]>("sftp_list", { sessionId, path });
|
const result = await invoke<FileEntry[]>("sftp_list", { sessionId, path });
|
||||||
return result ?? [];
|
return result ?? [];
|
||||||
@ -54,12 +45,10 @@ export function useSftp(sessionIdRef: Ref<string>): UseSftpReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function navigateTo(path: string): Promise<void> {
|
async function navigateTo(path: string): Promise<void> {
|
||||||
if (!currentSessionId) return;
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
currentPath.value = path;
|
currentPath.value = path;
|
||||||
sessionPaths[currentSessionId] = path;
|
entries.value = await listDirectory(path);
|
||||||
entries.value = await listDirectory(currentSessionId, path);
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
@ -79,63 +68,25 @@ export function useSftp(sessionIdRef: Ref<string>): UseSftpReturn {
|
|||||||
await navigateTo(currentPath.value);
|
await navigateTo(currentPath.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchToSession(sessionId: string): Promise<void> {
|
// Listen for CWD changes from the Rust backend (OSC 7 tracking).
|
||||||
if (!sessionId) {
|
// listen() returns Promise<UnlistenFn> — store it for cleanup.
|
||||||
entries.value = [];
|
listen<string>(`ssh:cwd:${sessionId}`, (event) => {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save current path for the old session
|
|
||||||
if (currentSessionId) {
|
|
||||||
sessionPaths[currentSessionId] = currentPath.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlisten old CWD events
|
|
||||||
if (unlistenCwd) {
|
|
||||||
unlistenCwd();
|
|
||||||
unlistenCwd = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSessionId = sessionId;
|
|
||||||
|
|
||||||
// Restore saved path or default to root
|
|
||||||
const savedPath = sessionPaths[sessionId] || "/";
|
|
||||||
currentPath.value = savedPath;
|
|
||||||
|
|
||||||
// Load the directory
|
|
||||||
isLoading.value = true;
|
|
||||||
try {
|
|
||||||
entries.value = await listDirectory(sessionId, savedPath);
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for CWD changes on the new session
|
|
||||||
try {
|
|
||||||
unlistenCwd = await listen<string>(`ssh:cwd:${sessionId}`, (event) => {
|
|
||||||
if (!followTerminal.value) return;
|
if (!followTerminal.value) return;
|
||||||
const newPath = event.payload;
|
const newPath = event.payload;
|
||||||
if (newPath && newPath !== currentPath.value) {
|
if (newPath && newPath !== currentPath.value) {
|
||||||
navigateTo(newPath);
|
navigateTo(newPath);
|
||||||
}
|
}
|
||||||
|
}).then((unlisten) => {
|
||||||
|
unlistenCwd = unlisten;
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
// Event listener setup failed — non-fatal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// React to session ID changes
|
|
||||||
watch(sessionIdRef, (newId) => {
|
|
||||||
switchToSession(newId);
|
|
||||||
}, { immediate: true });
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (currentSessionId) {
|
|
||||||
sessionPaths[currentSessionId] = currentPath.value;
|
|
||||||
}
|
|
||||||
if (unlistenCwd) unlistenCwd();
|
if (unlistenCwd) unlistenCwd();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load home directory on init
|
||||||
|
navigateTo("/home");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentPath,
|
currentPath,
|
||||||
entries,
|
entries,
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { SearchAddon } from "@xterm/addon-search";
|
|||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { useSessionStore } from "@/stores/session.store";
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
/** MobaXTerm Classic–inspired terminal theme colors. */
|
/** MobaXTerm Classic–inspired terminal theme colors. */
|
||||||
@ -14,9 +13,8 @@ const defaultTheme = {
|
|||||||
foreground: "#e0e0e0",
|
foreground: "#e0e0e0",
|
||||||
cursor: "#58a6ff",
|
cursor: "#58a6ff",
|
||||||
cursorAccent: "#0d1117",
|
cursorAccent: "#0d1117",
|
||||||
selectionBackground: "#264f78",
|
selectionBackground: "rgba(88, 166, 255, 0.3)",
|
||||||
selectionForeground: "#ffffff",
|
selectionForeground: "#ffffff",
|
||||||
selectionInactiveBackground: "#264f78",
|
|
||||||
black: "#0d1117",
|
black: "#0d1117",
|
||||||
red: "#f85149",
|
red: "#f85149",
|
||||||
green: "#3fb950",
|
green: "#3fb950",
|
||||||
@ -71,9 +69,7 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
|
|||||||
cursorStyle: "block",
|
cursorStyle: "block",
|
||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
// SSH always needs EOL conversion. PTY needs it on Windows (ConPTY sends bare \n)
|
convertEol: backend === 'ssh',
|
||||||
// but not on Unix (PTY driver handles LF→CRLF). navigator.platform is the simplest check.
|
|
||||||
convertEol: backend === 'ssh' || navigator.platform.startsWith('Win'),
|
|
||||||
rightClickSelectsWord: false,
|
rightClickSelectsWord: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -156,7 +152,6 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
|
|||||||
// cell widths — producing tiny dashes and 200+ column terminals.
|
// cell widths — producing tiny dashes and 200+ column terminals.
|
||||||
document.fonts.ready.then(() => {
|
document.fonts.ready.then(() => {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
terminal.focus();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Right-click paste on the terminal's DOM element
|
// Right-click paste on the terminal's DOM element
|
||||||
@ -165,17 +160,7 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
|
|||||||
// Subscribe to SSH output events for this session.
|
// Subscribe to SSH output events for this session.
|
||||||
// Tauri v2 listen() callback receives { payload: T } — the base64 string
|
// Tauri v2 listen() callback receives { payload: T } — the base64 string
|
||||||
// is in event.payload (not event.data as in Wails).
|
// is in event.payload (not event.data as in Wails).
|
||||||
// Throttle activity marking to avoid Vue reactivity storms
|
|
||||||
let lastActivityMark = 0;
|
|
||||||
|
|
||||||
unlistenPromise = listen<string>(dataEvent, (event) => {
|
unlistenPromise = listen<string>(dataEvent, (event) => {
|
||||||
// Mark tab activity at most once per second
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastActivityMark > 1000) {
|
|
||||||
lastActivityMark = now;
|
|
||||||
try { useSessionStore().markActivity(sessionId); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const b64data = event.payload;
|
const b64data = event.payload;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -202,12 +187,9 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
|
|||||||
unlistenFn = fn;
|
unlistenFn = fn;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-fit when the container resizes — but only if visible
|
// Auto-fit when the container resizes
|
||||||
resizeObserver = new ResizeObserver((entries) => {
|
resizeObserver = new ResizeObserver(() => {
|
||||||
const entry = entries[0];
|
|
||||||
if (entry && entry.contentRect.width > 50 && entry.contentRect.height > 50) {
|
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
resizeObserver.observe(container);
|
resizeObserver.observe(container);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,13 +113,6 @@
|
|||||||
<span class="flex-1">Subnet Calculator</span>
|
<span class="flex-1">Subnet Calculator</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="border-t border-[#30363d] my-1" />
|
<div class="border-t border-[#30363d] my-1" />
|
||||||
<button
|
|
||||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
|
||||||
@mousedown.prevent="handleToolAction('docker')"
|
|
||||||
>
|
|
||||||
<span class="flex-1">Docker Manager</span>
|
|
||||||
</button>
|
|
||||||
<div class="border-t border-[#30363d] my-1" />
|
|
||||||
<button
|
<button
|
||||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||||
@mousedown.prevent="handleToolAction('wake-on-lan')"
|
@mousedown.prevent="handleToolAction('wake-on-lan')"
|
||||||
@ -141,47 +134,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Help menu -->
|
|
||||||
<div class="relative">
|
|
||||||
<button
|
|
||||||
class="text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer px-2 py-1 rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
|
||||||
@click="showHelpMenu = !showHelpMenu"
|
|
||||||
@blur="closeHelpMenuDeferred"
|
|
||||||
>
|
|
||||||
Help
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="showHelpMenu"
|
|
||||||
class="absolute top-full left-0 mt-0.5 w-56 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden z-50 py-1"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
|
||||||
@mousedown.prevent="handleHelpAction('guide')"
|
|
||||||
>
|
|
||||||
<span class="flex-1">Getting Started</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
|
||||||
@mousedown.prevent="handleHelpAction('shortcuts')"
|
|
||||||
>
|
|
||||||
<span class="flex-1">Keyboard Shortcuts</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
|
||||||
@mousedown.prevent="handleHelpAction('mcp')"
|
|
||||||
>
|
|
||||||
<span class="flex-1">MCP Integration</span>
|
|
||||||
</button>
|
|
||||||
<div class="border-t border-[#30363d] my-1" />
|
|
||||||
<button
|
|
||||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
|
||||||
@mousedown.prevent="handleHelpAction('about')"
|
|
||||||
>
|
|
||||||
<span class="flex-1">About Wraith</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Connect -->
|
<!-- Quick Connect -->
|
||||||
@ -263,6 +215,7 @@
|
|||||||
<template v-else-if="sidebarTab === 'sftp'">
|
<template v-else-if="sidebarTab === 'sftp'">
|
||||||
<template v-if="activeSessionId">
|
<template v-if="activeSessionId">
|
||||||
<FileTree
|
<FileTree
|
||||||
|
:key="activeSessionId"
|
||||||
:session-id="activeSessionId"
|
:session-id="activeSessionId"
|
||||||
class="flex-1 min-h-0"
|
class="flex-1 min-h-0"
|
||||||
@open-file="handleOpenFile"
|
@open-file="handleOpenFile"
|
||||||
@ -283,6 +236,15 @@
|
|||||||
<!-- Tab bar -->
|
<!-- Tab bar -->
|
||||||
<TabBar />
|
<TabBar />
|
||||||
|
|
||||||
|
<!-- Inline file editor -->
|
||||||
|
<EditorWindow
|
||||||
|
v-if="editorFile"
|
||||||
|
:content="editorFile.content"
|
||||||
|
:file-path="editorFile.path"
|
||||||
|
:session-id="editorFile.sessionId"
|
||||||
|
@close="editorFile = null"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Session area -->
|
<!-- Session area -->
|
||||||
<SessionContainer ref="sessionContainer" />
|
<SessionContainer ref="sessionContainer" />
|
||||||
</div>
|
</div>
|
||||||
@ -308,7 +270,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
import { useKeyboardShortcuts } from "@/composables/useKeyboardShortcuts";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { useAppStore } from "@/stores/app.store";
|
import { useAppStore } from "@/stores/app.store";
|
||||||
@ -325,6 +286,7 @@ import SettingsModal from "@/components/common/SettingsModal.vue";
|
|||||||
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
|
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
|
||||||
import FileTree from "@/components/sftp/FileTree.vue";
|
import FileTree from "@/components/sftp/FileTree.vue";
|
||||||
import TransferProgress from "@/components/sftp/TransferProgress.vue";
|
import TransferProgress from "@/components/sftp/TransferProgress.vue";
|
||||||
|
import EditorWindow from "@/components/editor/EditorWindow.vue";
|
||||||
import CopilotPanel from "@/components/ai/CopilotPanel.vue";
|
import CopilotPanel from "@/components/ai/CopilotPanel.vue";
|
||||||
import type { FileEntry } from "@/composables/useSftp";
|
import type { FileEntry } from "@/composables/useSftp";
|
||||||
|
|
||||||
@ -349,9 +311,10 @@ const connectionEditDialog = ref<InstanceType<typeof ConnectionEditDialog> | nul
|
|||||||
const statusBar = ref<InstanceType<typeof StatusBar> | null>(null);
|
const statusBar = ref<InstanceType<typeof StatusBar> | null>(null);
|
||||||
const sessionContainer = ref<InstanceType<typeof SessionContainer> | null>(null);
|
const sessionContainer = ref<InstanceType<typeof SessionContainer> | null>(null);
|
||||||
|
|
||||||
|
interface EditorFile { path: string; content: string; sessionId: string; }
|
||||||
|
const editorFile = ref<EditorFile | null>(null);
|
||||||
const showFileMenu = ref(false);
|
const showFileMenu = ref(false);
|
||||||
const showToolsMenu = ref(false);
|
const showToolsMenu = ref(false);
|
||||||
const showHelpMenu = ref(false);
|
|
||||||
|
|
||||||
function closeFileMenuDeferred(): void {
|
function closeFileMenuDeferred(): void {
|
||||||
setTimeout(() => { showFileMenu.value = false; }, 150);
|
setTimeout(() => { showFileMenu.value = false; }, 150);
|
||||||
@ -361,22 +324,6 @@ function closeToolsMenuDeferred(): void {
|
|||||||
setTimeout(() => { showToolsMenu.value = false; }, 150);
|
setTimeout(() => { showToolsMenu.value = false; }, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeHelpMenuDeferred(): void {
|
|
||||||
setTimeout(() => { showHelpMenu.value = false; }, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleHelpAction(page: string): Promise<void> {
|
|
||||||
showHelpMenu.value = false;
|
|
||||||
try {
|
|
||||||
await invoke("open_child_window", {
|
|
||||||
label: `help-${page}-${Date.now()}`,
|
|
||||||
title: "Wraith — Help",
|
|
||||||
url: `index.html#/tool/help?page=${page}`,
|
|
||||||
width: 750, height: 600,
|
|
||||||
});
|
|
||||||
} catch (err) { console.error("Help window error:", err); alert("Window error: " + String(err)); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToolAction(tool: string): Promise<void> {
|
async function handleToolAction(tool: string): Promise<void> {
|
||||||
showToolsMenu.value = false;
|
showToolsMenu.value = false;
|
||||||
|
|
||||||
@ -388,6 +335,8 @@ async function handleToolAction(tool: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||||
|
|
||||||
const toolConfig: Record<string, { title: string; width: number; height: number }> = {
|
const toolConfig: Record<string, { title: string; width: number; height: number }> = {
|
||||||
"network-scanner": { title: "Network Scanner", width: 800, height: 600 },
|
"network-scanner": { title: "Network Scanner", width: 800, height: 600 },
|
||||||
"port-scanner": { title: "Port Scanner", width: 700, height: 500 },
|
"port-scanner": { title: "Port Scanner", width: 700, height: 500 },
|
||||||
@ -397,7 +346,6 @@ async function handleToolAction(tool: string): Promise<void> {
|
|||||||
"whois": { title: "Whois", width: 700, height: 500 },
|
"whois": { title: "Whois", width: 700, height: 500 },
|
||||||
"bandwidth": { title: "Bandwidth Test", width: 700, height: 450 },
|
"bandwidth": { title: "Bandwidth Test", width: 700, height: 450 },
|
||||||
"subnet-calc": { title: "Subnet Calculator", width: 650, height: 350 },
|
"subnet-calc": { title: "Subnet Calculator", width: 650, height: 350 },
|
||||||
"docker": { title: "Docker Manager", width: 900, height: 600 },
|
|
||||||
"wake-on-lan": { title: "Wake on LAN", width: 500, height: 300 },
|
"wake-on-lan": { title: "Wake on LAN", width: 500, height: 300 },
|
||||||
"ssh-keygen": { title: "SSH Key Generator", width: 700, height: 500 },
|
"ssh-keygen": { title: "SSH Key Generator", width: 700, height: 500 },
|
||||||
"password-gen": { title: "Password Generator", width: 500, height: 400 },
|
"password-gen": { title: "Password Generator", width: 500, height: 400 },
|
||||||
@ -408,14 +356,16 @@ async function handleToolAction(tool: string): Promise<void> {
|
|||||||
|
|
||||||
const sessionId = activeSessionId.value || "";
|
const sessionId = activeSessionId.value || "";
|
||||||
|
|
||||||
try {
|
// Open tool in a new Tauri window
|
||||||
await invoke("open_child_window", {
|
const label = `tool-${tool}-${Date.now()}`;
|
||||||
label: `tool-${tool}-${Date.now()}`,
|
new WebviewWindow(label, {
|
||||||
title: `Wraith — ${config.title}`,
|
title: `Wraith — ${config.title}`,
|
||||||
|
width: config.width,
|
||||||
|
height: config.height,
|
||||||
|
resizable: true,
|
||||||
|
center: true,
|
||||||
url: `index.html#/tool/${tool}?sessionId=${sessionId}`,
|
url: `index.html#/tool/${tool}?sessionId=${sessionId}`,
|
||||||
width: config.width, height: config.height,
|
|
||||||
});
|
});
|
||||||
} catch (err) { console.error("Tool window error:", err); alert("Tool window error: " + String(err)); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileMenuAction(action: string): Promise<void> {
|
async function handleFileMenuAction(action: string): Promise<void> {
|
||||||
@ -435,15 +385,9 @@ function handleThemeSelect(theme: ThemeDefinition): void {
|
|||||||
async function handleOpenFile(entry: FileEntry): Promise<void> {
|
async function handleOpenFile(entry: FileEntry): Promise<void> {
|
||||||
if (!activeSessionId.value) return;
|
if (!activeSessionId.value) return;
|
||||||
try {
|
try {
|
||||||
const fileName = entry.path.split("/").pop() || entry.path;
|
const content = await invoke<string>("sftp_read_file", { sessionId: activeSessionId.value, path: entry.path });
|
||||||
const sessionId = activeSessionId.value;
|
editorFile.value = { path: entry.path, content, sessionId: activeSessionId.value };
|
||||||
await invoke("open_child_window", {
|
} catch (err) { console.error("Failed to open SFTP file:", err); }
|
||||||
label: `editor-${Date.now()}`,
|
|
||||||
title: `${fileName} — Wraith Editor`,
|
|
||||||
url: `index.html#/tool/editor?sessionId=${sessionId}&path=${encodeURIComponent(entry.path)}`,
|
|
||||||
width: 800, height: 600,
|
|
||||||
});
|
|
||||||
} catch (err) { console.error("Failed to open editor:", err); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleQuickConnect(): Promise<void> {
|
async function handleQuickConnect(): Promise<void> {
|
||||||
@ -469,81 +413,27 @@ async function handleQuickConnect(): Promise<void> {
|
|||||||
} catch (err) { console.error("Quick connect failed:", err); }
|
} catch (err) { console.error("Quick connect failed:", err); }
|
||||||
}
|
}
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
function handleKeydown(event: KeyboardEvent): void {
|
||||||
sessionStore,
|
const target = event.target as HTMLElement;
|
||||||
sidebarVisible,
|
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
|
||||||
copilotVisible,
|
const ctrl = event.ctrlKey || event.metaKey;
|
||||||
openCommandPalette: () => commandPalette.value?.toggle(),
|
if (ctrl && event.key === "k") { event.preventDefault(); commandPalette.value?.toggle(); return; }
|
||||||
openActiveSearch: () => sessionContainer.value?.openActiveSearch(),
|
if (isInputFocused) return;
|
||||||
});
|
if (ctrl && event.key === "w") { event.preventDefault(); const active = sessionStore.activeSession; if (active) sessionStore.closeSession(active.id); return; }
|
||||||
|
if (ctrl && event.key === "Tab" && !event.shiftKey) { event.preventDefault(); const sessions = sessionStore.sessions; if (sessions.length < 2) return; const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId); const next = sessions[(idx + 1) % sessions.length]; sessionStore.activateSession(next.id); return; }
|
||||||
let workspaceSaveInterval: ReturnType<typeof setInterval> | null = null;
|
if (ctrl && event.key === "Tab" && event.shiftKey) { event.preventDefault(); const sessions = sessionStore.sessions; if (sessions.length < 2) return; const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId); const prev = sessions[(idx - 1 + sessions.length) % sessions.length]; sessionStore.activateSession(prev.id); return; }
|
||||||
|
if (ctrl && event.key >= "1" && event.key <= "9") { const tabIndex = parseInt(event.key, 10) - 1; const sessions = sessionStore.sessions; if (tabIndex < sessions.length) { event.preventDefault(); sessionStore.activateSession(sessions[tabIndex].id); } return; }
|
||||||
function handleBeforeUnload(e: BeforeUnloadEvent): void {
|
if (ctrl && event.key === "b") { event.preventDefault(); sidebarVisible.value = !sidebarVisible.value; return; }
|
||||||
if (sessionStore.sessions.length > 0) {
|
if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") { event.preventDefault(); copilotVisible.value = !copilotVisible.value; return; }
|
||||||
e.preventDefault();
|
if (ctrl && event.key === "f") { const active = sessionStore.activeSession; if (active?.protocol === "ssh") { event.preventDefault(); sessionContainer.value?.openActiveSearch(); } return; }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Confirm before closing if sessions are active (synchronous — won't hang)
|
document.addEventListener("keydown", handleKeydown);
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
||||||
|
|
||||||
await connectionStore.loadAll();
|
await connectionStore.loadAll();
|
||||||
|
|
||||||
// Restore saved theme so every terminal opens with the user's preferred colors
|
|
||||||
try {
|
|
||||||
const savedThemeName = await invoke<string | null>("get_setting", { key: "active_theme" });
|
|
||||||
if (savedThemeName) {
|
|
||||||
const themes = await invoke<Array<{ name: string; foreground: string; background: string; cursor: string; black: string; red: string; green: string; yellow: string; blue: string; magenta: string; cyan: string; white: string; brightBlack: string; brightRed: string; brightGreen: string; brightYellow: string; brightBlue: string; brightMagenta: string; brightCyan: string; brightWhite: string }>>("list_themes");
|
|
||||||
const theme = themes?.find(t => t.name === savedThemeName);
|
|
||||||
if (theme) {
|
|
||||||
sessionStore.setTheme(theme);
|
|
||||||
statusBar.value?.setThemeName(theme.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Restore workspace — reconnect saved tabs (non-blocking, non-fatal)
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const workspace = await invoke<{ tabs: { connectionId: number; protocol: string; position: number }[] } | null>("load_workspace");
|
|
||||||
if (workspace?.tabs?.length) {
|
|
||||||
for (const tab of workspace.tabs.sort((a, b) => a.position - b.position)) {
|
|
||||||
try { await sessionStore.connect(tab.connectionId); } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
// Auto-save workspace every 30 seconds instead of on close
|
|
||||||
// (onCloseRequested was hanging the window close on Windows)
|
|
||||||
workspaceSaveInterval = setInterval(() => {
|
|
||||||
const tabs = sessionStore.sessions
|
|
||||||
.filter(s => s.protocol === "ssh" || s.protocol === "rdp")
|
|
||||||
.map((s, i) => ({ connectionId: s.connectionId, protocol: s.protocol, position: i }));
|
|
||||||
if (tabs.length > 0) {
|
|
||||||
invoke("save_workspace", { tabs }).catch(() => {});
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
// Check for updates on startup via Tauri updater plugin (non-blocking)
|
|
||||||
invoke<{ currentVersion: string; latestVersion: string; updateAvailable: boolean; downloadUrl: string }>("check_for_updates")
|
|
||||||
.then((info) => {
|
|
||||||
if (info.updateAvailable) {
|
|
||||||
if (confirm(`Wraith v${info.latestVersion} is available (you have v${info.currentVersion}). Open download page?`)) {
|
|
||||||
import("@tauri-apps/plugin-shell").then(({ open }) => open(info.downloadUrl)).catch(() => window.open(info.downloadUrl, "_blank"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
document.removeEventListener("keydown", handleKeydown);
|
||||||
if (workspaceSaveInterval !== null) {
|
|
||||||
clearInterval(workspaceSaveInterval);
|
|
||||||
workspaceSaveInterval = null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -50,25 +50,68 @@ const displayError = computed(() => localError.value ?? app.error);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full flex items-center justify-center bg-[var(--wraith-bg-primary)]">
|
<div
|
||||||
<div class="w-full max-w-[400px] p-10 bg-[var(--wraith-bg-secondary)] border border-[var(--wraith-border)] rounded-xl shadow-[0_8px_32px_rgba(0,0,0,0.5)]">
|
class="unlock-root"
|
||||||
|
style="
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--wraith-bg-primary);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="unlock-card"
|
||||||
|
style="
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
background-color: var(--wraith-bg-secondary);
|
||||||
|
border: 1px solid var(--wraith-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
"
|
||||||
|
>
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div style="text-align: center; margin-bottom: 2rem">
|
||||||
<span class="text-[2rem] font-extrabold tracking-[0.3em] text-[var(--wraith-accent-blue)] uppercase font-['Inter',monospace]">
|
<span
|
||||||
|
style="
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
color: var(--wraith-accent-blue);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: 'Inter', monospace;
|
||||||
|
"
|
||||||
|
>
|
||||||
WRAITH
|
WRAITH
|
||||||
</span>
|
</span>
|
||||||
<p class="mt-2 text-[0.8rem] text-[var(--wraith-text-muted)] tracking-[0.15em] uppercase">
|
<p
|
||||||
|
style="
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--wraith-text-muted);
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
"
|
||||||
|
>
|
||||||
{{ isFirstRun ? "Initialize Secure Vault" : "Secure Desktop" }}
|
{{ isFirstRun ? "Initialize Secure Vault" : "Secure Desktop" }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-4">
|
<form @submit.prevent="handleSubmit" style="display: flex; flex-direction: column; gap: 1rem">
|
||||||
<!-- Master password -->
|
<!-- Master password -->
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="master-password"
|
for="master-password"
|
||||||
class="block mb-[0.4rem] text-[0.8rem] text-[var(--wraith-text-secondary)] tracking-[0.05em]"
|
style="
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--wraith-text-secondary);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
"
|
||||||
>
|
>
|
||||||
MASTER PASSWORD
|
MASTER PASSWORD
|
||||||
</label>
|
</label>
|
||||||
@ -79,7 +122,20 @@ const displayError = computed(() => localError.value ?? app.error);
|
|||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
placeholder="Enter master password"
|
placeholder="Enter master password"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
class="w-full px-[0.9rem] py-[0.65rem] bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded-[6px] text-[var(--wraith-text-primary)] text-[0.95rem] outline-none transition-colors duration-150 box-border focus:border-[var(--wraith-accent-blue)]"
|
style="
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
background-color: var(--wraith-bg-tertiary);
|
||||||
|
border: 1px solid var(--wraith-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--wraith-text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
"
|
||||||
|
@focus="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-accent-blue)'"
|
||||||
|
@blur="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-border)'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -87,7 +143,13 @@ const displayError = computed(() => localError.value ?? app.error);
|
|||||||
<div v-if="isFirstRun">
|
<div v-if="isFirstRun">
|
||||||
<label
|
<label
|
||||||
for="confirm-password"
|
for="confirm-password"
|
||||||
class="block mb-[0.4rem] text-[0.8rem] text-[var(--wraith-text-secondary)] tracking-[0.05em]"
|
style="
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--wraith-text-secondary);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
"
|
||||||
>
|
>
|
||||||
CONFIRM PASSWORD
|
CONFIRM PASSWORD
|
||||||
</label>
|
</label>
|
||||||
@ -98,9 +160,28 @@ const displayError = computed(() => localError.value ?? app.error);
|
|||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
placeholder="Confirm master password"
|
placeholder="Confirm master password"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
class="w-full px-[0.9rem] py-[0.65rem] bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded-[6px] text-[var(--wraith-text-primary)] text-[0.95rem] outline-none transition-colors duration-150 box-border focus:border-[var(--wraith-accent-blue)]"
|
style="
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
background-color: var(--wraith-bg-tertiary);
|
||||||
|
border: 1px solid var(--wraith-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--wraith-text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
"
|
||||||
|
@focus="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-accent-blue)'"
|
||||||
|
@blur="($event.target as HTMLInputElement).style.borderColor = 'var(--wraith-border)'"
|
||||||
/>
|
/>
|
||||||
<p class="mt-[0.4rem] text-[0.75rem] text-[var(--wraith-text-muted)]">
|
<p
|
||||||
|
style="
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--wraith-text-muted);
|
||||||
|
"
|
||||||
|
>
|
||||||
Minimum 12 characters. This password cannot be recovered.
|
Minimum 12 characters. This password cannot be recovered.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -108,7 +189,14 @@ const displayError = computed(() => localError.value ?? app.error);
|
|||||||
<!-- Error message -->
|
<!-- Error message -->
|
||||||
<div
|
<div
|
||||||
v-if="displayError"
|
v-if="displayError"
|
||||||
class="px-[0.9rem] py-[0.6rem] bg-[rgba(248,81,73,0.1)] border border-[rgba(248,81,73,0.3)] rounded-[6px] text-[var(--wraith-accent-red)] text-[0.85rem]"
|
style="
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
background-color: rgba(248, 81, 73, 0.1);
|
||||||
|
border: 1px solid rgba(248, 81, 73, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--wraith-accent-red);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ displayError }}
|
{{ displayError }}
|
||||||
</div>
|
</div>
|
||||||
@ -117,8 +205,22 @@ const displayError = computed(() => localError.value ?? app.error);
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
class="w-full py-[0.7rem] mt-2 bg-[var(--wraith-accent-blue)] text-[#0d1117] font-bold text-[0.9rem] tracking-[0.08em] uppercase border-none rounded-[6px] transition-[opacity,background-color] duration-150"
|
style="
|
||||||
:class="loading ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'"
|
width: 100%;
|
||||||
|
padding: 0.7rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background-color: var(--wraith-accent-blue);
|
||||||
|
color: #0d1117;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||||
|
"
|
||||||
|
:style="{ opacity: loading ? '0.6' : '1', cursor: loading ? 'not-allowed' : 'pointer' }"
|
||||||
>
|
>
|
||||||
<span v-if="loading">
|
<span v-if="loading">
|
||||||
{{ isFirstRun ? "Creating vault..." : "Unlocking..." }}
|
{{ isFirstRun ? "Creating vault..." : "Unlocking..." }}
|
||||||
@ -130,7 +232,14 @@ const displayError = computed(() => localError.value ?? app.error);
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Footer hint -->
|
<!-- Footer hint -->
|
||||||
<p class="mt-6 text-center text-[0.75rem] text-[var(--wraith-text-muted)]">
|
<p
|
||||||
|
style="
|
||||||
|
margin: 1.5rem 0 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--wraith-text-muted);
|
||||||
|
"
|
||||||
|
>
|
||||||
<template v-if="isFirstRun">
|
<template v-if="isFirstRun">
|
||||||
Your vault will be encrypted with AES-256-GCM.
|
Your vault will be encrypted with AES-256-GCM.
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -51,33 +51,22 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Memoized map of groupId → filtered connections. Recomputes only when connections or searchQuery change. */
|
|
||||||
const connectionsByGroupMap = computed<Record<number, Connection[]>>(() => {
|
|
||||||
const q = searchQuery.value.toLowerCase().trim();
|
|
||||||
const map: Record<number, Connection[]> = {};
|
|
||||||
for (const c of connections.value) {
|
|
||||||
if (c.groupId === null) continue;
|
|
||||||
if (q) {
|
|
||||||
const match =
|
|
||||||
c.name.toLowerCase().includes(q) ||
|
|
||||||
c.hostname.toLowerCase().includes(q) ||
|
|
||||||
c.tags?.some((t) => t.toLowerCase().includes(q));
|
|
||||||
if (!match) continue;
|
|
||||||
}
|
|
||||||
if (!map[c.groupId]) map[c.groupId] = [];
|
|
||||||
map[c.groupId].push(c);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Get connections belonging to a specific group. */
|
/** Get connections belonging to a specific group. */
|
||||||
function connectionsByGroup(groupId: number): Connection[] {
|
function connectionsByGroup(groupId: number): Connection[] {
|
||||||
return connectionsByGroupMap.value[groupId] ?? [];
|
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). */
|
/** Check if a group has any matching connections (for search filtering). */
|
||||||
function groupHasResults(groupId: number): boolean {
|
function groupHasResults(groupId: number): boolean {
|
||||||
return (connectionsByGroupMap.value[groupId]?.length ?? 0) > 0;
|
return connectionsByGroup(groupId).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load connections from the Rust backend. */
|
/** Load connections from the Rust backend. */
|
||||||
@ -112,7 +101,6 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
groups,
|
groups,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
filteredConnections,
|
filteredConnections,
|
||||||
connectionsByGroupMap,
|
|
||||||
connectionsByGroup,
|
connectionsByGroup,
|
||||||
groupHasResults,
|
groupHasResults,
|
||||||
loadConnections,
|
loadConnections,
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { defineStore } from "pinia";
|
|||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
|
||||||
import { useConnectionStore } from "@/stores/connection.store";
|
import { useConnectionStore } from "@/stores/connection.store";
|
||||||
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
||||||
|
|
||||||
@ -14,7 +13,6 @@ export interface Session {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
status: "connected" | "disconnected";
|
status: "connected" | "disconnected";
|
||||||
hasActivity: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TerminalDimensions {
|
export interface TerminalDimensions {
|
||||||
@ -40,14 +38,10 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
|
|
||||||
const sessionCount = computed(() => sessions.value.length);
|
const sessionCount = computed(() => sessions.value.length);
|
||||||
|
|
||||||
const sessionUnlisteners = new Map<string, Array<UnlistenFn>>();
|
|
||||||
|
|
||||||
// Listen for backend close/exit events to update session status
|
// Listen for backend close/exit events to update session status
|
||||||
async function setupStatusListeners(sessionId: string): Promise<void> {
|
function setupStatusListeners(sessionId: string): void {
|
||||||
const unlisteners: UnlistenFn[] = [];
|
listen(`ssh:close:${sessionId}`, () => markDisconnected(sessionId));
|
||||||
unlisteners.push(await listen(`ssh:close:${sessionId}`, () => markDisconnected(sessionId)));
|
listen(`ssh:exit:${sessionId}`, () => markDisconnected(sessionId));
|
||||||
unlisteners.push(await listen(`ssh:exit:${sessionId}`, () => markDisconnected(sessionId)));
|
|
||||||
sessionUnlisteners.set(sessionId, unlisteners);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function markDisconnected(sessionId: string): void {
|
function markDisconnected(sessionId: string): void {
|
||||||
@ -57,16 +51,6 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
|
|
||||||
function activateSession(id: string): void {
|
function activateSession(id: string): void {
|
||||||
activeSessionId.value = id;
|
activeSessionId.value = id;
|
||||||
// Clear activity indicator when switching to tab
|
|
||||||
const session = sessions.value.find(s => s.id === id);
|
|
||||||
if (session) session.hasActivity = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mark a background tab as having new activity. */
|
|
||||||
function markActivity(sessionId: string): void {
|
|
||||||
if (sessionId === activeSessionId.value) return; // don't flash the active tab
|
|
||||||
const session = sessions.value.find(s => s.id === sessionId);
|
|
||||||
if (session) session.hasActivity = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reorder sessions by moving a tab from one index to another. */
|
/** Reorder sessions by moving a tab from one index to another. */
|
||||||
@ -97,12 +81,6 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
console.error("Failed to disconnect session:", err);
|
console.error("Failed to disconnect session:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const unlisteners = sessionUnlisteners.get(id);
|
|
||||||
if (unlisteners) {
|
|
||||||
unlisteners.forEach((fn) => fn());
|
|
||||||
sessionUnlisteners.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions.value.splice(idx, 1);
|
sessions.value.splice(idx, 1);
|
||||||
|
|
||||||
if (activeSessionId.value === id) {
|
if (activeSessionId.value === id) {
|
||||||
@ -126,31 +104,38 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
return count === 0 ? baseName : `${baseName} (${count + 1})`;
|
return count === 0 ? baseName : `${baseName} (${count + 1})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CredentialRow = { id: number; name: string; username: string | null; domain?: string | null; credentialType: string; sshKeyId: number | null };
|
/**
|
||||||
|
* Connect to a server by connection ID.
|
||||||
|
* Multiple sessions to the same host are allowed (MobaXTerm-style).
|
||||||
|
* Each gets its own tab with a disambiguated name like "Asgard (2)".
|
||||||
|
*
|
||||||
|
* For Tauri: we must resolve the connection details ourselves and pass
|
||||||
|
* hostname/port/username/password directly to connect_ssh, because the
|
||||||
|
* Rust side has no knowledge of connection IDs — the vault owns credentials.
|
||||||
|
*/
|
||||||
|
async function connect(connectionId: number): Promise<void> {
|
||||||
|
const connectionStore = useConnectionStore();
|
||||||
|
const conn = connectionStore.connections.find((c) => c.id === connectionId);
|
||||||
|
if (!conn) return;
|
||||||
|
|
||||||
async function resolveCredentials(credentialId: number): Promise<CredentialRow | null> {
|
connecting.value = true;
|
||||||
try {
|
try {
|
||||||
const allCreds = await invoke<CredentialRow[]>("list_credentials");
|
if (conn.protocol === "ssh") {
|
||||||
return allCreds.find((c) => c.id === credentialId) ?? null;
|
|
||||||
} catch (credErr) {
|
|
||||||
console.warn("Failed to resolve credential:", credErr);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectSsh(
|
|
||||||
conn: { id: number; name: string; hostname: string; port: number; credentialId?: number | null },
|
|
||||||
connectionId: number,
|
|
||||||
): Promise<void> {
|
|
||||||
let sessionId: string;
|
let sessionId: string;
|
||||||
let resolvedUsername = "";
|
let resolvedUsername = "";
|
||||||
let resolvedPassword = "";
|
let resolvedPassword = "";
|
||||||
|
|
||||||
|
// If connection has a linked credential, decrypt it from the vault
|
||||||
if (conn.credentialId) {
|
if (conn.credentialId) {
|
||||||
const cred = await resolveCredentials(conn.credentialId);
|
try {
|
||||||
|
const allCreds = await invoke<{ id: number; name: string; username: string | null; credentialType: string; sshKeyId: number | null }[]>("list_credentials");
|
||||||
|
const cred = allCreds.find((c) => c.id === conn.credentialId);
|
||||||
|
|
||||||
if (cred) {
|
if (cred) {
|
||||||
resolvedUsername = cred.username ?? "";
|
resolvedUsername = cred.username ?? "";
|
||||||
|
|
||||||
if (cred.credentialType === "ssh_key" && cred.sshKeyId) {
|
if (cred.credentialType === "ssh_key" && cred.sshKeyId) {
|
||||||
|
// SSH key auth — decrypt key from vault
|
||||||
const [privateKey, passphrase] = await invoke<[string, string]>("decrypt_ssh_key", { sshKeyId: cred.sshKeyId });
|
const [privateKey, passphrase] = await invoke<[string, string]>("decrypt_ssh_key", { sshKeyId: cred.sshKeyId });
|
||||||
sessionId = await invoke<string>("connect_ssh_with_key", {
|
sessionId = await invoke<string>("connect_ssh_with_key", {
|
||||||
hostname: conn.hostname,
|
hostname: conn.hostname,
|
||||||
@ -161,6 +146,7 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
cols: 120,
|
cols: 120,
|
||||||
rows: 40,
|
rows: 40,
|
||||||
});
|
});
|
||||||
|
|
||||||
sessions.value.push({
|
sessions.value.push({
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
connectionId,
|
connectionId,
|
||||||
@ -169,19 +155,25 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
active: true,
|
active: true,
|
||||||
username: resolvedUsername,
|
username: resolvedUsername,
|
||||||
status: "connected",
|
status: "connected",
|
||||||
hasActivity: false,
|
|
||||||
});
|
});
|
||||||
setupStatusListeners(sessionId);
|
setupStatusListeners(sessionId);
|
||||||
activeSessionId.value = sessionId;
|
activeSessionId.value = sessionId;
|
||||||
return;
|
return; // early return — key auth handled
|
||||||
} else {
|
} else {
|
||||||
|
// Password auth — decrypt password from vault
|
||||||
resolvedPassword = await invoke<string>("decrypt_password", { credentialId: cred.id });
|
resolvedPassword = await invoke<string>("decrypt_password", { credentialId: cred.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (credErr) {
|
||||||
|
console.warn("Failed to resolve credential, will prompt:", credErr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!resolvedUsername) throw new Error("NO_CREDENTIALS");
|
if (!resolvedUsername) {
|
||||||
|
// No credential linked — prompt immediately
|
||||||
|
throw new Error("NO_CREDENTIALS");
|
||||||
|
}
|
||||||
sessionId = await invoke<string>("connect_ssh", {
|
sessionId = await invoke<string>("connect_ssh", {
|
||||||
hostname: conn.hostname,
|
hostname: conn.hostname,
|
||||||
port: conn.port,
|
port: conn.port,
|
||||||
@ -191,13 +183,20 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
rows: 40,
|
rows: 40,
|
||||||
});
|
});
|
||||||
} catch (sshErr: unknown) {
|
} catch (sshErr: unknown) {
|
||||||
const errMsg = sshErr instanceof Error ? sshErr.message : typeof sshErr === "string" ? sshErr : String(sshErr);
|
const errMsg = sshErr instanceof Error
|
||||||
|
? sshErr.message
|
||||||
|
: typeof sshErr === "string"
|
||||||
|
? sshErr
|
||||||
|
: String(sshErr);
|
||||||
|
|
||||||
|
// If no credentials or auth failed, prompt for username/password
|
||||||
const errLower = errMsg.toLowerCase();
|
const errLower = errMsg.toLowerCase();
|
||||||
if (errLower.includes("no_credentials") || errLower.includes("unable to authenticate") || errLower.includes("authentication") || errLower.includes("rejected")) {
|
if (errLower.includes("no_credentials") || errLower.includes("unable to authenticate") || errLower.includes("authentication") || errLower.includes("rejected")) {
|
||||||
const username = window.prompt(`Username for ${conn.hostname}:`, resolvedUsername || "root");
|
const username = window.prompt(`Username for ${conn.hostname}:`, resolvedUsername || "root");
|
||||||
if (!username) throw new Error("Connection cancelled");
|
if (!username) throw new Error("Connection cancelled");
|
||||||
const password = window.prompt(`Password for ${username}@${conn.hostname}:`);
|
const password = window.prompt(`Password for ${username}@${conn.hostname}:`);
|
||||||
if (password === null) throw new Error("Connection cancelled");
|
if (password === null) throw new Error("Connection cancelled");
|
||||||
|
|
||||||
resolvedUsername = username;
|
resolvedUsername = username;
|
||||||
sessionId = await invoke<string>("connect_ssh", {
|
sessionId = await invoke<string>("connect_ssh", {
|
||||||
hostname: conn.hostname,
|
hostname: conn.hostname,
|
||||||
@ -220,30 +219,16 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
active: true,
|
active: true,
|
||||||
username: resolvedUsername,
|
username: resolvedUsername,
|
||||||
status: "connected",
|
status: "connected",
|
||||||
hasActivity: false,
|
|
||||||
});
|
});
|
||||||
setupStatusListeners(sessionId);
|
setupStatusListeners(sessionId);
|
||||||
activeSessionId.value = sessionId;
|
activeSessionId.value = sessionId;
|
||||||
}
|
} else if (conn.protocol === "rdp") {
|
||||||
|
|
||||||
async function connectRdp(
|
|
||||||
conn: { id: number; name: string; hostname: string; port: number; credentialId?: number | null; options?: string },
|
|
||||||
connectionId: number,
|
|
||||||
): Promise<void> {
|
|
||||||
let username = "";
|
let username = "";
|
||||||
let password = "";
|
let password = "";
|
||||||
let domain = "";
|
let domain = "";
|
||||||
|
|
||||||
if (conn.credentialId) {
|
// Extract stored credentials from connection options JSON if present
|
||||||
const cred = await resolveCredentials(conn.credentialId);
|
if (conn.options) {
|
||||||
if (cred && cred.credentialType === "password") {
|
|
||||||
username = cred.username ?? "";
|
|
||||||
domain = cred.domain ?? "";
|
|
||||||
password = await invoke<string>("decrypt_password", { credentialId: cred.id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!username && conn.options) {
|
|
||||||
try {
|
try {
|
||||||
const opts = JSON.parse(conn.options);
|
const opts = JSON.parse(conn.options);
|
||||||
if (opts?.username) username = opts.username;
|
if (opts?.username) username = opts.username;
|
||||||
@ -257,19 +242,53 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
let sessionId: string;
|
let sessionId: string;
|
||||||
try {
|
try {
|
||||||
sessionId = await invoke<string>("connect_rdp", {
|
sessionId = await invoke<string>("connect_rdp", {
|
||||||
config: { hostname: conn.hostname, port: conn.port, username, password, domain, width: 1920, height: 1080 },
|
config: {
|
||||||
|
hostname: conn.hostname,
|
||||||
|
port: conn.port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
domain,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (rdpErr: unknown) {
|
} catch (rdpErr: unknown) {
|
||||||
const errMsg = rdpErr instanceof Error ? rdpErr.message : typeof rdpErr === "string" ? rdpErr : String(rdpErr);
|
const errMsg =
|
||||||
if (errMsg.includes("NO_CREDENTIALS") || errMsg.includes("authentication") || errMsg.includes("logon failure")) {
|
rdpErr instanceof Error
|
||||||
const promptedUsername = prompt(`Username for ${conn.hostname}:`, "Administrator");
|
? rdpErr.message
|
||||||
|
: typeof rdpErr === "string"
|
||||||
|
? rdpErr
|
||||||
|
: String(rdpErr);
|
||||||
|
|
||||||
|
// If credentials are missing or rejected, prompt the operator
|
||||||
|
if (
|
||||||
|
errMsg.includes("NO_CREDENTIALS") ||
|
||||||
|
errMsg.includes("authentication") ||
|
||||||
|
errMsg.includes("logon failure")
|
||||||
|
) {
|
||||||
|
const promptedUsername = prompt(
|
||||||
|
`Username for ${conn.hostname}:`,
|
||||||
|
"Administrator",
|
||||||
|
);
|
||||||
if (!promptedUsername) throw new Error("Connection cancelled");
|
if (!promptedUsername) throw new Error("Connection cancelled");
|
||||||
const promptedPassword = prompt(`Password for ${promptedUsername}@${conn.hostname}:`);
|
const promptedPassword = prompt(
|
||||||
|
`Password for ${promptedUsername}@${conn.hostname}:`,
|
||||||
|
);
|
||||||
if (promptedPassword === null) throw new Error("Connection cancelled");
|
if (promptedPassword === null) throw new Error("Connection cancelled");
|
||||||
const promptedDomain = prompt(`Domain (leave blank if none):`, "") ?? "";
|
const promptedDomain = prompt(`Domain (leave blank if none):`, "") ?? "";
|
||||||
|
|
||||||
username = promptedUsername;
|
username = promptedUsername;
|
||||||
|
|
||||||
sessionId = await invoke<string>("connect_rdp", {
|
sessionId = await invoke<string>("connect_rdp", {
|
||||||
config: { hostname: conn.hostname, port: conn.port, username: promptedUsername, password: promptedPassword, domain: promptedDomain, width: 1920, height: 1080 },
|
config: {
|
||||||
|
hostname: conn.hostname,
|
||||||
|
port: conn.port,
|
||||||
|
username: promptedUsername,
|
||||||
|
password: promptedPassword,
|
||||||
|
domain: promptedDomain,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
throw rdpErr;
|
throw rdpErr;
|
||||||
@ -284,32 +303,14 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
active: true,
|
active: true,
|
||||||
username,
|
username,
|
||||||
status: "connected",
|
status: "connected",
|
||||||
hasActivity: false,
|
|
||||||
});
|
});
|
||||||
activeSessionId.value = sessionId;
|
activeSessionId.value = sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to a server by connection ID.
|
|
||||||
* Multiple sessions to the same host are allowed (MobaXTerm-style).
|
|
||||||
* Each gets its own tab with a disambiguated name like "Asgard (2)".
|
|
||||||
*/
|
|
||||||
async function connect(connectionId: number): Promise<void> {
|
|
||||||
const connectionStore = useConnectionStore();
|
|
||||||
const conn = connectionStore.connections.find((c) => c.id === connectionId);
|
|
||||||
if (!conn) return;
|
|
||||||
|
|
||||||
connecting.value = true;
|
|
||||||
try {
|
|
||||||
if (conn.protocol === "ssh") {
|
|
||||||
await connectSsh(conn, connectionId);
|
|
||||||
} else if (conn.protocol === "rdp") {
|
|
||||||
await connectRdp(conn, connectionId);
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
|
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
|
||||||
console.error("Connection failed:", msg);
|
console.error("Connection failed:", msg);
|
||||||
lastError.value = msg;
|
lastError.value = msg;
|
||||||
|
// Show error as native alert so it's visible without DevTools
|
||||||
alert(`Connection failed: ${msg}`);
|
alert(`Connection failed: ${msg}`);
|
||||||
} finally {
|
} finally {
|
||||||
connecting.value = false;
|
connecting.value = false;
|
||||||
@ -332,12 +333,10 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
protocol: "local",
|
protocol: "local",
|
||||||
active: true,
|
active: true,
|
||||||
status: "connected",
|
status: "connected",
|
||||||
hasActivity: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for PTY close
|
// Listen for PTY close
|
||||||
const unlistenPty = await listen(`pty:close:${sessionId}`, () => markDisconnected(sessionId));
|
listen(`pty:close:${sessionId}`, () => markDisconnected(sessionId));
|
||||||
sessionUnlisteners.set(sessionId, [unlistenPty]);
|
|
||||||
|
|
||||||
activeSessionId.value = sessionId;
|
activeSessionId.value = sessionId;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@ -377,7 +376,6 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
connect,
|
connect,
|
||||||
spawnLocalTab,
|
spawnLocalTab,
|
||||||
moveSession,
|
moveSession,
|
||||||
markActivity,
|
|
||||||
setTheme,
|
setTheme,
|
||||||
setTerminalDimensions,
|
setTerminalDimensions,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,20 +1,10 @@
|
|||||||
import { defineConfig, type Plugin } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
|
|
||||||
/** Strip crossorigin attribute from HTML — WKWebView + Tauri custom protocol compatibility. */
|
|
||||||
function stripCrossOrigin(): Plugin {
|
|
||||||
return {
|
|
||||||
name: "strip-crossorigin",
|
|
||||||
transformIndexHtml(html) {
|
|
||||||
return html.replace(/ crossorigin/g, "");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue(), tailwindcss(), stripCrossOrigin()],
|
plugins: [vue(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": resolve(__dirname, "src"),
|
"@": resolve(__dirname, "src"),
|
||||||
@ -33,9 +23,5 @@ export default defineConfig({
|
|||||||
target: ["es2021", "chrome100", "safari13"],
|
target: ["es2021", "chrome100", "safari13"],
|
||||||
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
||||||
sourcemap: !!process.env.TAURI_DEBUG,
|
sourcemap: !!process.env.TAURI_DEBUG,
|
||||||
// Disable crossorigin attribute on script/link tags — WKWebView on
|
|
||||||
// macOS may reject CORS-mode requests for Tauri's custom tauri://
|
|
||||||
// protocol in dynamically created child WebviewWindows.
|
|
||||||
crossOriginLoading: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user