diff --git a/src/components/ai/CopilotPanel.vue b/src/components/ai/CopilotPanel.vue index d6c7735..22ac6cf 100644 --- a/src/components/ai/CopilotPanel.vue +++ b/src/components/ai/CopilotPanel.vue @@ -203,25 +203,28 @@ async function launch(): Promise { }); connected.value = true; - await nextTick(); + // Instantiate terminal synchronously (before any further awaits) now that + // sessionId is known. Cleanup is owned by this component's onBeforeUnmount. + terminalInstance = useTerminal(sessionId, "pty"); - if (containerRef.value) { - terminalInstance = useTerminal(sessionId, "pty"); - terminalInstance.mount(containerRef.value); + nextTick(() => { + if (containerRef.value && terminalInstance) { + terminalInstance.mount(containerRef.value); - // Fit after mount to get real dimensions, then resize the PTY - setTimeout(() => { - if (terminalInstance) { - terminalInstance.fit(); - const term = terminalInstance.terminal; - invoke("pty_resize", { - sessionId, - cols: term.cols, - rows: term.rows, - }).catch(() => {}); - } - }, 50); - } + // Fit after mount to get real dimensions, then resize the PTY + setTimeout(() => { + if (terminalInstance) { + terminalInstance.fit(); + const term = terminalInstance.terminal; + invoke("pty_resize", { + sessionId, + cols: term.cols, + rows: term.rows, + }).catch(() => {}); + } + }, 50); + } + }); // Listen for shell exit closeUnlisten = await listen(`pty:close:${sessionId}`, () => { diff --git a/src/components/session/DetachedSession.vue b/src/components/session/DetachedSession.vue index 7ed8d70..42f10e2 100644 --- a/src/components/session/DetachedSession.vue +++ b/src/components/session/DetachedSession.vue @@ -26,36 +26,34 @@ const sessionName = ref("Detached Session"); const protocol = ref("ssh"); const containerRef = ref(null); -let terminalInstance: ReturnType | 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 () => { - // Parse session info from URL hash - const hash = window.location.hash; - const params = new URLSearchParams(hash.split("?")[1] || ""); - sessionId.value = params.get("sessionId") || ""; + sessionId.value = _initialSessionId; sessionName.value = decodeURIComponent(params.get("name") || "Detached Session"); - protocol.value = params.get("protocol") || "ssh"; + protocol.value = _initialProtocol; if (!sessionId.value || !containerRef.value) return; - // Determine backend type - const backend = protocol.value === "local" ? "pty" : "ssh"; - terminalInstance = useTerminal(sessionId.value, backend as 'ssh' | 'pty'); terminalInstance.mount(containerRef.value); setTimeout(() => { - if (terminalInstance) { - terminalInstance.fit(); - terminalInstance.terminal.focus(); + terminalInstance.fit(); + terminalInstance.terminal.focus(); - // Resize the backend - const resizeCmd = backend === "ssh" ? "ssh_resize" : "pty_resize"; - invoke(resizeCmd, { - sessionId: sessionId.value, - cols: terminalInstance.terminal.cols, - rows: terminalInstance.terminal.rows, - }).catch(() => {}); - } + 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 @@ -72,9 +70,6 @@ onMounted(async () => { }); onBeforeUnmount(() => { - if (terminalInstance) { - terminalInstance.destroy(); - terminalInstance = null; - } + terminalInstance.destroy(); }); diff --git a/src/components/terminal/LocalTerminalView.vue b/src/components/terminal/LocalTerminalView.vue index 79be014..49bda8d 100644 --- a/src/components/terminal/LocalTerminalView.vue +++ b/src/components/terminal/LocalTerminalView.vue @@ -21,19 +21,10 @@ const props = defineProps<{ const containerRef = ref(null); const { terminal, mount, fit, destroy } = useTerminal(props.sessionId, "pty"); -let resizeObserver: ResizeObserver | null = null; onMounted(() => { if (containerRef.value) { mount(containerRef.value); - - // Watch container size changes and refit terminal - resizeObserver = new ResizeObserver(() => { - if (props.isActive) { - fit(); - } - }); - resizeObserver.observe(containerRef.value); } setTimeout(() => { fit(); @@ -66,10 +57,6 @@ watch( ); onBeforeUnmount(() => { - if (resizeObserver) { - resizeObserver.disconnect(); - resizeObserver = null; - } destroy(); }); diff --git a/src/components/terminal/TerminalView.vue b/src/components/terminal/TerminalView.vue index d9d1dc1..70c4df1 100644 --- a/src/components/terminal/TerminalView.vue +++ b/src/components/terminal/TerminalView.vue @@ -59,11 +59,12 @@ diff --git a/src/stores/session.store.ts b/src/stores/session.store.ts index d31b812..8b7c10c 100644 --- a/src/stores/session.store.ts +++ b/src/stores/session.store.ts @@ -115,14 +115,173 @@ export const useSessionStore = defineStore("session", () => { return count === 0 ? baseName : `${baseName} (${count + 1})`; } + type CredentialRow = { id: number; name: string; username: string | null; domain?: string | null; credentialType: string; sshKeyId: number | null }; + + async function resolveCredentials(credentialId: number): Promise { + try { + const allCreds = await invoke("list_credentials"); + 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 { + let sessionId: string; + let resolvedUsername = ""; + let resolvedPassword = ""; + + if (conn.credentialId) { + const cred = await resolveCredentials(conn.credentialId); + if (cred) { + resolvedUsername = cred.username ?? ""; + if (cred.credentialType === "ssh_key" && cred.sshKeyId) { + const [privateKey, passphrase] = await invoke<[string, string]>("decrypt_ssh_key", { sshKeyId: cred.sshKeyId }); + sessionId = await invoke("connect_ssh_with_key", { + hostname: conn.hostname, + port: conn.port, + username: resolvedUsername, + privateKeyPem: privateKey, + passphrase: passphrase || null, + cols: 120, + rows: 40, + }); + sessions.value.push({ + id: sessionId, + connectionId, + name: disambiguatedName(conn.name, connectionId), + protocol: "ssh", + active: true, + username: resolvedUsername, + status: "connected", + hasActivity: false, + }); + setupStatusListeners(sessionId); + activeSessionId.value = sessionId; + return; + } else { + resolvedPassword = await invoke("decrypt_password", { credentialId: cred.id }); + } + } + } + + try { + if (!resolvedUsername) throw new Error("NO_CREDENTIALS"); + sessionId = await invoke("connect_ssh", { + hostname: conn.hostname, + port: conn.port, + username: resolvedUsername, + password: resolvedPassword, + cols: 120, + rows: 40, + }); + } catch (sshErr: unknown) { + const errMsg = sshErr instanceof Error ? sshErr.message : typeof sshErr === "string" ? sshErr : String(sshErr); + const errLower = errMsg.toLowerCase(); + 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"); + if (!username) throw new Error("Connection cancelled"); + const password = window.prompt(`Password for ${username}@${conn.hostname}:`); + if (password === null) throw new Error("Connection cancelled"); + resolvedUsername = username; + sessionId = await invoke("connect_ssh", { + hostname: conn.hostname, + port: conn.port, + username, + password, + cols: 120, + rows: 40, + }); + } else { + throw sshErr; + } + } + + sessions.value.push({ + id: sessionId, + connectionId, + name: disambiguatedName(conn.name, connectionId), + protocol: "ssh", + active: true, + username: resolvedUsername, + status: "connected", + hasActivity: false, + }); + setupStatusListeners(sessionId); + activeSessionId.value = sessionId; + } + + async function connectRdp( + conn: { id: number; name: string; hostname: string; port: number; credentialId?: number | null; options?: string }, + connectionId: number, + ): Promise { + let username = ""; + let password = ""; + let domain = ""; + + if (conn.credentialId) { + const cred = await resolveCredentials(conn.credentialId); + if (cred && cred.credentialType === "password") { + username = cred.username ?? ""; + domain = cred.domain ?? ""; + password = await invoke("decrypt_password", { credentialId: cred.id }); + } + } + + if (!username && conn.options) { + try { + const opts = JSON.parse(conn.options); + if (opts?.username) username = opts.username; + if (opts?.password) password = opts.password; + if (opts?.domain) domain = opts.domain; + } catch { + // ignore malformed options + } + } + + let sessionId: string; + try { + sessionId = await invoke("connect_rdp", { + config: { hostname: conn.hostname, port: conn.port, username, password, domain, width: 1920, height: 1080 }, + }); + } catch (rdpErr: unknown) { + const errMsg = rdpErr instanceof Error ? rdpErr.message : typeof rdpErr === "string" ? rdpErr : String(rdpErr); + 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"); + const promptedPassword = prompt(`Password for ${promptedUsername}@${conn.hostname}:`); + if (promptedPassword === null) throw new Error("Connection cancelled"); + const promptedDomain = prompt(`Domain (leave blank if none):`, "") ?? ""; + username = promptedUsername; + sessionId = await invoke("connect_rdp", { + config: { hostname: conn.hostname, port: conn.port, username: promptedUsername, password: promptedPassword, domain: promptedDomain, width: 1920, height: 1080 }, + }); + } else { + throw rdpErr; + } + } + + sessions.value.push({ + id: sessionId, + connectionId, + name: disambiguatedName(conn.name, connectionId), + protocol: "rdp", + active: true, + username, + status: "connected", + hasActivity: false, + }); + 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)". - * - * 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 { const connectionStore = useConnectionStore(); @@ -132,214 +291,14 @@ export const useSessionStore = defineStore("session", () => { connecting.value = true; try { if (conn.protocol === "ssh") { - let sessionId: string; - let resolvedUsername = ""; - let resolvedPassword = ""; - - // If connection has a linked credential, decrypt it from the vault - if (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) { - resolvedUsername = cred.username ?? ""; - - 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 }); - sessionId = await invoke("connect_ssh_with_key", { - hostname: conn.hostname, - port: conn.port, - username: resolvedUsername, - privateKeyPem: privateKey, - passphrase: passphrase || null, - cols: 120, - rows: 40, - }); - - sessions.value.push({ - id: sessionId, - connectionId, - name: disambiguatedName(conn.name, connectionId), - protocol: "ssh", - active: true, - username: resolvedUsername, - status: "connected", - hasActivity: false, - }); - setupStatusListeners(sessionId); - activeSessionId.value = sessionId; - return; // early return — key auth handled - } else { - // Password auth — decrypt password from vault - resolvedPassword = await invoke("decrypt_password", { credentialId: cred.id }); - } - } - } catch (credErr) { - console.warn("Failed to resolve credential, will prompt:", credErr); - } - } - - try { - if (!resolvedUsername) { - // No credential linked — prompt immediately - throw new Error("NO_CREDENTIALS"); - } - sessionId = await invoke("connect_ssh", { - hostname: conn.hostname, - port: conn.port, - username: resolvedUsername, - password: resolvedPassword, - cols: 120, - rows: 40, - }); - } catch (sshErr: unknown) { - 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(); - 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"); - if (!username) throw new Error("Connection cancelled"); - const password = window.prompt(`Password for ${username}@${conn.hostname}:`); - if (password === null) throw new Error("Connection cancelled"); - - resolvedUsername = username; - sessionId = await invoke("connect_ssh", { - hostname: conn.hostname, - port: conn.port, - username, - password, - cols: 120, - rows: 40, - }); - } else { - throw sshErr; - } - } - - sessions.value.push({ - id: sessionId, - connectionId, - name: disambiguatedName(conn.name, connectionId), - protocol: "ssh", - active: true, - username: resolvedUsername, - status: "connected", - hasActivity: false, - }); - setupStatusListeners(sessionId); - activeSessionId.value = sessionId; + await connectSsh(conn, connectionId); } else if (conn.protocol === "rdp") { - let username = ""; - let password = ""; - let domain = ""; - - // 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; - if (opts?.password) password = opts.password; - if (opts?.domain) domain = opts.domain; - } catch { - // ignore malformed options - } - } - - let sessionId: string; - try { - sessionId = await invoke("connect_rdp", { - config: { - hostname: conn.hostname, - port: conn.port, - username, - password, - domain, - width: 1920, - height: 1080, - }, - }); - } catch (rdpErr: unknown) { - const errMsg = - rdpErr instanceof Error - ? 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"); - const promptedPassword = prompt( - `Password for ${promptedUsername}@${conn.hostname}:`, - ); - if (promptedPassword === null) throw new Error("Connection cancelled"); - const promptedDomain = prompt(`Domain (leave blank if none):`, "") ?? ""; - - username = promptedUsername; - - sessionId = await invoke("connect_rdp", { - config: { - hostname: conn.hostname, - port: conn.port, - username: promptedUsername, - password: promptedPassword, - domain: promptedDomain, - width: 1920, - height: 1080, - }, - }); - } else { - throw rdpErr; - } - } - - sessions.value.push({ - id: sessionId, - connectionId, - name: disambiguatedName(conn.name, connectionId), - protocol: "rdp", - active: true, - username, - status: "connected", - hasActivity: false, - }); - activeSessionId.value = sessionId; + await connectRdp(conn, connectionId); } } catch (err: unknown) { const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err); console.error("Connection failed:", msg); lastError.value = msg; - // Show error as native alert so it's visible without DevTools alert(`Connection failed: ${msg}`); } finally { connecting.value = false;