diff --git a/.github/workflows/crowdin-sync.yml b/.github/workflows/crowdin-sync.yml new file mode 100644 index 00000000..8ab185cd --- /dev/null +++ b/.github/workflows/crowdin-sync.yml @@ -0,0 +1,83 @@ +name: Crowdin Sync + +on: + schedule: + - cron: "0 6 * * *" + workflow_dispatch: + inputs: + branch: + description: "Branch to sync translations into" + required: false + type: string + +permissions: + contents: write + +jobs: + crowdin: + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Resolve target branch + id: branch + run: | + BRANCH="${{ inputs.branch }}" + if [ -z "$BRANCH" ]; then + BRANCH="${{ github.event.repository.default_branch }}" + fi + echo "name=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Checkout branch + uses: actions/checkout@v7 + with: + ref: ${{ steps.branch.outputs.name }} + fetch-depth: 0 + token: ${{ secrets.GHCR_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" + + - name: Upload sources to Crowdin + uses: crowdin/github-action@v2 + with: + upload_sources: true + upload_translations: false + download_translations: false + create_pull_request: false + push_translations: false + token: ${{ secrets.CROWDIN_API_KEY }} + project_id: "858252" + env: + CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }} + + - name: Machine pre-translate untranslated strings + env: + CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }} + run: node scripts/crowdin-pretranslate.cjs + + - name: Download translations from Crowdin + uses: crowdin/github-action@v2 + with: + upload_sources: false + upload_translations: false + download_translations: true + create_pull_request: false + push_translations: false + token: ${{ secrets.CROWDIN_API_KEY }} + project_id: "858252" + env: + CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }} + + - name: Commit translations + run: | + git config user.name "LukeGus" + git config user.email "bugattiguy527@gmail.com" + + git add src/ui/locales/translated + if git diff --cached --quiet; then + echo "No translation changes to commit." + exit 0 + fi + git commit -m "chore: sync Crowdin translations" + git push origin HEAD:"${{ steps.branch.outputs.name }}" diff --git a/electron/main.cjs b/electron/main.cjs index 13963207..f6e8e03e 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1593,16 +1593,23 @@ function getC2SRelayUrl() { } async function getC2SRelayHeaders(relayUrl) { - if (!mainWindow?.webContents?.session) return {}; - const cookieUrl = relayUrl .replace(/^ws:/, "http:") .replace(/^wss:/, "https:"); - const cookies = await mainWindow.webContents.session.cookies.get({ - url: cookieUrl, - name: "jwt", - }); - const jwt = cookies[0]?.value; + + let jwt; + if (mainWindow?.webContents?.session) { + const cookies = await mainWindow.webContents.session.cookies.get({ + url: cookieUrl, + name: "jwt", + }); + jwt = cookies[0]?.value; + } + + if (!jwt) { + jwt = getRememberedElectronAuthCookie("jwt", cookieUrl)?.value; + } + if (!jwt) return {}; return { diff --git a/index.html b/index.html index da71ea34..79c6e4f5 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,10 @@ - + diff --git a/scripts/patch-guacamole-lite.cjs b/scripts/patch-guacamole-lite.cjs index 67cdca65..97d8da94 100644 --- a/scripts/patch-guacamole-lite.cjs +++ b/scripts/patch-guacamole-lite.cjs @@ -56,20 +56,26 @@ const newVersionBlock = const oldTimezone = "if (protocolVersion === '1_1_0') {"; const newTimezone = "if (protocolVersion !== '1_0_0') {"; -// Patch 3: send the `name` handshake instruction for protocol >= 1.3.0. -// The Guacamole protocol added the `name` instruction in 1.3.0 (an optional -// human-readable identifier for the joining user). guacd 1.6.0 began requiring -// it during the VNC handshake even when negotiating older protocol versions, -// causing connections to silently drop right after "User joined". See +// Patch 3: send the `name` handshake instruction for all protocol versions >= 1.1.0. +// The Guacamole protocol added `name` in 1.3.0, but guacd 1.6.0 began requiring it +// during the VNC handshake even when negotiating VERSION_1_1_0, causing connections to +// silently drop right after "User joined". Sending it for all non-1.0.0 sessions is +// harmless (guacd ignores unknown handshake instructions for older versions). See // Termix-SSH/Support#567 and #734. const oldConnect = " this.sendInstruction(['connect'].concat(connectArgs));"; -const newConnect = +const oldNameConnect = " if (protocolVersion === '1_3_0' || protocolVersion === '1_5_0') {\n" + " this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" + " }\n" + "\n" + " this.sendInstruction(['connect'].concat(connectArgs));"; +const newConnect = + " if (protocolVersion !== '1_0_0') {\n" + + " this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" + + " }\n" + + "\n" + + " this.sendInstruction(['connect'].concat(connectArgs));"; // Patch 4: answer guacd's dynamic argument requests locally. // macOS Screen Sharing can request VNC username/password through the @@ -156,13 +162,16 @@ if (!guacdClientContent.includes(newTimezone)) { } if (!guacdClientContent.includes(newConnect)) { - if (!guacdClientContent.includes(oldConnect)) { + if (guacdClientContent.includes(oldNameConnect)) { + guacdClientContent = guacdClientContent.replace(oldNameConnect, newConnect); + } else if (guacdClientContent.includes(oldConnect)) { + guacdClientContent = guacdClientContent.replace(oldConnect, newConnect); + } else { console.log( "[patch-guacamole-lite] Connect target not found, skipping name patch", ); process.exit(0); } - guacdClientContent = guacdClientContent.replace(oldConnect, newConnect); patched = true; } diff --git a/scripts/patch-guacamole-lite.test.ts b/scripts/patch-guacamole-lite.test.ts index d6b8c9eb..70e3ad33 100644 --- a/scripts/patch-guacamole-lite.test.ts +++ b/scripts/patch-guacamole-lite.test.ts @@ -69,6 +69,31 @@ describe("patch-guacamole-lite", () => { ]); }); + it("sends name instruction for VERSION_1_1_0 to fix guacd 1.6.0 VNC drops", () => { + const client = createPatchedClient({ + hostname: "192.0.2.10", + port: 5900, + password: "secret", + width: 1280, + height: 720, + dpi: 96, + }); + + client.sendHandshakeReply(["VERSION_1_1_0", "hostname", "port"]); + + expect(client.sendInstruction).toHaveBeenCalledWith(["timezone"]); + expect(client.sendInstruction).toHaveBeenCalledWith([ + "name", + "guacamole-lite", + ]); + expect(client.sendInstruction).toHaveBeenCalledWith([ + "connect", + "VERSION_1_1_0", + "192.0.2.10", + 5900, + ]); + }); + it("answers required credentials through argument value streams", () => { const client = createPatchedClient({ username: "", diff --git a/scripts/patch-xterm-android-ime.cjs b/scripts/patch-xterm-android-ime.cjs index e7738b32..60660a99 100644 --- a/scripts/patch-xterm-android-ime.cjs +++ b/scripts/patch-xterm-android-ime.cjs @@ -14,6 +14,17 @@ const xtermDir = path.join( // xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart // composition on the previous word and replace it with a shorter value (for // example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing. +// +// Also fixes _handleAnyTextareaChanges, which iOS Safari/WKWebView drives +// for ordinary typing (it reports keyCode 229 for all software-keyboard +// input, not just IME composition). That handler diffs the textarea value +// via `newValue.replace(oldValue, "")`, a literal substring removal. When +// keystrokes arrive faster than the function's setTimeout(0) callback runs, +// several overlapping callbacks each capture a stale oldValue, so the +// literal-substring search fails to match and the diff silently comes back +// empty - characters are dropped instead of sent. Swap in the same +// common-prefix diff used for composition-end above so a stale oldValue +// still yields the correct delta. const patches = [ { file: "xterm.mjs", @@ -34,6 +45,10 @@ const patches = [ "e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&", "e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length0&&", ], + [ + '_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}},0)}', + ], ], }, { @@ -55,6 +70,10 @@ const patches = [ "e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&", "e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length0&&", ], + [ + '_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}}),0)}', + ], ], }, ]; @@ -66,18 +85,24 @@ for (const { file, replacements } of patches) { } let source = fs.readFileSync(filePath, "utf8"); - if (source.includes("_preCompositionValue")) { - console.log(`[patch-xterm-android-ime] ${file} already patched`); - continue; - } + let changed = false; for (const [original, patched] of replacements) { + if (source.includes(patched)) { + continue; + } if (!source.includes(original)) { throw new Error( `[patch-xterm-android-ime] Expected source not found in ${file}`, ); } source = source.replace(original, patched); + changed = true; + } + + if (!changed) { + console.log(`[patch-xterm-android-ime] ${file} already patched`); + continue; } fs.writeFileSync(filePath, source); diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index cb9c358c..586280cb 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -1985,6 +1985,74 @@ const migrateSchema = () => { addColumnIfNotExists("users", "sso_provider_id", "INTEGER"); + try { + const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{ + cid: number; + name: string; + type: string; + notnull: number; + dflt_value: string | null; + pk: number; + }>; + const legacyNotNullColumns = new Set([ + "client_id", + "client_secret", + "issuer_url", + "authorization_url", + "token_url", + "identifier_path", + "name_path", + "scopes", + ]); + const hasStaleNotNull = usersTableInfo.some( + (col) => legacyNotNullColumns.has(col.name) && col.notnull === 1, + ); + + if (hasStaleNotNull) { + const tempTableName = "users_temp_migration"; + const columnDefs = usersTableInfo + .map((col) => { + const parts = [`"${col.name}"`, col.type || "TEXT"]; + if (col.pk === 1) parts.push("PRIMARY KEY"); + if (col.notnull === 1 && !legacyNotNullColumns.has(col.name)) { + parts.push("NOT NULL"); + } + if (col.dflt_value !== null) { + parts.push(`DEFAULT ${col.dflt_value}`); + } + return parts.join(" "); + }) + .join(",\n "); + const allColumns = usersTableInfo.map((col) => `"${col.name}"`).join(", "); + + sqlite.exec(`PRAGMA foreign_keys = OFF`); + sqlite.exec(` + CREATE TABLE ${tempTableName} ( + ${columnDefs} + ); + + INSERT INTO ${tempTableName} SELECT ${allColumns} FROM users; + + DROP TABLE users; + + ALTER TABLE ${tempTableName} RENAME TO users; + `); + sqlite.exec(`PRAGMA foreign_keys = ON`); + + databaseLogger.info( + "Successfully migrated users table to remove legacy OIDC NOT NULL constraints", + { + operation: "schema_migration_users_oidc_nullable", + }, + ); + } + } catch (migrationError) { + databaseLogger.warn("Failed to migrate users table legacy OIDC columns", { + operation: "schema_migration", + error: migrationError, + }); + } + // Migrate legacy single oidc_config settings blob into sso_providers table try { const migrationDone = getRawSettingValue("sso_migration_v1"); diff --git a/src/backend/database/routes/proxmox.ts b/src/backend/database/routes/proxmox.ts index c3a4b8fa..553c1b20 100644 --- a/src/backend/database/routes/proxmox.ts +++ b/src/backend/database/routes/proxmox.ts @@ -488,16 +488,51 @@ async function discoverProxmoxGuestsForHost( async function resolveIp(g: GuestBase): Promise { if (g.type === "lxc") { + let configIp: string | null = null; try { const cfgJson = await execCommand( client, `pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`, 8000, ); - return parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes); + configIp = parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes); } catch { - return null; + configIp = null; } + if (configIp) return configIp; + // Static config parsing found nothing (e.g. net0 uses ip=dhcp). + // Fall back to the live interface list for running containers. + if (g.status === "running") { + try { + const ifRaw = await execCommand( + client, + `pvesh get /nodes/${g.node}/lxc/${g.vmid}/interfaces --output-format json 2>/dev/null`, + 5000, + ); + const data = JSON.parse(ifRaw); + const entries: Array> = Array.isArray(data) + ? data + : []; + const allIps: string[] = []; + for (const entry of entries) { + if (entry.name === "lo") continue; + const inet = entry.inet; + if (typeof inet !== "string") continue; + const m = inet.match(/^(\d{1,3}(?:\.\d{1,3}){3})\/\d+$/); + if (m && !m[1].startsWith("127.")) allIps.push(m[1]); + } + if (allIps.length) { + for (const prefix of config.preferredPrefixes) { + const match = allIps.find((ip) => ip.startsWith(prefix)); + if (match) return match; + } + return allIps[0]; + } + } catch { + // Guest not running or interfaces unavailable + } + } + return null; } if (g.type === "qemu" && g.status === "running") { try { diff --git a/src/backend/hosts/docker/routes.ts b/src/backend/hosts/docker/routes.ts index 2cebf272..68e7d741 100644 --- a/src/backend/hosts/docker/routes.ts +++ b/src/backend/hosts/docker/routes.ts @@ -280,6 +280,7 @@ export function registerDockerSshRoutes(app: express.Express): void { if (userProvidedPassword) { resolvedCredentials.password = userProvidedPassword; + resolvedCredentials.authType = "password"; } if (userProvidedSshKey) { resolvedCredentials.sshKey = userProvidedSshKey; diff --git a/src/backend/hosts/guacamole/routes.ts b/src/backend/hosts/guacamole/routes.ts index 0e47c2f9..443836fa 100644 --- a/src/backend/hosts/guacamole/routes.ts +++ b/src/backend/hosts/guacamole/routes.ts @@ -3,16 +3,17 @@ import { GuacamoleTokenService } from "./token-service.js"; import { guacLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; -import { Client } from "ssh2"; import net from "net"; import crypto from "crypto"; import path from "path"; -import type { AuthenticatedRequest } from "../../../types/index.js"; +import type { AuthenticatedRequest, ProxyNode } from "../../../types/index.js"; import { createCurrentHostResolutionRepository, createCurrentSettingsRepository, } from "../../database/repositories/factory.js"; import { resolveGuacdOptions } from "../../utils/guacd-config.js"; +import { createJumpHostChain } from "../jump-host-chain.js"; +import type { SOCKS5Config } from "../../utils/socks5-helper.js"; const router = express.Router(); const tokenService = GuacamoleTokenService.getInstance(); @@ -463,65 +464,91 @@ router.post( if (jumpHosts.length > 0) { try { - const { resolveHostById } = await import("../host-resolver.js"); - const jumpHost = await resolveHostById(jumpHosts[0].hostId, userId); - if (jumpHost) { - const tunnelPort = await new Promise((resolve, reject) => { - const sshClient = new Client(); - sshClient.on("ready", () => { - const server = net.createServer((sock) => { - sshClient.forwardOut( - "127.0.0.1", - 0, - hostname, - port, - (err, stream) => { - if (err) { - sock.destroy(); - return; - } - sock.pipe(stream).pipe(sock); - }, - ); - }); - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as net.AddressInfo; - // Auto-cleanup after 1 hour - setTimeout( - () => { - server.close(); - sshClient.end(); - }, - 60 * 60 * 1000, - ); - resolve(addr.port); - }); - }); - sshClient.on("error", reject); + let socks5ProxyChain: ProxyNode[] = []; + if (hostRecord.socks5ProxyChain) { + try { + socks5ProxyChain = + typeof hostRecord.socks5ProxyChain === "string" + ? JSON.parse(hostRecord.socks5ProxyChain as string) + : (hostRecord.socks5ProxyChain as ProxyNode[]); + } catch { + socks5ProxyChain = []; + } + } - const connectOpts: Record = { - host: jumpHost.ip, - port: jumpHost.port || 22, - username: jumpHost.username, - readyTimeout: 30000, - }; - if (jumpHost.key) { - connectOpts.privateKey = jumpHost.key; - if (jumpHost.keyPassword) - connectOpts.passphrase = jumpHost.keyPassword; - } else if (jumpHost.password) { - connectOpts.password = jumpHost.password; - } - sshClient.connect(connectOpts); - }); - hostname = "127.0.0.1"; - port = tunnelPort; - guacLogger.info("SSH tunnel established for guacamole", { - operation: "guac_ssh_tunnel", - hostId, - tunnelPort, + const proxyConfig: SOCKS5Config | null = + hostRecord.useSocks5 && + (hostRecord.socks5Host || socks5ProxyChain.length > 0) + ? { + useSocks5: hostRecord.useSocks5 as boolean, + socks5Host: hostRecord.socks5Host as string | undefined, + socks5Port: hostRecord.socks5Port as number | undefined, + socks5Username: hostRecord.socks5Username as + | string + | undefined, + socks5Password: hostRecord.socks5Password as + | string + | undefined, + socks5ProxyChain, + } + : null; + + const jumpClient = await createJumpHostChain( + jumpHosts, + userId, + proxyConfig, + ); + + if (!jumpClient) { + guacLogger.error( + "Failed to establish jump host chain for guacamole", + undefined, + { operation: "guac_ssh_tunnel_error", hostId }, + ); + return res.status(500).json({ + error: "Failed to establish SSH tunnel to remote host", }); } + + const targetHostname = hostname; + const targetPort = port; + const tunnelPort = await new Promise((resolve, reject) => { + const server = net.createServer((sock) => { + jumpClient.forwardOut( + "127.0.0.1", + 0, + targetHostname, + targetPort, + (err, stream) => { + if (err) { + sock.destroy(); + return; + } + sock.pipe(stream).pipe(sock); + }, + ); + }); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as net.AddressInfo; + // Auto-cleanup after 1 hour + setTimeout( + () => { + server.close(); + jumpClient.end(); + }, + 60 * 60 * 1000, + ); + resolve(addr.port); + }); + }); + hostname = "127.0.0.1"; + port = tunnelPort; + guacLogger.info("SSH tunnel established for guacamole", { + operation: "guac_ssh_tunnel", + hostId, + tunnelPort, + }); } catch (tunnelError) { guacLogger.error("Failed to establish SSH tunnel", tunnelError, { operation: "guac_ssh_tunnel_error", diff --git a/src/backend/hosts/metrics/widgets/disk-collector.ts b/src/backend/hosts/metrics/widgets/disk-collector.ts index 79ae76c2..eec042d3 100644 --- a/src/backend/hosts/metrics/widgets/disk-collector.ts +++ b/src/backend/hosts/metrics/widgets/disk-collector.ts @@ -1,6 +1,67 @@ import type { Client } from "ssh2"; import { execCommand, toFixedNum } from "./common-utils.js"; +const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/; + +export interface DfRow { + filesystem: string; + mount: string; + parts: string[]; +} + +export function parseDfLines(output: string): DfRow[] { + return output + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .map((line) => { + const parts = line.split(/\s+/); + return { filesystem: parts[0] || "", mount: parts[5] || "", parts }; + }) + .filter( + (row) => row.parts.length >= 6 && !PSEUDO_FS_RE.test(row.filesystem), + ); +} + +// Finds the index of the most-utilized real filesystem in a `df -B1`-style +// row set (parts[1] = total bytes, parts[2] = used bytes), so a nearly-full +// secondary mount (e.g. /data) isn't hidden behind a healthy root filesystem. +export function findWorstMountIndex(bytesRows: DfRow[]): { + index: number; + usedBytes: number; + totalBytes: number; +} { + let worstIndex = -1; + let worstUsedBytes = -1; + let worstTotalBytes = 0; + + bytesRows.forEach((row, index) => { + const totalBytes = Number(row.parts[1]); + const usedBytes = Number(row.parts[2]); + if ( + !Number.isFinite(totalBytes) || + !Number.isFinite(usedBytes) || + totalBytes <= 0 + ) { + return; + } + const usedRatio = usedBytes / totalBytes; + const worstRatio = + worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1; + if (usedRatio > worstRatio) { + worstIndex = index; + worstUsedBytes = usedBytes; + worstTotalBytes = totalBytes; + } + }); + + return { + index: worstIndex, + usedBytes: worstUsedBytes, + totalBytes: worstTotalBytes, + }; +} + export async function collectDiskMetrics(client: Client): Promise<{ percent: number | null; usedHuman: string | null; @@ -14,41 +75,28 @@ export async function collectDiskMetrics(client: Client): Promise<{ try { const [diskOutHuman, diskOutBytes] = await Promise.all([ - execCommand(client, "df -h -P / | tail -n +2"), - execCommand(client, "df -B1 -P / | tail -n +2"), + execCommand(client, "df -h -P | tail -n +2"), + execCommand(client, "df -B1 -P | tail -n +2"), ]); - const humanLine = - diskOutHuman.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)[0] || ""; - const bytesLine = - diskOutBytes.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)[0] || ""; + const humanRows = parseDfLines(diskOutHuman.stdout); + const bytesRows = parseDfLines(diskOutBytes.stdout); + const worst = findWorstMountIndex(bytesRows); - const humanParts = humanLine.split(/\s+/); - const bytesParts = bytesLine.split(/\s+/); + if (worst.totalBytes > 0) { + diskPercent = Math.max( + 0, + Math.min(100, (worst.usedBytes / worst.totalBytes) * 100), + ); - if (humanParts.length >= 6 && bytesParts.length >= 6) { - totalHuman = humanParts[1] || null; - usedHuman = humanParts[2] || null; - availableHuman = humanParts[3] || null; - - const totalBytes = Number(bytesParts[1]); - const usedBytes = Number(bytesParts[2]); - - if ( - Number.isFinite(totalBytes) && - Number.isFinite(usedBytes) && - totalBytes > 0 - ) { - diskPercent = Math.max( - 0, - Math.min(100, (usedBytes / totalBytes) * 100), - ); + const humanRow = + humanRows.length === bytesRows.length + ? humanRows[worst.index] + : humanRows.find((row) => row.mount === bytesRows[worst.index].mount); + if (humanRow) { + totalHuman = humanRow.parts[1] || null; + usedHuman = humanRow.parts[2] || null; + availableHuman = humanRow.parts[3] || null; } } } catch { diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index f23b2a10..edb4383a 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -1288,39 +1288,56 @@ wss.on("connection", async (ws: WebSocket, req) => { }; } - sendLog("dns", "info", `Starting address resolution of ${ip}`); + const connectsViaJumpHosts = !!( + hostConfig.jumpHosts && + hostConfig.jumpHosts.length > 0 && + hostConfig.userId + ); + let connectHost = ip; - try { - const resolution = await resolveHostForSshConnect(ip); - connectHost = resolution.host; - if (resolution.resolvedAddress && resolution.resolvedAddress !== ip) { - sendLog( - "dns", - "success", - `Resolved ${ip} to ${resolution.resolvedAddress}`, - { attempts: resolution.attempts }, - ); - } - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - sshLogger.error("SSH hostname resolution failed", error, { - operation: "terminal_dns_resolve", - hostId: id, - ip, - port, - transient: isRetriableDnsError(error), - }); - sendLog("dns", "error", `DNS resolution failed for ${ip}: ${message}`); - ws.send( - JSON.stringify({ - type: "error", - message: isRetriableDnsError(error) - ? "SSH error: DNS lookup temporarily failed. Check the Docker/container DNS configuration or try again." - : "SSH error: Could not resolve hostname from the Termix server container.", - }), + if (connectsViaJumpHosts) { + // The target is only reachable through the jump host's network (e.g. a + // VPN-only address), so DNS must be resolved there, not on this host. + sendLog( + "dns", + "info", + `Skipping local address resolution of ${ip} (resolved by jump host)`, ); - cleanupAuthState(connectionTimeout); - return; + } else { + sendLog("dns", "info", `Starting address resolution of ${ip}`); + try { + const resolution = await resolveHostForSshConnect(ip); + connectHost = resolution.host; + if (resolution.resolvedAddress && resolution.resolvedAddress !== ip) { + sendLog( + "dns", + "success", + `Resolved ${ip} to ${resolution.resolvedAddress}`, + { attempts: resolution.attempts }, + ); + } + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error"; + sshLogger.error("SSH hostname resolution failed", error, { + operation: "terminal_dns_resolve", + hostId: id, + ip, + port, + transient: isRetriableDnsError(error), + }); + sendLog("dns", "error", `DNS resolution failed for ${ip}: ${message}`); + ws.send( + JSON.stringify({ + type: "error", + message: isRetriableDnsError(error) + ? "SSH error: DNS lookup temporarily failed. Check the Docker/container DNS configuration or try again." + : "SSH error: Could not resolve hostname from the Termix server container.", + }), + ); + cleanupAuthState(connectionTimeout); + return; + } } sendLog("tcp", "info", `Connecting to ${ip} port ${port}`); @@ -2014,7 +2031,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sendLog( "auth", "error", - "Tailscale SSH authentication failed. Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy permits this connection.", + `Tailscale SSH authentication failed for user "${username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${username}" user to your identity (check tailscale.com/s/ssh for the check/action ACL syntax). If your Tailscale identity maps to a different Unix user, update the username on this host.`, ); if (currentSessionId) { sessionManager.destroySession(currentSessionId); @@ -2024,8 +2041,7 @@ wss.on("connection", async (ws: WebSocket, req) => { ws.send( JSON.stringify({ type: "error", - message: - "Tailscale SSH authentication failed. Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy permits this connection.", + message: `Tailscale SSH authentication failed for user "${username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${username}" user to your identity. If your Tailscale identity maps to a different Unix user, update the username on this host.`, }), ); return; diff --git a/src/backend/tests/hosts/metrics/widgets/disk-collector.test.ts b/src/backend/tests/hosts/metrics/widgets/disk-collector.test.ts new file mode 100644 index 00000000..70d17e30 --- /dev/null +++ b/src/backend/tests/hosts/metrics/widgets/disk-collector.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { + parseDfLines, + findWorstMountIndex, +} from "../../../../hosts/metrics/widgets/disk-collector.js"; + +describe("parseDfLines", () => { + it("parses df -P output into rows", () => { + const output = + "/dev/nvme0n1p2 3848290697216 1046898851840 2606516101120 29% /\n" + + "/dev/nvme1n1p1 15393162788864 15239230844928 153931922841 99% /data\n"; + const rows = parseDfLines(output); + expect(rows).toHaveLength(2); + expect(rows[0].mount).toBe("/"); + expect(rows[1].mount).toBe("/data"); + }); + + it("filters out pseudo filesystems", () => { + const output = + "tmpfs 8000 0 8000 0% /dev/shm\n" + + "overlay 100 50 50 50% /\n" + + "/dev/sda1 100 50 50 50% /mnt/data\n"; + const rows = parseDfLines(output); + expect(rows).toHaveLength(1); + expect(rows[0].mount).toBe("/mnt/data"); + }); +}); + +describe("findWorstMountIndex", () => { + it("picks the most-utilized mount, not just the first row", () => { + const rows = parseDfLines( + "/dev/nvme0n1p2 3848290697216 1046898851840 2606516101120 29% /\n" + + "/dev/nvme1n1p1 15393162788864 15239230844928 153931922841 99% /data\n", + ); + const worst = findWorstMountIndex(rows); + expect(worst.index).toBe(1); + expect(worst.totalBytes).toBe(15393162788864); + expect(worst.usedBytes).toBe(15239230844928); + }); + + it("falls back to the only mount available", () => { + const rows = parseDfLines("/dev/sda1 100 30 70 30% /\n"); + const worst = findWorstMountIndex(rows); + expect(worst.index).toBe(0); + }); + + it("skips rows with invalid or zero totals", () => { + const rows = parseDfLines( + "/dev/sda1 0 0 0 0% /broken\n" + "/dev/sda2 100 40 60 40% /ok\n", + ); + const worst = findWorstMountIndex(rows); + expect(worst.index).toBe(1); + }); + + it("returns index -1 when there are no usable rows", () => { + const worst = findWorstMountIndex([]); + expect(worst.index).toBe(-1); + expect(worst.totalBytes).toBe(0); + }); +}); diff --git a/src/types/guacamole-common-js.d.ts b/src/types/guacamole-common-js.d.ts index 3dbb36c3..24079905 100644 --- a/src/types/guacamole-common-js.d.ts +++ b/src/types/guacamole-common-js.d.ts @@ -97,6 +97,39 @@ declare module "guacamole-common-js" { up: boolean; down: boolean; } + + interface MouseEvent { + state: Mouse.State; + preventDefault(): void; + stopPropagation(): void; + } + + class Touchpad { + constructor(element: HTMLElement); + currentState: Mouse.State; + clickTimingThreshold: number; + clickMoveThreshold: number; + scrollThreshold: number; + onEach( + types: string[], + listener: (event: Mouse.MouseEvent) => void, + ): void; + on(type: string, listener: (event: Mouse.MouseEvent) => void): void; + } + + class Touchscreen { + constructor(element: HTMLElement); + currentState: Mouse.State; + clickTimingThreshold: number; + clickMoveThreshold: number; + scrollThreshold: number; + longPressThreshold: number; + onEach( + types: string[], + listener: (event: Mouse.MouseEvent) => void, + ): void; + on(type: string, listener: (event: Mouse.MouseEvent) => void): void; + } } class Keyboard { diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 304d4127..90151578 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -152,6 +152,7 @@ export type Host = { vncPort: number; telnetPort: number; + rdpAuthType?: "direct" | "credential"; rdpCredentialId?: string; rdpUser?: string; rdpPassword?: string; @@ -159,10 +160,13 @@ export type Host = { security?: string; ignoreCert?: boolean; + vncAuthType?: "direct" | "credential"; vncCredentialId?: string; vncPassword?: string; vncUser?: string; + telnetAuthType?: "direct" | "credential"; + telnetCredentialId?: string; telnetUser?: string; telnetPassword?: string; diff --git a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx index 7259504a..d823b339 100644 --- a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx +++ b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx @@ -121,11 +121,14 @@ export function ProxmoxDiscoverDialog({ const credId = defaultCredentialId ?? discoveredCredentialId; const importAuth = resolveProxmoxImportAuth(defaultAuthType, credId); - const toImport = guests - .filter((g) => selected.has(g.vmid)) + const selectedGuests = guests.filter((g) => selected.has(g.vmid)); + const skippedNoIp = selectedGuests.filter((g) => !g.ip).length; + + const toImport = selectedGuests + .filter((g) => !!g.ip) .map((g) => ({ name: g.name, - ip: g.ip ?? "0.0.0.0", + ip: g.ip as string, port: g.connectionType === "rdp" ? 3389 : 22, username: defaultUsername ?? "root", folder: importFolder, @@ -152,10 +155,15 @@ export function ProxmoxDiscoverDialog({ }, })); - const result = await bulkImportSSHHosts(toImport, false); - const updated = await getSSHHosts(); - onHostsChanged(updated); - window.dispatchEvent(new CustomEvent("termix:hosts-changed")); + const result = toImport.length + ? await bulkImportSSHHosts(toImport, false) + : { success: 0, updated: 0, skipped: 0, failed: 0 }; + + if (toImport.length) { + const updated = await getSSHHosts(); + onHostsChanged(updated); + window.dispatchEvent(new CustomEvent("termix:hosts-changed")); + } const msg = [ result.success @@ -167,6 +175,9 @@ export function ProxmoxDiscoverDialog({ result.failed ? t("hosts.proxmoxResultFailed", { count: result.failed }) : null, + skippedNoIp + ? t("hosts.proxmoxResultSkippedNoIp", { count: skippedNoIp }) + : null, ] .filter(Boolean) .join(", "); diff --git a/src/ui/features/docker/components/ConsoleTerminal.tsx b/src/ui/features/docker/components/ConsoleTerminal.tsx index fdd41335..b4f1a607 100644 --- a/src/ui/features/docker/components/ConsoleTerminal.tsx +++ b/src/ui/features/docker/components/ConsoleTerminal.tsx @@ -23,6 +23,7 @@ import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { useTranslation } from "react-i18next"; import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme"; import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes"; +import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles"; import { useTheme } from "@/components/theme-provider"; interface ConsoleTerminalProps { @@ -77,6 +78,7 @@ export function ConsoleTerminal({ (f) => f.value === terminalConfig.fontFamily, ); const fontFamily = fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback; + ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value); terminal.options.cursorBlink = terminalConfig.cursorBlink; terminal.options.cursorStyle = terminalConfig.cursorStyle; diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/src/ui/features/guacamole/GuacamoleApp.tsx index 5e98717e..93865a14 100644 --- a/src/ui/features/guacamole/GuacamoleApp.tsx +++ b/src/ui/features/guacamole/GuacamoleApp.tsx @@ -8,6 +8,7 @@ import React, { import { GuacamoleDisplay, type GuacamoleDisplayHandle, + type GuacamoleTouchMode, } from "@/features/guacamole/GuacamoleDisplay.tsx"; import { getGuacamoleTokenFromHost, @@ -114,6 +115,12 @@ const GuacamoleAppInner = React.forwardRef< const [error, setError] = useState(null); const [connectionError, setConnectionError] = useState(null); const [retryCount, setRetryCount] = useState(0); + const [touchMode, setTouchMode] = useState(() => + typeof window !== "undefined" && + (navigator.maxTouchPoints > 0 || "ontouchstart" in window) + ? "touchscreen" + : null, + ); const displayRef = useRef(null); useImperativeHandle(ref, () => ({ @@ -245,7 +252,7 @@ const GuacamoleAppInner = React.forwardRef< )} setConnectionError(err)} /> - + ); }); diff --git a/src/ui/features/guacamole/GuacamoleDisplay.tsx b/src/ui/features/guacamole/GuacamoleDisplay.tsx index 5a7876e7..c6e75479 100644 --- a/src/ui/features/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/features/guacamole/GuacamoleDisplay.tsx @@ -44,9 +44,12 @@ export interface GuacamoleDisplayHandle { setClipboard: (data: string) => void; } +export type GuacamoleTouchMode = "touchscreen" | "touchpad"; + interface GuacamoleDisplayProps { connectionConfig: GuacamoleConnectionConfig; isVisible: boolean; + touchMode?: GuacamoleTouchMode | null; onConnect?: () => void; onDisconnect?: () => void; onError?: (error: string) => void; @@ -58,7 +61,7 @@ export const GuacamoleDisplay = forwardRef< GuacamoleDisplayHandle, GuacamoleDisplayProps >(function GuacamoleDisplay( - { connectionConfig, isVisible, onConnect, onDisconnect, onError }, + { connectionConfig, isVisible, touchMode, onConnect, onDisconnect, onError }, ref, ) { const { t } = useTranslation(); @@ -388,26 +391,46 @@ export const GuacamoleDisplay = forwardRef< setIsReady(true); } - const mouse = new Guacamole.Mouse(displayElement); - const sendMouseState = (state: Guacamole.Mouse.State) => { + const sendMouseEvent = (event: Guacamole.Mouse.MouseEvent) => { displayElement.focus({ preventScroll: true }); const scale = scaleRef.current; - const adjustedX = Math.round(state.x / scale); - const adjustedY = Math.round(state.y / scale); - + const state = event.state; const adjustedState = new Guacamole.Mouse.State( - adjustedX, - adjustedY, + Math.round(state.x / scale), + Math.round(state.y / scale), state.left, state.middle, state.right, state.up, state.down, ) as Guacamole.Mouse.State; - client.sendMouseState(adjustedState); }; - mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState; + + if (touchMode === "touchscreen") { + const touchscreen = new Guacamole.Mouse.Touchscreen(displayElement); + touchscreen.onEach(["mousedown", "mousemove", "mouseup"], sendMouseEvent); + } else if (touchMode === "touchpad") { + const touchpad = new Guacamole.Mouse.Touchpad(displayElement); + touchpad.onEach(["mousedown", "mousemove", "mouseup"], sendMouseEvent); + } else { + const mouse = new Guacamole.Mouse(displayElement); + const sendMouseState = (state: Guacamole.Mouse.State) => { + displayElement.focus({ preventScroll: true }); + const scale = scaleRef.current; + const adjustedState = new Guacamole.Mouse.State( + Math.round(state.x / scale), + Math.round(state.y / scale), + state.left, + state.middle, + state.right, + state.up, + state.down, + ) as Guacamole.Mouse.State; + client.sendMouseState(adjustedState); + }; + mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState; + } const keyboard = new Guacamole.Keyboard(displayElement); keyboardRef.current = keyboard; @@ -425,6 +448,9 @@ export const GuacamoleDisplay = forwardRef< displayElement.addEventListener("focus", handleDisplayFocus); displayElement.addEventListener("blur", handleDisplayBlur); displayElement.addEventListener("mousedown", handleDisplayFocus); + displayElement.addEventListener("touchstart", handleDisplayFocus, { + passive: true, + }); refreshKeyboardHandlers(); client.onstatechange = (state: number) => { @@ -529,6 +555,7 @@ export const GuacamoleDisplay = forwardRef< connectionConfig.protocol, connectionConfig.type, connectionConfig.dpi, + touchMode, t, ]); diff --git a/src/ui/features/guacamole/GuacamoleToolbar.tsx b/src/ui/features/guacamole/GuacamoleToolbar.tsx index e28bae0f..2736d688 100644 --- a/src/ui/features/guacamole/GuacamoleToolbar.tsx +++ b/src/ui/features/guacamole/GuacamoleToolbar.tsx @@ -13,6 +13,8 @@ import { ChevronUp, ChevronDown, ChevronsLeftRight, + Touchpad, + MousePointer, } from "lucide-react"; import { Tooltip, @@ -20,13 +22,18 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/tooltip.tsx"; -import type { GuacamoleDisplayHandle } from "@/features/guacamole/GuacamoleDisplay.tsx"; +import type { + GuacamoleDisplayHandle, + GuacamoleTouchMode, +} from "@/features/guacamole/GuacamoleDisplay.tsx"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; interface GuacamoleToolbarProps { displayRef: React.RefObject; protocol: "rdp" | "vnc" | "telnet"; + touchMode?: GuacamoleTouchMode | null; + onTouchModeChange?: (mode: GuacamoleTouchMode) => void; } const MODIFIER_KEYSYMS = { @@ -107,6 +114,8 @@ function TipIconBtn({ export const GuacamoleToolbar: React.FC = ({ displayRef, protocol, + touchMode, + onTouchModeChange, }) => { const { t } = useTranslation(); const [position, setPosition] = useState({ x: 0, y: 12 }); @@ -287,6 +296,39 @@ export const GuacamoleToolbar: React.FC = ({ + {/* Touch mode toggle — touch devices only */} + {touchMode != null && onTouchModeChange && ( + <> +
+ + + + + + {touchMode === "touchscreen" + ? t("guacamole.toolbar.switchToTrackpad") + : t("guacamole.toolbar.switchToTouch")} + + + + )} + {/* System combos — RDP/VNC only */} {isRdpVnc && ( <> diff --git a/src/ui/features/serial/Serial.tsx b/src/ui/features/serial/Serial.tsx index 969b0ad6..828aba1b 100644 --- a/src/ui/features/serial/Serial.tsx +++ b/src/ui/features/serial/Serial.tsx @@ -14,6 +14,7 @@ import { isEmbeddedMode } from "@/main-axios"; import { useTheme } from "@/components/theme-provider"; import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme"; import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes"; +import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles"; import type { SerialConfig } from "@/types/ui-types"; import type { SerialHandle } from "./serial-types"; @@ -67,6 +68,7 @@ export const Serial = forwardRef(function Serial( const fontConfig = TERMINAL_FONTS.find( (f) => f.value === DEFAULT_TERMINAL_CONFIG.fontFamily, ); + ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value); terminal.options.theme = { background: themeColors.background, foreground: themeColors.foreground, diff --git a/src/ui/features/terminal/MobileTerminalKeyboard.tsx b/src/ui/features/terminal/MobileTerminalKeyboard.tsx index 83d0123a..f21364e1 100644 --- a/src/ui/features/terminal/MobileTerminalKeyboard.tsx +++ b/src/ui/features/terminal/MobileTerminalKeyboard.tsx @@ -5,11 +5,13 @@ import { ChevronDown, ChevronLeft, ChevronRight, + Clipboard, Pencil, X, Plus, RotateCcw, } from "lucide-react"; +import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; @@ -252,6 +254,18 @@ export function MobileTerminalKeyboard({ terminalRef.current?.sendInput?.(seq); } + async function handlePaste() { + try { + const text = window.electronClipboard + ? await window.electronClipboard.readText() + : ((await navigator.clipboard?.readText?.()) ?? ""); + if (text) terminalRef.current?.paste?.(text); + else toast.error(t("terminal.clipboardReadFailed")); + } catch { + toast.error(t("terminal.clipboardReadFailed")); + } + } + function toggleCtrl() { setCtrlActive((v) => !v); setShiftActive(false); @@ -322,6 +336,18 @@ export function MobileTerminalKeyboard({ {shiftActive ? t("mobileKeyboard.backTab") : t("mobileKeyboard.tab")} + {/* Paste */} + +
{/* Ctrl */} diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 7eda9981..18993961 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -39,7 +39,7 @@ import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS, } from "@/lib/terminal-themes.ts"; -import "./terminal-global-styles.ts"; +import { ensureTerminalFontsLoaded } from "./terminal-global-styles.ts"; import { useTheme } from "@/components/theme-provider.tsx"; import { globalShortcutHandler } from "@/lib/global-shortcut-handler"; import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts"; @@ -824,6 +824,9 @@ const TerminalInner = forwardRef( webSocketRef.current.send(JSON.stringify({ type: "input", data })); } }, + paste: (text: string) => { + terminal?.paste(text); + }, notifyResize: () => { try { const cols = terminal?.cols ?? undefined; @@ -1986,6 +1989,7 @@ const TerminalInner = forwardRef( (f) => f.value === config.fontFamily, ); const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback; + ensureTerminalFontsLoaded(fontConfig?.value || TERMINAL_FONTS[0].value); // Update terminal options individually to avoid re-initialization flashes terminal.options.cursorBlink = config.cursorBlink; @@ -2053,6 +2057,7 @@ const TerminalInner = forwardRef( (f) => f.value === config.fontFamily, ); const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback; + ensureTerminalFontsLoaded(fontConfig?.value || TERMINAL_FONTS[0].value); const activeTheme = previewTheme || config.theme; const themeColors = resolveTermixThemeColors( diff --git a/src/ui/features/terminal/terminal-global-styles.ts b/src/ui/features/terminal/terminal-global-styles.ts index a5a1b60f..c91a890f 100644 --- a/src/ui/features/terminal/terminal-global-styles.ts +++ b/src/ui/features/terminal/terminal-global-styles.ts @@ -46,6 +46,7 @@ style.innerHTML = ` .xterm .xterm-viewport { scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.3) transparent; + background-color: transparent !important; } .dark .xterm .xterm-viewport::-webkit-scrollbar-thumb { @@ -74,3 +75,22 @@ style.innerHTML = ` } `; document.head.appendChild(style); + +// Canvas fillText() does not reliably trigger @font-face fetches on every +// browser engine (notably Android WebView) the way rendering real DOM text +// does. xterm.js draws glyphs to a , so without an explicit load the +// terminal can keep painting the fallback font's tofu boxes even after +// document.fonts.ready resolves. Forcing the load here ensures the glyph +// data is actually fetched before the terminal renders with it. +export function ensureTerminalFontsLoaded(fontFamily: string): void { + if (typeof document === "undefined" || !document.fonts) return; + const specs = [ + `400 16px "${fontFamily}"`, + `700 16px "${fontFamily}"`, + `italic 400 16px "${fontFamily}"`, + `italic 700 16px "${fontFamily}"`, + ]; + for (const spec of specs) { + document.fonts.load(spec).catch(() => {}); + } +} diff --git a/src/ui/features/terminal/terminal-types.ts b/src/ui/features/terminal/terminal-types.ts index 3c2c562b..88fff378 100644 --- a/src/ui/features/terminal/terminal-types.ts +++ b/src/ui/features/terminal/terminal-types.ts @@ -24,6 +24,7 @@ export interface TerminalHandle { fit: () => void; focus: () => void; sendInput: (data: string) => void; + paste: (text: string) => void; notifyResize: () => void; refresh: () => void; getApplicationCursorKeysMode: () => boolean; diff --git a/src/ui/lib/terminal-syntax-highlighter.ts b/src/ui/lib/terminal-syntax-highlighter.ts index b576514e..3799e59f 100644 --- a/src/ui/lib/terminal-syntax-highlighter.ts +++ b/src/ui/lib/terminal-syntax-highlighter.ts @@ -256,11 +256,11 @@ function highlightPlainText( text: string, activePatterns: HighlightPattern[], activeSgr: string, + protectedRanges: ProtectedRange[], ): string { if (text.length > MAX_LINE_LENGTH || !text.trim()) return text; const matches: MatchResult[] = []; - const protectedRanges = getProtectedRanges(text); for (const pattern of activePatterns) { pattern.regex.lastIndex = 0; @@ -381,13 +381,34 @@ function highlightLine( if (bare.length > MAX_LINE_LENGTH) return line; if (isShellPromptLine(bare)) return line; + // Compute protected ranges (e.g. SSH bracket headings) against the fully + // stripped line rather than per-ANSI-segment text. A colored prompt theme + // (e.g. "[user@host]") splits the heading across + // multiple plain-text segments, so matching per-segment would miss it and + // let a username like "warning" get wrongly highlighted as a log level. + const plainLine = bare.replace(STRIP_ANSI_RE, ""); + const lineProtectedRanges = getProtectedRanges(plainLine); + const segments = parseAnsiSegments(bare); + let plainOffset = 0; const result = segments - .map((s) => - s.isAnsi - ? s.content - : highlightPlainText(s.content, activePatterns, s.activeSgr ?? ""), - ) + .map((s) => { + if (s.isAnsi) return s.content; + const segmentStart = plainOffset; + plainOffset += s.content.length; + const localRanges = lineProtectedRanges + .map((r) => ({ + start: r.start - segmentStart, + end: r.end - segmentStart, + })) + .filter((r) => r.start < s.content.length && r.end > 0); + return highlightPlainText( + s.content, + activePatterns, + s.activeSgr ?? "", + localRanges, + ); + }) .join(""); return cr ? result + "\r" : result; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 9e33a4ab..3a94e234 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -602,6 +602,7 @@ "overrideCredentialUsername": "Override Credential Username", "overrideCredentialUsernameDesc": "Use the username specified above instead of the credential's username", "oidcUsernameHint": "Use $oidc.preferred_username to substitute your OIDC login name.", + "tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.", "jumpHostChain": "Jump Host Chain", "portKnocking": "Port Knocking", "addKnock": "Add Port", @@ -895,6 +896,7 @@ "proxmoxResultImported": "{{count}} imported", "proxmoxResultUpdated": "{{count}} updated", "proxmoxResultFailed": "{{count}} failed", + "proxmoxResultSkippedNoIp": "{{count}} skipped (no IP found)", "proxmoxImportComplete": "Proxmox import complete: {{summary}}", "proxmoxDiscoveryFailed": "Discovery failed", "proxmoxImportFailed": "Import failed", @@ -1461,7 +1463,9 @@ "reconnect": "Reconnect Session", "collapse": "Collapse toolbar", "expand": "Expand toolbar", - "dragHandle": "Drag to reposition" + "dragHandle": "Drag to reposition", + "switchToTrackpad": "Switch to trackpad mode (drag to move cursor, tap to click)", + "switchToTouch": "Switch to touch mode (tap directly where you want to click)" } }, "terminal": { @@ -3379,6 +3383,7 @@ "pageUp": "PgUp", "pageDown": "PgDn", "delete": "Del", + "paste": "Paste", "editQuickKeys": "Edit quick keys", "quickKeysTitle": "Quick Keys", "quickKeysDesc": "Tap × to remove. Supports up to 8 characters.", diff --git a/src/ui/sidebar/AdminUserManagePanel.tsx b/src/ui/sidebar/AdminUserManagePanel.tsx index d9745be3..4a64877b 100644 --- a/src/ui/sidebar/AdminUserManagePanel.tsx +++ b/src/ui/sidebar/AdminUserManagePanel.tsx @@ -446,6 +446,13 @@ export function AdminUserManagePanel({ key={editor.credential ? editor.credential.id : "new-cred"} credential={editor.credential} activeTab={editorTab} + existingFolders={Array.from( + new Set( + credentials + .map((c) => c.folder) + .filter((f): f is string => !!f), + ), + ).sort()} onBack={() => { setEditor(null); setEditorTab("general"); diff --git a/src/ui/sidebar/CredentialEditorView.tsx b/src/ui/sidebar/CredentialEditorView.tsx index a50a5cda..8a198158 100644 --- a/src/ui/sidebar/CredentialEditorView.tsx +++ b/src/ui/sidebar/CredentialEditorView.tsx @@ -26,14 +26,14 @@ export function CredentialEditorView({ onBack, onSave, adminTargetUserId, + existingFolders = [], }: { credential: Credential | null; activeTab: string; onBack: () => void; onSave: (saved: Record) => void; - // When set, saves go to another user's credentials via the admin - // impersonation endpoints. adminTargetUserId?: string; + existingFolders?: string[]; }) { const [credForm, setCredForm] = useState(() => ({ name: credential?.name ?? "", @@ -155,7 +155,15 @@ export function CredentialEditorView({ placeholder="e.g. Server Keys" value={credForm.folder} onChange={(e) => setCredField("folder", e.target.value)} + list="cred-folder-suggestions" /> + {existingFolders.length > 0 && ( + + {existingFolders.map((f) => ( + + )}
{authMethod === "password" && (
@@ -950,8 +959,8 @@ export function HostEditor({
setField("fontSize", v)} diff --git a/src/ui/sidebar/HostManager.tsx b/src/ui/sidebar/HostManager.tsx index 375f8cca..8a3f21d9 100644 --- a/src/ui/sidebar/HostManager.tsx +++ b/src/ui/sidebar/HostManager.tsx @@ -431,6 +431,13 @@ export function HostManager({ : (editingCredential as Credential) } activeTab={activeCredentialTab} + existingFolders={Array.from( + new Set( + credentials + .map((c) => c.folder) + .filter((f): f is string => !!f), + ), + ).sort()} onBack={() => { setEditingCredential(null); setActiveCredentialTab("general"); diff --git a/src/ui/sidebar/HostManagerData.ts b/src/ui/sidebar/HostManagerData.ts index 736ae2ba..18ff2597 100644 --- a/src/ui/sidebar/HostManagerData.ts +++ b/src/ui/sidebar/HostManagerData.ts @@ -81,15 +81,28 @@ export function sshHostToHost(h: SSHHostWithStatus): Host { rdpPort: h.rdpPort ?? (h.connectionType === "rdp" ? h.port : 3389), vncPort: h.vncPort ?? (h.connectionType === "vnc" ? h.port : 5900), telnetPort: h.telnetPort ?? (h.connectionType === "telnet" ? h.port : 23), + rdpAuthType: + (h.rdpAuthType as "direct" | "credential") ?? + (h.rdpCredentialId ? "credential" : "direct"), + rdpCredentialId: + h.rdpCredentialId != null ? String(h.rdpCredentialId) : undefined, rdpUser: h.rdpUser, rdpPassword: h.rdpPassword ?? "", domain: h.rdpDomain, security: h.rdpSecurity, ignoreCert: h.rdpIgnoreCert ?? false, - vncAuthType: h.vncAuthType ?? (h.vncCredentialId ? "credential" : "direct"), - vncCredentialId: h.vncCredentialId ?? null, + vncAuthType: + (h.vncAuthType as "direct" | "credential") ?? + (h.vncCredentialId ? "credential" : "direct"), + vncCredentialId: + h.vncCredentialId != null ? String(h.vncCredentialId) : undefined, vncPassword: h.vncPassword ?? "", vncUser: h.vncUser, + telnetAuthType: + (h.telnetAuthType as "direct" | "credential") ?? + (h.telnetCredentialId ? "credential" : "direct"), + telnetCredentialId: + h.telnetCredentialId != null ? String(h.telnetCredentialId) : undefined, telnetUser: h.telnetUser, telnetPassword: h.telnetPassword ?? "", quickActions: (h.quickActions ?? []).map((a: HostQuickAction) => ({ diff --git a/src/ui/tests/features/terminal/ios-rapid-typing.test.ts b/src/ui/tests/features/terminal/ios-rapid-typing.test.ts new file mode 100644 index 00000000..8f644682 --- /dev/null +++ b/src/ui/tests/features/terminal/ios-rapid-typing.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Terminal } from "@xterm/xterm"; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function setTextareaValue(textarea: HTMLTextAreaElement, value: string) { + textarea.value = value; + textarea.selectionStart = value.length; + textarea.selectionEnd = value.length; +} + +// iOS Safari/WKWebView reports keyCode 229 for ordinary software-keyboard +// input, not just true IME composition, so xterm routes typing through +// CompositionHelper.keydown -> _handleAnyTextareaChanges instead of the +// normal keypress path. That handler snapshots the textarea value on +// keydown, then diffs it against the value a setTimeout(0) later. +function dispatchIOSKeydown(textarea: HTMLTextAreaElement) { + textarea.dispatchEvent( + new KeyboardEvent("keydown", { keyCode: 229 } as KeyboardEventInit), + ); +} + +describe("iOS rapid typing (keyCode 229 outside composition)", () => { + let terminal: Terminal | undefined; + let container: HTMLDivElement | undefined; + + afterEach(() => { + terminal?.dispose(); + container?.remove(); + terminal = undefined; + container = undefined; + }); + + it("forwards a mid-word autocorrect rewrite instead of dropping it", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + terminal = new Terminal(); + terminal.open(container); + + const input: string[] = []; + terminal.onData((data) => input.push(data)); + + const textarea = terminal.textarea!; + + // keydown fires while the textarea still holds the pre-keystroke value; + // the browser (or, on iOS, autocorrect) mutates the value afterward. + // Autocorrect can rewrite characters earlier in the word, not just + // append at the cursor, so the old value is no longer a literal + // substring of the new one. + dispatchIOSKeydown(textarea); + setTextareaValue(textarea, "wrold"); + await tick(); + + dispatchIOSKeydown(textarea); + setTextareaValue(textarea, "world"); + await tick(); + + expect(input.join("")).toBe("wrold" + "orld"); + }); +}); diff --git a/src/ui/tests/features/terminal/terminal-global-styles.test.ts b/src/ui/tests/features/terminal/terminal-global-styles.test.ts new file mode 100644 index 00000000..bce2b0e2 --- /dev/null +++ b/src/ui/tests/features/terminal/terminal-global-styles.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { ensureTerminalFontsLoaded } from "../../../features/terminal/terminal-global-styles"; + +describe("ensureTerminalFontsLoaded", () => { + it("requests regular, bold, italic, and bold-italic variants for the given font", () => { + const load = vi.fn().mockResolvedValue([]); + const originalFonts = document.fonts; + Object.defineProperty(document, "fonts", { + configurable: true, + value: { load }, + }); + + try { + ensureTerminalFontsLoaded("Caskaydia Cove Nerd Font Mono"); + + expect(load).toHaveBeenCalledWith( + '400 16px "Caskaydia Cove Nerd Font Mono"', + ); + expect(load).toHaveBeenCalledWith( + '700 16px "Caskaydia Cove Nerd Font Mono"', + ); + expect(load).toHaveBeenCalledWith( + 'italic 400 16px "Caskaydia Cove Nerd Font Mono"', + ); + expect(load).toHaveBeenCalledWith( + 'italic 700 16px "Caskaydia Cove Nerd Font Mono"', + ); + expect(load).toHaveBeenCalledTimes(4); + } finally { + Object.defineProperty(document, "fonts", { + configurable: true, + value: originalFonts, + }); + } + }); + + it("does not throw when document.fonts is unavailable", () => { + const originalFonts = document.fonts; + Object.defineProperty(document, "fonts", { + configurable: true, + value: undefined, + }); + + try { + expect(() => ensureTerminalFontsLoaded("JetBrains Mono")).not.toThrow(); + } finally { + Object.defineProperty(document, "fonts", { + configurable: true, + value: originalFonts, + }); + } + }); + + it("swallows rejected font load promises", async () => { + const load = vi.fn().mockRejectedValue(new Error("network error")); + const originalFonts = document.fonts; + Object.defineProperty(document, "fonts", { + configurable: true, + value: { load }, + }); + + try { + expect(() => ensureTerminalFontsLoaded("Fira Code")).not.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + Object.defineProperty(document, "fonts", { + configurable: true, + value: originalFonts, + }); + } + }); +}); diff --git a/src/ui/tests/lib/terminal-syntax-highlighter.test.ts b/src/ui/tests/lib/terminal-syntax-highlighter.test.ts index 737064f7..c74a1a83 100644 --- a/src/ui/tests/lib/terminal-syntax-highlighter.test.ts +++ b/src/ui/tests/lib/terminal-syntax-highlighter.test.ts @@ -263,6 +263,14 @@ describe("highlightTerminalOutput", () => { expect(out).toContain(`${ESC}[91mERROR`); }); + it("does not highlight a log-level-like username split across ANSI segments in a colored SSH heading", () => { + // Prompt themes often color the user and host portions of "[user@host]" + // separately, so the heading is not one contiguous plain-text segment. + const chunk = `[${ESC}[1;33mwarning${ESC}[0m@host] some command output`; + const out = highlightTerminalOutput(chunk); + expect(out).toBe(chunk); + }); + it("does not highlight 'success' when immediately followed by a path (cd output)", () => { // Some shells print "success~/new/dir" or "success/path" after a cd command const out = highlightTerminalOutput("success~/home/user/projects");