merge: VUE-1 through VUE-8 lifecycle fixes

This commit is contained in:
Vantz Stockwell 2026-03-29 16:40:21 -04:00
commit 3843f18b31
6 changed files with 233 additions and 269 deletions

View File

@ -203,25 +203,28 @@ async function launch(): Promise<void> {
}); });
connected.value = true; 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) { nextTick(() => {
terminalInstance = useTerminal(sessionId, "pty"); if (containerRef.value && terminalInstance) {
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
setTimeout(() => { setTimeout(() => {
if (terminalInstance) { if (terminalInstance) {
terminalInstance.fit(); terminalInstance.fit();
const term = terminalInstance.terminal; const term = terminalInstance.terminal;
invoke("pty_resize", { invoke("pty_resize", {
sessionId, sessionId,
cols: term.cols, cols: term.cols,
rows: term.rows, rows: term.rows,
}).catch(() => {}); }).catch(() => {});
} }
}, 50); }, 50);
} }
});
// Listen for shell exit // Listen for shell exit
closeUnlisten = await listen(`pty:close:${sessionId}`, () => { closeUnlisten = await listen(`pty:close:${sessionId}`, () => {

View File

@ -26,36 +26,34 @@ const sessionName = ref("Detached Session");
const protocol = ref("ssh"); const protocol = ref("ssh");
const containerRef = ref<HTMLElement | null>(null); const containerRef = ref<HTMLElement | null>(null);
let terminalInstance: ReturnType<typeof useTerminal> | 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 () => { onMounted(async () => {
// Parse session info from URL hash sessionId.value = _initialSessionId;
const hash = window.location.hash;
const params = new URLSearchParams(hash.split("?")[1] || "");
sessionId.value = params.get("sessionId") || "";
sessionName.value = decodeURIComponent(params.get("name") || "Detached Session"); sessionName.value = decodeURIComponent(params.get("name") || "Detached Session");
protocol.value = params.get("protocol") || "ssh"; protocol.value = _initialProtocol;
if (!sessionId.value || !containerRef.value) return; 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); terminalInstance.mount(containerRef.value);
setTimeout(() => { setTimeout(() => {
if (terminalInstance) { terminalInstance.fit();
terminalInstance.fit(); terminalInstance.terminal.focus();
terminalInstance.terminal.focus();
// Resize the backend const resizeCmd = _backend === "ssh" ? "ssh_resize" : "pty_resize";
const resizeCmd = backend === "ssh" ? "ssh_resize" : "pty_resize"; invoke(resizeCmd, {
invoke(resizeCmd, { sessionId: sessionId.value,
sessionId: sessionId.value, cols: terminalInstance.terminal.cols,
cols: terminalInstance.terminal.cols, rows: terminalInstance.terminal.rows,
rows: terminalInstance.terminal.rows, }).catch(() => {});
}).catch(() => {});
}
}, 50); }, 50);
// On window close, emit event so main window reattaches the tab // On window close, emit event so main window reattaches the tab
@ -72,9 +70,6 @@ onMounted(async () => {
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (terminalInstance) { terminalInstance.destroy();
terminalInstance.destroy();
terminalInstance = null;
}
}); });
</script> </script>

View File

@ -21,19 +21,10 @@ const props = defineProps<{
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, destroy } = useTerminal(props.sessionId, "pty");
let resizeObserver: ResizeObserver | null = null;
onMounted(() => { onMounted(() => {
if (containerRef.value) { if (containerRef.value) {
mount(containerRef.value); mount(containerRef.value);
// Watch container size changes and refit terminal
resizeObserver = new ResizeObserver(() => {
if (props.isActive) {
fit();
}
});
resizeObserver.observe(containerRef.value);
} }
setTimeout(() => { setTimeout(() => {
fit(); fit();
@ -66,10 +57,6 @@ watch(
); );
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
destroy(); destroy();
}); });
</script> </script>

View File

@ -59,11 +59,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick, onMounted, watch } from "vue"; import { ref, nextTick, onMounted, onBeforeUnmount, 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 { 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<{
@ -74,6 +75,7 @@ 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;
// --- Search state --- // --- Search state ---
const searchVisible = ref(false); const searchVisible = ref(false);
@ -139,7 +141,7 @@ onMounted(() => {
} }
// Track terminal dimensions in the session store // Track terminal dimensions in the session store
terminal.onResize(({ cols, rows }) => { resizeDisposable = terminal.onResize(({ cols, rows }) => {
sessionStore.setTerminalDimensions(props.sessionId, cols, rows); sessionStore.setTerminalDimensions(props.sessionId, cols, rows);
}); });
@ -162,7 +164,9 @@ watch(
fit(); fit();
terminal.focus(); terminal.focus();
// Also notify the backend of the correct size // Also notify the backend of the correct size
invoke("ssh_resize", { const session = sessionStore.sessions.find(s => s.id === props.sessionId);
const resizeCmd = session?.protocol === "local" ? "pty_resize" : "ssh_resize";
invoke(resizeCmd, {
sessionId: props.sessionId, sessionId: props.sessionId,
cols: terminal.cols, cols: terminal.cols,
rows: terminal.rows, rows: terminal.rows,
@ -205,6 +209,13 @@ watch(() => sessionStore.activeTheme, (newTheme) => {
if (newTheme) applyTheme(); if (newTheme) applyTheme();
}); });
onBeforeUnmount(() => {
if (resizeDisposable) {
resizeDisposable.dispose();
resizeDisposable = null;
}
});
function handleFocus(): void { function handleFocus(): void {
terminal.focus(); terminal.focus();
} }

View File

@ -494,15 +494,19 @@ function handleKeydown(event: KeyboardEvent): void {
if (ctrl && event.key === "f") { const active = sessionStore.activeSession; if (active?.protocol === "ssh") { event.preventDefault(); sessionContainer.value?.openActiveSearch(); } return; } if (ctrl && event.key === "f") { const active = sessionStore.activeSession; if (active?.protocol === "ssh") { event.preventDefault(); sessionContainer.value?.openActiveSearch(); } return; }
} }
let workspaceSaveInterval: ReturnType<typeof setInterval> | null = null;
function handleBeforeUnload(e: BeforeUnloadEvent): void {
if (sessionStore.sessions.length > 0) {
e.preventDefault();
}
}
onMounted(async () => { onMounted(async () => {
document.addEventListener("keydown", handleKeydown); document.addEventListener("keydown", handleKeydown);
// Confirm before closing if sessions are active (synchronous won't hang) // Confirm before closing if sessions are active (synchronous won't hang)
window.addEventListener("beforeunload", (e) => { window.addEventListener("beforeunload", handleBeforeUnload);
if (sessionStore.sessions.length > 0) {
e.preventDefault();
}
});
await connectionStore.loadAll(); await connectionStore.loadAll();
@ -520,7 +524,7 @@ onMounted(async () => {
// Auto-save workspace every 30 seconds instead of on close // Auto-save workspace every 30 seconds instead of on close
// (onCloseRequested was hanging the window close on Windows) // (onCloseRequested was hanging the window close on Windows)
setInterval(() => { workspaceSaveInterval = setInterval(() => {
const tabs = sessionStore.sessions const tabs = sessionStore.sessions
.filter(s => s.protocol === "ssh" || s.protocol === "rdp") .filter(s => s.protocol === "ssh" || s.protocol === "rdp")
.map((s, i) => ({ connectionId: s.connectionId, protocol: s.protocol, position: i })); .map((s, i) => ({ connectionId: s.connectionId, protocol: s.protocol, position: i }));
@ -543,5 +547,10 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown); document.removeEventListener("keydown", handleKeydown);
window.removeEventListener("beforeunload", handleBeforeUnload);
if (workspaceSaveInterval !== null) {
clearInterval(workspaceSaveInterval);
workspaceSaveInterval = null;
}
}); });
</script> </script>

View File

@ -115,14 +115,173 @@ 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 };
async function resolveCredentials(credentialId: number): Promise<CredentialRow | null> {
try {
const allCreds = await invoke<CredentialRow[]>("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<void> {
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<string>("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<string>("decrypt_password", { credentialId: cred.id });
}
}
}
try {
if (!resolvedUsername) throw new Error("NO_CREDENTIALS");
sessionId = await invoke<string>("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<string>("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<void> {
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<string>("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<string>("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<string>("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. * Connect to a server by connection ID.
* Multiple sessions to the same host are allowed (MobaXTerm-style). * Multiple sessions to the same host are allowed (MobaXTerm-style).
* Each gets its own tab with a disambiguated name like "Asgard (2)". * 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> { async function connect(connectionId: number): Promise<void> {
const connectionStore = useConnectionStore(); const connectionStore = useConnectionStore();
@ -132,214 +291,14 @@ export const useSessionStore = defineStore("session", () => {
connecting.value = true; connecting.value = true;
try { try {
if (conn.protocol === "ssh") { if (conn.protocol === "ssh") {
let sessionId: string; await connectSsh(conn, connectionId);
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<string>("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<string>("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<string>("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<string>("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;
} else if (conn.protocol === "rdp") { } else if (conn.protocol === "rdp") {
let username = ""; await connectRdp(conn, connectionId);
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<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;
if (opts?.password) password = opts.password;
if (opts?.domain) domain = opts.domain;
} catch {
// ignore malformed options
}
}
let sessionId: string;
try {
sessionId = await invoke<string>("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<string>("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;
} }
} 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;