feat: Tauri auto-updater + RDP vault credentials + sidebar persist
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 2m55s

Tauri auto-updater:
- Signing pubkey in tauri.conf.json
- tauri-plugin-updater initialized in lib.rs
- CI workflow passes TAURI_SIGNING_PRIVATE_KEY env vars to cargo tauri build
- CI generates update.json manifest with signature and uploads to
  packages/latest/update.json endpoint
- Frontend checks for updates on startup via @tauri-apps/plugin-updater
- Downloads, installs, and relaunches seamlessly
- Settings → About button uses native updater too

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-25 12:42:01 -04:00
parent 7c2ab2aa60
commit 0c6a4b8109
13 changed files with 211 additions and 56 deletions

View File

@ -61,13 +61,15 @@ jobs:
$env:Path = "$env:EXTRA_PATH;$env:Path"
cargo install tauri-cli --version "^2"
- name: Build Tauri app
- name: Build Tauri app (with update signing)
shell: powershell
run: |
$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
Write-Host "=== Build output ==="
Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*
- name: Download jsign
shell: powershell
@ -122,6 +124,55 @@ jobs:
Write-Host "=== Upload complete ==="
- name: Generate and upload update.json for Tauri updater
shell: powershell
run: |
$ver = ("${{ github.ref_name }}" -replace '^v','')
$giteaUrl = "https://git.command.vigilcyber.com"
$headers = @{ Authorization = "token ${{ secrets.GIT_TOKEN }}" }
# Find the .sig file produced by Tauri signing
$sigFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip.sig | Select-Object -First 1
$zipFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip | Select-Object -First 1
if ($sigFile -and $zipFile) {
$signature = Get-Content $sigFile.FullName -Raw
$downloadUrl = "$giteaUrl/api/packages/vstockwell/generic/wraith/$ver/$($zipFile.Name)"
# Upload the .nsis.zip to packages
Write-Host "Uploading: $($zipFile.Name)"
Invoke-RestMethod -Uri "$giteaUrl/api/packages/vstockwell/generic/wraith/$ver/$($zipFile.Name)" -Method PUT -Headers $headers -ContentType "application/octet-stream" -InFile $zipFile.FullName
# Build update.json
$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 latest/ so the updater endpoint always points to the newest
Invoke-RestMethod -Uri "$giteaUrl/api/packages/vstockwell/generic/wraith/latest/update.json" -Method PUT -Headers $headers -ContentType "application/octet-stream" -InFile update.json
# Also upload to versioned path
Invoke-RestMethod -Uri "$giteaUrl/api/packages/vstockwell/generic/wraith/$ver/update.json" -Method PUT -Headers $headers -ContentType "application/octet-stream" -InFile update.json
Write-Host "=== Update manifest uploaded ==="
} else {
Write-Host "WARNING: No .sig file found — update signing may have failed"
Write-Host "Sig files found:"
Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.sig
}
- name: Create Release and attach installers
shell: powershell
run: |

20
package-lock.json generated
View File

@ -19,7 +19,9 @@
"@codemirror/theme-one-dark": "^6.0.0",
"@codemirror/view": "^6.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-updater": "^2.10.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
@ -1516,6 +1518,15 @@
"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": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
@ -1525,6 +1536,15 @@
"@tauri-apps/api": "^2.10.1"
}
},
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",

View File

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

View File

@ -9,6 +9,7 @@
"core:window:allow-create",
"core:webview:default",
"core:webview:allow-create-webview-window",
"shell:allow-open"
"shell:allow-open",
"updater:default"
]
}

View File

@ -1 +1 @@
{"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","core:window:allow-create","core:webview:default","core:webview:allow-create-webview-window","shell:allow-open"]}}
{"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","core:window:allow-create","core:webview:default","core:webview:allow-create-webview-window","shell:allow-open","updater:default"]}}

View File

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

View File

@ -429,6 +429,32 @@ impl ConnectionService {
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();
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(())
}
/// Batch-update sort_order for a list of group IDs.
pub fn reorder_groups(&self, ids: &[i64]) -> Result<(), String> {
let conn = self.db.conn();
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(())
}
}
// ── private helpers ───────────────────────────────────────────────────────────

View File

@ -130,6 +130,7 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(app_state)
.setup(|app| {
#[cfg(debug_assertions)]
@ -191,7 +192,7 @@ pub fn run() {
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::connections::list_connections, commands::connections::create_connection, commands::connections::get_connection, commands::connections::update_connection, commands::connections::delete_connection,
commands::connections::list_groups, commands::connections::create_group, commands::connections::delete_group, commands::connections::rename_group, commands::connections::search_connections,
commands::connections::list_groups, commands::connections::create_group, commands::connections::delete_group, commands::connections::rename_group, commands::connections::search_connections, commands::connections::reorder_connections, commands::connections::reorder_groups,
commands::credentials::list_credentials, commands::credentials::create_password, commands::credentials::create_ssh_key, commands::credentials::delete_credential, commands::credentials::decrypt_password, commands::credentials::decrypt_ssh_key,
commands::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,

View File

@ -48,6 +48,12 @@
"plugins": {
"shell": {
"open": true
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNCRkQ2OUY2OEY0Q0ZFQkYKUldTLy9reVA5bW45T3dUQ1R5OFNCenVhL2srTXlLcHR4cFNaeCtJSmJUSTZKSUNHVTRIbWZwanEK",
"endpoints": [
"https://git.command.vigilcyber.com/api/packages/vstockwell/generic/wraith/latest/update.json"
]
}
}
}

View File

@ -320,7 +320,25 @@ async function checkUpdates(): Promise<void> {
updateChecking.value = true;
updateInfo.value = null;
try {
updateInfo.value = await invoke<UpdateCheckInfo>("check_for_updates");
const { check } = await import("@tauri-apps/plugin-updater");
const update = await check();
if (update?.available) {
updateInfo.value = {
currentVersion: await getVersion(),
latestVersion: update.version || "unknown",
updateAvailable: true,
downloadUrl: "",
releaseNotes: update.body || "",
};
} else {
updateInfo.value = {
currentVersion: await getVersion(),
latestVersion: await getVersion(),
updateAvailable: false,
downloadUrl: "",
releaseNotes: "",
};
}
} catch (err) {
alert(`Update check failed: ${err}`);
}
@ -328,11 +346,16 @@ async function checkUpdates(): Promise<void> {
}
async function downloadUpdate(): Promise<void> {
if (!updateInfo.value?.downloadUrl) return;
try {
await shellOpen(updateInfo.value.downloadUrl);
} catch {
window.open(updateInfo.value.downloadUrl, "_blank");
const { check } = await import("@tauri-apps/plugin-updater");
const update = await check();
if (update?.available) {
await update.downloadAndInstall();
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
}
} catch (err) {
alert(`Update failed: ${err}`);
}
}
const currentVersion = ref("loading...");

View File

@ -157,26 +157,22 @@ function onGroupDragOver(target: Group): void {
async function onGroupDrop(target: Group): Promise<void> {
if (draggedGroup && draggedGroup.id !== target.id) {
// Reorder groups swap sort_order
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) {
// Move connection to different group
try {
await invoke("update_connection", {
id: draggedConn.conn.id,
input: { groupId: target.id },
});
await invoke("update_connection", { id: draggedConn.conn.id, input: { groupId: target.id } });
await connectionStore.loadAll();
} catch (err) {
console.error("Failed to move connection:", err);
}
} catch (err) { console.error("Failed to move connection:", err); }
}
resetDragState();
}
@ -195,26 +191,21 @@ function onConnDragOver(target: Connection): void {
async function onConnDrop(target: Connection, targetGroupId: number): Promise<void> {
if (draggedConn && draggedConn.conn.id !== target.id) {
// Reorder within group or move between groups
if (draggedConn.fromGroupId !== targetGroupId) {
// Move to different group
try {
await invoke("update_connection", {
id: draggedConn.conn.id,
input: { groupId: targetGroupId },
});
await invoke("update_connection", { id: draggedConn.conn.id, input: { groupId: targetGroupId } });
await connectionStore.loadAll();
} catch (err) {
console.error("Failed to move connection:", err);
}
} catch (err) { console.error("Failed to move connection:", err); }
} else {
// Reorder within same group
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);
}
}
}

View File

@ -462,16 +462,19 @@ onMounted(async () => {
});
} catch {}
// Check for updates on startup (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"));
// Check for updates on startup via Tauri updater plugin (non-blocking)
import("@tauri-apps/plugin-updater").then(async ({ check }) => {
try {
const update = await check();
if (update?.available) {
if (confirm(`Wraith v${update.version} is available. Download and install?`)) {
await update.downloadAndInstall();
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
}
}
})
.catch(() => {}); // Silent fail no internet is fine
} catch {}
}).catch(() => {});
});
onUnmounted(() => {

View File

@ -240,8 +240,23 @@ export const useSessionStore = defineStore("session", () => {
let password = "";
let domain = "";
// Extract stored credentials from connection options JSON if present
if (conn.options) {
// Try vault credentials first (same as SSH path)
if (conn.credentialId) {
try {
const allCreds = await invoke<{ id: number; name: string; username: string | null; domain: string | null; credentialType: string; sshKeyId: number | null }[]>("list_credentials");
const cred = allCreds.find((c) => c.id === conn.credentialId);
if (cred && cred.credentialType === "password") {
username = cred.username ?? "";
domain = cred.domain ?? "";
password = await invoke<string>("decrypt_password", { credentialId: cred.id });
}
} catch (credErr) {
console.warn("Failed to resolve RDP credential from vault:", credErr);
}
}
// Fall back to connection options JSON if vault didn't provide creds
if (!username && conn.options) {
try {
const opts = JSON.parse(conn.options);
if (opts?.username) username = opts.username;