From 0c6a4b8109470f8ab04853ccb3112319a422470f Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Wed, 25 Mar 2026 12:42:01 -0400 Subject: [PATCH] feat: Tauri auto-updater + RDP vault credentials + sidebar persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/build-release.yml | 55 ++++++++++++++++++++++- package-lock.json | 20 +++++++++ package.json | 36 ++++++++------- src-tauri/capabilities/default.json | 3 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/src/commands/connections.rs | 16 +++++++ src-tauri/src/connections/mod.rs | 26 +++++++++++ src-tauri/src/lib.rs | 3 +- src-tauri/tauri.conf.json | 6 +++ src/components/common/SettingsModal.vue | 33 +++++++++++--- src/components/sidebar/ConnectionTree.vue | 29 +++++------- src/layouts/MainLayout.vue | 19 ++++---- src/stores/session.store.ts | 19 +++++++- 13 files changed, 211 insertions(+), 56 deletions(-) diff --git a/.gitea/workflows/build-release.yml b/.gitea/workflows/build-release.yml index 5828815..96ab3f0 100644 --- a/.gitea/workflows/build-release.yml +++ b/.gitea/workflows/build-release.yml @@ -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: | diff --git a/package-lock.json b/package-lock.json index a4007a8..f3a5b9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f47d3bd..1ef3473 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8d310dc..cbb7a41 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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" ] } diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 1f051ca..eb80d06 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -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"]}} \ No newline at end of file +{"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"]}} \ No newline at end of file diff --git a/src-tauri/src/commands/connections.rs b/src-tauri/src/commands/connections.rs index e5c63ae..51ac429 100644 --- a/src-tauri/src/commands/connections.rs +++ b/src-tauri/src/commands/connections.rs @@ -92,3 +92,19 @@ pub fn search_connections( ) -> Result, String> { state.connections.search(&query) } + +#[tauri::command] +pub fn reorder_connections( + ids: Vec, + state: State<'_, AppState>, +) -> Result<(), String> { + state.connections.reorder_connections(&ids) +} + +#[tauri::command] +pub fn reorder_groups( + ids: Vec, + state: State<'_, AppState>, +) -> Result<(), String> { + state.connections.reorder_groups(&ids) +} diff --git a/src-tauri/src/connections/mod.rs b/src-tauri/src/connections/mod.rs index 479c2d1..2785f18 100644 --- a/src-tauri/src/connections/mod.rs +++ b/src-tauri/src/connections/mod.rs @@ -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 ─────────────────────────────────────────────────────────── diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5116e80..b60ff80 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5a130bd..ea538f3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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" + ] } } } diff --git a/src/components/common/SettingsModal.vue b/src/components/common/SettingsModal.vue index 82ab4bd..dbb93a1 100644 --- a/src/components/common/SettingsModal.vue +++ b/src/components/common/SettingsModal.vue @@ -320,7 +320,25 @@ async function checkUpdates(): Promise { updateChecking.value = true; updateInfo.value = null; try { - updateInfo.value = await invoke("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 { } async function downloadUpdate(): Promise { - 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..."); diff --git a/src/components/sidebar/ConnectionTree.vue b/src/components/sidebar/ConnectionTree.vue index 4a38aeb..265f084 100644 --- a/src/components/sidebar/ConnectionTree.vue +++ b/src/components/sidebar/ConnectionTree.vue @@ -157,26 +157,22 @@ function onGroupDragOver(target: Group): void { async function onGroupDrop(target: Group): Promise { 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 { 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); } } } diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index e535890..6428fed 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -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(() => { diff --git a/src/stores/session.store.ts b/src/stores/session.store.ts index 29f9277..d31b812 100644 --- a/src/stores/session.store.ts +++ b/src/stores/session.store.ts @@ -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("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;