fix: general bug fixes

This commit is contained in:
LukeGus
2026-07-20 00:38:13 -05:00
parent cf3e2cb499
commit 8da7b25c81
35 changed files with 982 additions and 179 deletions
+83
View File
@@ -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 }}"
+10 -3
View File
@@ -1593,16 +1593,23 @@ function getC2SRelayUrl() {
} }
async function getC2SRelayHeaders(relayUrl) { async function getC2SRelayHeaders(relayUrl) {
if (!mainWindow?.webContents?.session) return {};
const cookieUrl = relayUrl const cookieUrl = relayUrl
.replace(/^ws:/, "http:") .replace(/^ws:/, "http:")
.replace(/^wss:/, "https:"); .replace(/^wss:/, "https:");
let jwt;
if (mainWindow?.webContents?.session) {
const cookies = await mainWindow.webContents.session.cookies.get({ const cookies = await mainWindow.webContents.session.cookies.get({
url: cookieUrl, url: cookieUrl,
name: "jwt", name: "jwt",
}); });
const jwt = cookies[0]?.value; jwt = cookies[0]?.value;
}
if (!jwt) {
jwt = getRememberedElectronAuthCookie("jwt", cookieUrl)?.value;
}
if (!jwt) return {}; if (!jwt) return {};
return { return {
+4 -1
View File
@@ -3,7 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.ico" /> <link rel="icon" type="image/svg+xml" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="theme-color" content="#09090b" /> <meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
+17 -8
View File
@@ -56,20 +56,26 @@ const newVersionBlock =
const oldTimezone = "if (protocolVersion === '1_1_0') {"; const oldTimezone = "if (protocolVersion === '1_1_0') {";
const newTimezone = "if (protocolVersion !== '1_0_0') {"; const newTimezone = "if (protocolVersion !== '1_0_0') {";
// Patch 3: send the `name` handshake instruction for protocol >= 1.3.0. // Patch 3: send the `name` handshake instruction for all protocol versions >= 1.1.0.
// The Guacamole protocol added the `name` instruction in 1.3.0 (an optional // The Guacamole protocol added `name` in 1.3.0, but guacd 1.6.0 began requiring it
// human-readable identifier for the joining user). guacd 1.6.0 began requiring // during the VNC handshake even when negotiating VERSION_1_1_0, causing connections to
// it during the VNC handshake even when negotiating older protocol versions, // silently drop right after "User joined". Sending it for all non-1.0.0 sessions is
// causing connections to silently drop right after "User joined". See // harmless (guacd ignores unknown handshake instructions for older versions). See
// Termix-SSH/Support#567 and #734. // Termix-SSH/Support#567 and #734.
const oldConnect = const oldConnect =
" this.sendInstruction(['connect'].concat(connectArgs));"; " this.sendInstruction(['connect'].concat(connectArgs));";
const newConnect = const oldNameConnect =
" if (protocolVersion === '1_3_0' || protocolVersion === '1_5_0') {\n" + " if (protocolVersion === '1_3_0' || protocolVersion === '1_5_0') {\n" +
" this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" + " this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
" }\n" + " }\n" +
"\n" + "\n" +
" this.sendInstruction(['connect'].concat(connectArgs));"; " 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. // Patch 4: answer guacd's dynamic argument requests locally.
// macOS Screen Sharing can request VNC username/password through the // 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(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( console.log(
"[patch-guacamole-lite] Connect target not found, skipping name patch", "[patch-guacamole-lite] Connect target not found, skipping name patch",
); );
process.exit(0); process.exit(0);
} }
guacdClientContent = guacdClientContent.replace(oldConnect, newConnect);
patched = true; patched = true;
} }
+25
View File
@@ -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", () => { it("answers required credentials through argument value streams", () => {
const client = createPatchedClient({ const client = createPatchedClient({
username: "", username: "",
+29 -4
View File
@@ -14,6 +14,17 @@ const xtermDir = path.join(
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart // xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
// composition on the previous word and replace it with a shorter value (for // composition on the previous word and replace it with a shorter value (for
// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing. // 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 = [ const patches = [
{ {
file: "xterm.mjs", 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,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.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.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.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
], ],
[
'_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<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&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,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.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.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.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
], ],
[
'_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<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}',
],
], ],
}, },
]; ];
@@ -66,18 +85,24 @@ for (const { file, replacements } of patches) {
} }
let source = fs.readFileSync(filePath, "utf8"); let source = fs.readFileSync(filePath, "utf8");
if (source.includes("_preCompositionValue")) { let changed = false;
console.log(`[patch-xterm-android-ime] ${file} already patched`);
continue;
}
for (const [original, patched] of replacements) { for (const [original, patched] of replacements) {
if (source.includes(patched)) {
continue;
}
if (!source.includes(original)) { if (!source.includes(original)) {
throw new Error( throw new Error(
`[patch-xterm-android-ime] Expected source not found in ${file}`, `[patch-xterm-android-ime] Expected source not found in ${file}`,
); );
} }
source = source.replace(original, patched); source = source.replace(original, patched);
changed = true;
}
if (!changed) {
console.log(`[patch-xterm-android-ime] ${file} already patched`);
continue;
} }
fs.writeFileSync(filePath, source); fs.writeFileSync(filePath, source);
+68
View File
@@ -1985,6 +1985,74 @@ const migrateSchema = () => {
addColumnIfNotExists("users", "sso_provider_id", "INTEGER"); 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 // Migrate legacy single oidc_config settings blob into sso_providers table
try { try {
const migrationDone = getRawSettingValue("sso_migration_v1"); const migrationDone = getRawSettingValue("sso_migration_v1");
+37 -2
View File
@@ -488,16 +488,51 @@ async function discoverProxmoxGuestsForHost(
async function resolveIp(g: GuestBase): Promise<string | null> { async function resolveIp(g: GuestBase): Promise<string | null> {
if (g.type === "lxc") { if (g.type === "lxc") {
let configIp: string | null = null;
try { try {
const cfgJson = await execCommand( const cfgJson = await execCommand(
client, client,
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`, `pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`,
8000, 8000,
); );
return parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes); configIp = parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
} catch { } 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<Record<string, unknown>> = 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") { if (g.type === "qemu" && g.status === "running") {
try { try {
+1
View File
@@ -280,6 +280,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
if (userProvidedPassword) { if (userProvidedPassword) {
resolvedCredentials.password = userProvidedPassword; resolvedCredentials.password = userProvidedPassword;
resolvedCredentials.authType = "password";
} }
if (userProvidedSshKey) { if (userProvidedSshKey) {
resolvedCredentials.sshKey = userProvidedSshKey; resolvedCredentials.sshKey = userProvidedSshKey;
+56 -29
View File
@@ -3,16 +3,17 @@ import { GuacamoleTokenService } from "./token-service.js";
import { guacLogger } from "../../utils/logger.js"; import { guacLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js"; import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js";
import { Client } from "ssh2";
import net from "net"; import net from "net";
import crypto from "crypto"; import crypto from "crypto";
import path from "path"; import path from "path";
import type { AuthenticatedRequest } from "../../../types/index.js"; import type { AuthenticatedRequest, ProxyNode } from "../../../types/index.js";
import { import {
createCurrentHostResolutionRepository, createCurrentHostResolutionRepository,
createCurrentSettingsRepository, createCurrentSettingsRepository,
} from "../../database/repositories/factory.js"; } from "../../database/repositories/factory.js";
import { resolveGuacdOptions } from "../../utils/guacd-config.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 router = express.Router();
const tokenService = GuacamoleTokenService.getInstance(); const tokenService = GuacamoleTokenService.getInstance();
@@ -463,18 +464,61 @@ router.post(
if (jumpHosts.length > 0) { if (jumpHosts.length > 0) {
try { try {
const { resolveHostById } = await import("../host-resolver.js"); let socks5ProxyChain: ProxyNode[] = [];
const jumpHost = await resolveHostById(jumpHosts[0].hostId, userId); if (hostRecord.socks5ProxyChain) {
if (jumpHost) { try {
socks5ProxyChain =
typeof hostRecord.socks5ProxyChain === "string"
? JSON.parse(hostRecord.socks5ProxyChain as string)
: (hostRecord.socks5ProxyChain as ProxyNode[]);
} catch {
socks5ProxyChain = [];
}
}
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<number>((resolve, reject) => { const tunnelPort = await new Promise<number>((resolve, reject) => {
const sshClient = new Client();
sshClient.on("ready", () => {
const server = net.createServer((sock) => { const server = net.createServer((sock) => {
sshClient.forwardOut( jumpClient.forwardOut(
"127.0.0.1", "127.0.0.1",
0, 0,
hostname, targetHostname,
port, targetPort,
(err, stream) => { (err, stream) => {
if (err) { if (err) {
sock.destroy(); sock.destroy();
@@ -484,36 +528,20 @@ router.post(
}, },
); );
}); });
server.on("error", reject);
server.listen(0, "127.0.0.1", () => { server.listen(0, "127.0.0.1", () => {
const addr = server.address() as net.AddressInfo; const addr = server.address() as net.AddressInfo;
// Auto-cleanup after 1 hour // Auto-cleanup after 1 hour
setTimeout( setTimeout(
() => { () => {
server.close(); server.close();
sshClient.end(); jumpClient.end();
}, },
60 * 60 * 1000, 60 * 60 * 1000,
); );
resolve(addr.port); resolve(addr.port);
}); });
}); });
sshClient.on("error", reject);
const connectOpts: Record<string, unknown> = {
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"; hostname = "127.0.0.1";
port = tunnelPort; port = tunnelPort;
guacLogger.info("SSH tunnel established for guacamole", { guacLogger.info("SSH tunnel established for guacamole", {
@@ -521,7 +549,6 @@ router.post(
hostId, hostId,
tunnelPort, tunnelPort,
}); });
}
} catch (tunnelError) { } catch (tunnelError) {
guacLogger.error("Failed to establish SSH tunnel", tunnelError, { guacLogger.error("Failed to establish SSH tunnel", tunnelError, {
operation: "guac_ssh_tunnel_error", operation: "guac_ssh_tunnel_error",
@@ -1,6 +1,67 @@
import type { Client } from "ssh2"; import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js"; 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<{ export async function collectDiskMetrics(client: Client): Promise<{
percent: number | null; percent: number | null;
usedHuman: string | null; usedHuman: string | null;
@@ -14,41 +75,28 @@ export async function collectDiskMetrics(client: Client): Promise<{
try { try {
const [diskOutHuman, diskOutBytes] = await Promise.all([ const [diskOutHuman, diskOutBytes] = await Promise.all([
execCommand(client, "df -h -P / | tail -n +2"), execCommand(client, "df -h -P | tail -n +2"),
execCommand(client, "df -B1 -P / | tail -n +2"), execCommand(client, "df -B1 -P | tail -n +2"),
]); ]);
const humanLine = const humanRows = parseDfLines(diskOutHuman.stdout);
diskOutHuman.stdout const bytesRows = parseDfLines(diskOutBytes.stdout);
.split("\n") const worst = findWorstMountIndex(bytesRows);
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const bytesLine =
diskOutBytes.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const humanParts = humanLine.split(/\s+/); if (worst.totalBytes > 0) {
const bytesParts = bytesLine.split(/\s+/);
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( diskPercent = Math.max(
0, 0,
Math.min(100, (usedBytes / totalBytes) * 100), Math.min(100, (worst.usedBytes / worst.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 { } catch {
+21 -5
View File
@@ -1288,8 +1288,23 @@ 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; let connectHost = ip;
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)`,
);
} else {
sendLog("dns", "info", `Starting address resolution of ${ip}`);
try { try {
const resolution = await resolveHostForSshConnect(ip); const resolution = await resolveHostForSshConnect(ip);
connectHost = resolution.host; connectHost = resolution.host;
@@ -1302,7 +1317,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
); );
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message =
error instanceof Error ? error.message : "Unknown error";
sshLogger.error("SSH hostname resolution failed", error, { sshLogger.error("SSH hostname resolution failed", error, {
operation: "terminal_dns_resolve", operation: "terminal_dns_resolve",
hostId: id, hostId: id,
@@ -1322,6 +1338,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
cleanupAuthState(connectionTimeout); cleanupAuthState(connectionTimeout);
return; return;
} }
}
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`); sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
sshConn.on("ready", () => { sshConn.on("ready", () => {
@@ -2014,7 +2031,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
sendLog( sendLog(
"auth", "auth",
"error", "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) { if (currentSessionId) {
sessionManager.destroySession(currentSessionId); sessionManager.destroySession(currentSessionId);
@@ -2024,8 +2041,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "error", type: "error",
message: 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.`,
"Tailscale SSH authentication failed. Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy permits this connection.",
}), }),
); );
return; return;
@@ -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);
});
});
+33
View File
@@ -97,6 +97,39 @@ declare module "guacamole-common-js" {
up: boolean; up: boolean;
down: 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 { class Keyboard {
+4
View File
@@ -152,6 +152,7 @@ export type Host = {
vncPort: number; vncPort: number;
telnetPort: number; telnetPort: number;
rdpAuthType?: "direct" | "credential";
rdpCredentialId?: string; rdpCredentialId?: string;
rdpUser?: string; rdpUser?: string;
rdpPassword?: string; rdpPassword?: string;
@@ -159,10 +160,13 @@ export type Host = {
security?: string; security?: string;
ignoreCert?: boolean; ignoreCert?: boolean;
vncAuthType?: "direct" | "credential";
vncCredentialId?: string; vncCredentialId?: string;
vncPassword?: string; vncPassword?: string;
vncUser?: string; vncUser?: string;
telnetAuthType?: "direct" | "credential";
telnetCredentialId?: string;
telnetUser?: string; telnetUser?: string;
telnetPassword?: string; telnetPassword?: string;
@@ -121,11 +121,14 @@ export function ProxmoxDiscoverDialog({
const credId = defaultCredentialId ?? discoveredCredentialId; const credId = defaultCredentialId ?? discoveredCredentialId;
const importAuth = resolveProxmoxImportAuth(defaultAuthType, credId); const importAuth = resolveProxmoxImportAuth(defaultAuthType, credId);
const toImport = guests const selectedGuests = guests.filter((g) => selected.has(g.vmid));
.filter((g) => selected.has(g.vmid)) const skippedNoIp = selectedGuests.filter((g) => !g.ip).length;
const toImport = selectedGuests
.filter((g) => !!g.ip)
.map((g) => ({ .map((g) => ({
name: g.name, name: g.name,
ip: g.ip ?? "0.0.0.0", ip: g.ip as string,
port: g.connectionType === "rdp" ? 3389 : 22, port: g.connectionType === "rdp" ? 3389 : 22,
username: defaultUsername ?? "root", username: defaultUsername ?? "root",
folder: importFolder, folder: importFolder,
@@ -152,10 +155,15 @@ export function ProxmoxDiscoverDialog({
}, },
})); }));
const result = await bulkImportSSHHosts(toImport, false); const result = toImport.length
? await bulkImportSSHHosts(toImport, false)
: { success: 0, updated: 0, skipped: 0, failed: 0 };
if (toImport.length) {
const updated = await getSSHHosts(); const updated = await getSSHHosts();
onHostsChanged(updated); onHostsChanged(updated);
window.dispatchEvent(new CustomEvent("termix:hosts-changed")); window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
}
const msg = [ const msg = [
result.success result.success
@@ -167,6 +175,9 @@ export function ProxmoxDiscoverDialog({
result.failed result.failed
? t("hosts.proxmoxResultFailed", { count: result.failed }) ? t("hosts.proxmoxResultFailed", { count: result.failed })
: null, : null,
skippedNoIp
? t("hosts.proxmoxResultSkippedNoIp", { count: skippedNoIp })
: null,
] ]
.filter(Boolean) .filter(Boolean)
.join(", "); .join(", ");
@@ -23,6 +23,7 @@ import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme"; import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes"; import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
import { useTheme } from "@/components/theme-provider"; import { useTheme } from "@/components/theme-provider";
interface ConsoleTerminalProps { interface ConsoleTerminalProps {
@@ -77,6 +78,7 @@ export function ConsoleTerminal({
(f) => f.value === terminalConfig.fontFamily, (f) => f.value === terminalConfig.fontFamily,
); );
const fontFamily = fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback; const fontFamily = fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback;
ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value);
terminal.options.cursorBlink = terminalConfig.cursorBlink; terminal.options.cursorBlink = terminalConfig.cursorBlink;
terminal.options.cursorStyle = terminalConfig.cursorStyle; terminal.options.cursorStyle = terminalConfig.cursorStyle;
+15 -2
View File
@@ -8,6 +8,7 @@ import React, {
import { import {
GuacamoleDisplay, GuacamoleDisplay,
type GuacamoleDisplayHandle, type GuacamoleDisplayHandle,
type GuacamoleTouchMode,
} from "@/features/guacamole/GuacamoleDisplay.tsx"; } from "@/features/guacamole/GuacamoleDisplay.tsx";
import { import {
getGuacamoleTokenFromHost, getGuacamoleTokenFromHost,
@@ -114,6 +115,12 @@ const GuacamoleAppInner = React.forwardRef<
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null); const [connectionError, setConnectionError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0); const [retryCount, setRetryCount] = useState(0);
const [touchMode, setTouchMode] = useState<GuacamoleTouchMode | null>(() =>
typeof window !== "undefined" &&
(navigator.maxTouchPoints > 0 || "ontouchstart" in window)
? "touchscreen"
: null,
);
const displayRef = useRef<GuacamoleDisplayHandle>(null); const displayRef = useRef<GuacamoleDisplayHandle>(null);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -245,7 +252,7 @@ const GuacamoleAppInner = React.forwardRef<
</div> </div>
)} )}
<GuacamoleDisplay <GuacamoleDisplay
key={token} key={`${token}-${touchMode}`}
ref={displayRef} ref={displayRef}
connectionConfig={{ connectionConfig={{
token, token,
@@ -257,9 +264,15 @@ const GuacamoleAppInner = React.forwardRef<
: undefined, : undefined,
}} }}
isVisible={true} isVisible={true}
touchMode={touchMode}
onError={(err) => setConnectionError(err)} onError={(err) => setConnectionError(err)}
/> />
<GuacamoleToolbar displayRef={displayRef} protocol={resolvedProtocol} /> <GuacamoleToolbar
displayRef={displayRef}
protocol={resolvedProtocol}
touchMode={touchMode}
onTouchModeChange={setTouchMode}
/>
</div> </div>
); );
}); });
+35 -8
View File
@@ -44,9 +44,12 @@ export interface GuacamoleDisplayHandle {
setClipboard: (data: string) => void; setClipboard: (data: string) => void;
} }
export type GuacamoleTouchMode = "touchscreen" | "touchpad";
interface GuacamoleDisplayProps { interface GuacamoleDisplayProps {
connectionConfig: GuacamoleConnectionConfig; connectionConfig: GuacamoleConnectionConfig;
isVisible: boolean; isVisible: boolean;
touchMode?: GuacamoleTouchMode | null;
onConnect?: () => void; onConnect?: () => void;
onDisconnect?: () => void; onDisconnect?: () => void;
onError?: (error: string) => void; onError?: (error: string) => void;
@@ -58,7 +61,7 @@ export const GuacamoleDisplay = forwardRef<
GuacamoleDisplayHandle, GuacamoleDisplayHandle,
GuacamoleDisplayProps GuacamoleDisplayProps
>(function GuacamoleDisplay( >(function GuacamoleDisplay(
{ connectionConfig, isVisible, onConnect, onDisconnect, onError }, { connectionConfig, isVisible, touchMode, onConnect, onDisconnect, onError },
ref, ref,
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -388,26 +391,46 @@ export const GuacamoleDisplay = forwardRef<
setIsReady(true); setIsReady(true);
} }
const mouse = new Guacamole.Mouse(displayElement); const sendMouseEvent = (event: Guacamole.Mouse.MouseEvent) => {
const sendMouseState = (state: Guacamole.Mouse.State) => {
displayElement.focus({ preventScroll: true }); displayElement.focus({ preventScroll: true });
const scale = scaleRef.current; const scale = scaleRef.current;
const adjustedX = Math.round(state.x / scale); const state = event.state;
const adjustedY = Math.round(state.y / scale);
const adjustedState = new Guacamole.Mouse.State( const adjustedState = new Guacamole.Mouse.State(
adjustedX, Math.round(state.x / scale),
adjustedY, Math.round(state.y / scale),
state.left, state.left,
state.middle, state.middle,
state.right, state.right,
state.up, state.up,
state.down, state.down,
) as Guacamole.Mouse.State; ) as Guacamole.Mouse.State;
client.sendMouseState(adjustedState);
};
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); client.sendMouseState(adjustedState);
}; };
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState; mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
}
const keyboard = new Guacamole.Keyboard(displayElement); const keyboard = new Guacamole.Keyboard(displayElement);
keyboardRef.current = keyboard; keyboardRef.current = keyboard;
@@ -425,6 +448,9 @@ export const GuacamoleDisplay = forwardRef<
displayElement.addEventListener("focus", handleDisplayFocus); displayElement.addEventListener("focus", handleDisplayFocus);
displayElement.addEventListener("blur", handleDisplayBlur); displayElement.addEventListener("blur", handleDisplayBlur);
displayElement.addEventListener("mousedown", handleDisplayFocus); displayElement.addEventListener("mousedown", handleDisplayFocus);
displayElement.addEventListener("touchstart", handleDisplayFocus, {
passive: true,
});
refreshKeyboardHandlers(); refreshKeyboardHandlers();
client.onstatechange = (state: number) => { client.onstatechange = (state: number) => {
@@ -529,6 +555,7 @@ export const GuacamoleDisplay = forwardRef<
connectionConfig.protocol, connectionConfig.protocol,
connectionConfig.type, connectionConfig.type,
connectionConfig.dpi, connectionConfig.dpi,
touchMode,
t, t,
]); ]);
+43 -1
View File
@@ -13,6 +13,8 @@ import {
ChevronUp, ChevronUp,
ChevronDown, ChevronDown,
ChevronsLeftRight, ChevronsLeftRight,
Touchpad,
MousePointer,
} from "lucide-react"; } from "lucide-react";
import { import {
Tooltip, Tooltip,
@@ -20,13 +22,18 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/tooltip.tsx"; } 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 { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface GuacamoleToolbarProps { interface GuacamoleToolbarProps {
displayRef: React.RefObject<GuacamoleDisplayHandle>; displayRef: React.RefObject<GuacamoleDisplayHandle>;
protocol: "rdp" | "vnc" | "telnet"; protocol: "rdp" | "vnc" | "telnet";
touchMode?: GuacamoleTouchMode | null;
onTouchModeChange?: (mode: GuacamoleTouchMode) => void;
} }
const MODIFIER_KEYSYMS = { const MODIFIER_KEYSYMS = {
@@ -107,6 +114,8 @@ function TipIconBtn({
export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
displayRef, displayRef,
protocol, protocol,
touchMode,
onTouchModeChange,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [position, setPosition] = useState({ x: 0, y: 12 }); const [position, setPosition] = useState({ x: 0, y: 12 });
@@ -287,6 +296,39 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{/* Touch mode toggle — touch devices only */}
{touchMode != null && onTouchModeChange && (
<>
<div className={SEP} />
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
onTouchModeChange(
touchMode === "touchscreen"
? "touchpad"
: "touchscreen",
)
}
className={cn(BTN_ICON)}
>
{touchMode === "touchscreen" ? (
<MousePointer className="size-3.5" />
) : (
<Touchpad className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{touchMode === "touchscreen"
? t("guacamole.toolbar.switchToTrackpad")
: t("guacamole.toolbar.switchToTouch")}
</TooltipContent>
</Tooltip>
</>
)}
{/* System combos — RDP/VNC only */} {/* System combos — RDP/VNC only */}
{isRdpVnc && ( {isRdpVnc && (
<> <>
+2
View File
@@ -14,6 +14,7 @@ import { isEmbeddedMode } from "@/main-axios";
import { useTheme } from "@/components/theme-provider"; import { useTheme } from "@/components/theme-provider";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme"; import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes"; 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 { SerialConfig } from "@/types/ui-types";
import type { SerialHandle } from "./serial-types"; import type { SerialHandle } from "./serial-types";
@@ -67,6 +68,7 @@ export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
const fontConfig = TERMINAL_FONTS.find( const fontConfig = TERMINAL_FONTS.find(
(f) => f.value === DEFAULT_TERMINAL_CONFIG.fontFamily, (f) => f.value === DEFAULT_TERMINAL_CONFIG.fontFamily,
); );
ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value);
terminal.options.theme = { terminal.options.theme = {
background: themeColors.background, background: themeColors.background,
foreground: themeColors.foreground, foreground: themeColors.foreground,
@@ -5,11 +5,13 @@ import {
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Clipboard,
Pencil, Pencil,
X, X,
Plus, Plus,
RotateCcw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
@@ -252,6 +254,18 @@ export function MobileTerminalKeyboard({
terminalRef.current?.sendInput?.(seq); 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() { function toggleCtrl() {
setCtrlActive((v) => !v); setCtrlActive((v) => !v);
setShiftActive(false); setShiftActive(false);
@@ -322,6 +336,18 @@ export function MobileTerminalKeyboard({
{shiftActive ? t("mobileKeyboard.backTab") : t("mobileKeyboard.tab")} {shiftActive ? t("mobileKeyboard.backTab") : t("mobileKeyboard.tab")}
</button> </button>
{/* Paste */}
<button
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
onPointerDown={(e) => {
e.preventDefault();
handlePaste();
}}
title={t("mobileKeyboard.paste")}
>
<Clipboard className="size-4" />
</button>
<div className={SEP} /> <div className={SEP} />
{/* Ctrl */} {/* Ctrl */}
+6 -1
View File
@@ -39,7 +39,7 @@ import {
DEFAULT_TERMINAL_CONFIG, DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS, TERMINAL_FONTS,
} from "@/lib/terminal-themes.ts"; } 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 { useTheme } from "@/components/theme-provider.tsx";
import { globalShortcutHandler } from "@/lib/global-shortcut-handler"; import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts"; import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts";
@@ -824,6 +824,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
webSocketRef.current.send(JSON.stringify({ type: "input", data })); webSocketRef.current.send(JSON.stringify({ type: "input", data }));
} }
}, },
paste: (text: string) => {
terminal?.paste(text);
},
notifyResize: () => { notifyResize: () => {
try { try {
const cols = terminal?.cols ?? undefined; const cols = terminal?.cols ?? undefined;
@@ -1986,6 +1989,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
(f) => f.value === config.fontFamily, (f) => f.value === config.fontFamily,
); );
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback; const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
ensureTerminalFontsLoaded(fontConfig?.value || TERMINAL_FONTS[0].value);
// Update terminal options individually to avoid re-initialization flashes // Update terminal options individually to avoid re-initialization flashes
terminal.options.cursorBlink = config.cursorBlink; terminal.options.cursorBlink = config.cursorBlink;
@@ -2053,6 +2057,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
(f) => f.value === config.fontFamily, (f) => f.value === config.fontFamily,
); );
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback; const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
ensureTerminalFontsLoaded(fontConfig?.value || TERMINAL_FONTS[0].value);
const activeTheme = previewTheme || config.theme; const activeTheme = previewTheme || config.theme;
const themeColors = resolveTermixThemeColors( const themeColors = resolveTermixThemeColors(
@@ -46,6 +46,7 @@ style.innerHTML = `
.xterm .xterm-viewport { .xterm .xterm-viewport {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.3) transparent; scrollbar-color: rgba(0,0,0,0.3) transparent;
background-color: transparent !important;
} }
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb { .dark .xterm .xterm-viewport::-webkit-scrollbar-thumb {
@@ -74,3 +75,22 @@ style.innerHTML = `
} }
`; `;
document.head.appendChild(style); 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 <canvas>, 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(() => {});
}
}
@@ -24,6 +24,7 @@ export interface TerminalHandle {
fit: () => void; fit: () => void;
focus: () => void; focus: () => void;
sendInput: (data: string) => void; sendInput: (data: string) => void;
paste: (text: string) => void;
notifyResize: () => void; notifyResize: () => void;
refresh: () => void; refresh: () => void;
getApplicationCursorKeysMode: () => boolean; getApplicationCursorKeysMode: () => boolean;
+27 -6
View File
@@ -256,11 +256,11 @@ function highlightPlainText(
text: string, text: string,
activePatterns: HighlightPattern[], activePatterns: HighlightPattern[],
activeSgr: string, activeSgr: string,
protectedRanges: ProtectedRange[],
): string { ): string {
if (text.length > MAX_LINE_LENGTH || !text.trim()) return text; if (text.length > MAX_LINE_LENGTH || !text.trim()) return text;
const matches: MatchResult[] = []; const matches: MatchResult[] = [];
const protectedRanges = getProtectedRanges(text);
for (const pattern of activePatterns) { for (const pattern of activePatterns) {
pattern.regex.lastIndex = 0; pattern.regex.lastIndex = 0;
@@ -381,13 +381,34 @@ function highlightLine(
if (bare.length > MAX_LINE_LENGTH) return line; if (bare.length > MAX_LINE_LENGTH) return line;
if (isShellPromptLine(bare)) 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. "[<color>user<reset>@<color>host<reset>]") 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); const segments = parseAnsiSegments(bare);
let plainOffset = 0;
const result = segments const result = segments
.map((s) => .map((s) => {
s.isAnsi if (s.isAnsi) return s.content;
? s.content const segmentStart = plainOffset;
: highlightPlainText(s.content, activePatterns, s.activeSgr ?? ""), 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(""); .join("");
return cr ? result + "\r" : result; return cr ? result + "\r" : result;
+6 -1
View File
@@ -602,6 +602,7 @@
"overrideCredentialUsername": "Override Credential Username", "overrideCredentialUsername": "Override Credential Username",
"overrideCredentialUsernameDesc": "Use the username specified above instead of the credential's username", "overrideCredentialUsernameDesc": "Use the username specified above instead of the credential's username",
"oidcUsernameHint": "Use $oidc.preferred_username to substitute your OIDC login name.", "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", "jumpHostChain": "Jump Host Chain",
"portKnocking": "Port Knocking", "portKnocking": "Port Knocking",
"addKnock": "Add Port", "addKnock": "Add Port",
@@ -895,6 +896,7 @@
"proxmoxResultImported": "{{count}} imported", "proxmoxResultImported": "{{count}} imported",
"proxmoxResultUpdated": "{{count}} updated", "proxmoxResultUpdated": "{{count}} updated",
"proxmoxResultFailed": "{{count}} failed", "proxmoxResultFailed": "{{count}} failed",
"proxmoxResultSkippedNoIp": "{{count}} skipped (no IP found)",
"proxmoxImportComplete": "Proxmox import complete: {{summary}}", "proxmoxImportComplete": "Proxmox import complete: {{summary}}",
"proxmoxDiscoveryFailed": "Discovery failed", "proxmoxDiscoveryFailed": "Discovery failed",
"proxmoxImportFailed": "Import failed", "proxmoxImportFailed": "Import failed",
@@ -1461,7 +1463,9 @@
"reconnect": "Reconnect Session", "reconnect": "Reconnect Session",
"collapse": "Collapse toolbar", "collapse": "Collapse toolbar",
"expand": "Expand 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": { "terminal": {
@@ -3379,6 +3383,7 @@
"pageUp": "PgUp", "pageUp": "PgUp",
"pageDown": "PgDn", "pageDown": "PgDn",
"delete": "Del", "delete": "Del",
"paste": "Paste",
"editQuickKeys": "Edit quick keys", "editQuickKeys": "Edit quick keys",
"quickKeysTitle": "Quick Keys", "quickKeysTitle": "Quick Keys",
"quickKeysDesc": "Tap × to remove. Supports up to 8 characters.", "quickKeysDesc": "Tap × to remove. Supports up to 8 characters.",
+7
View File
@@ -446,6 +446,13 @@ export function AdminUserManagePanel({
key={editor.credential ? editor.credential.id : "new-cred"} key={editor.credential ? editor.credential.id : "new-cred"}
credential={editor.credential} credential={editor.credential}
activeTab={editorTab} activeTab={editorTab}
existingFolders={Array.from(
new Set(
credentials
.map((c) => c.folder)
.filter((f): f is string => !!f),
),
).sort()}
onBack={() => { onBack={() => {
setEditor(null); setEditor(null);
setEditorTab("general"); setEditorTab("general");
+10 -2
View File
@@ -26,14 +26,14 @@ export function CredentialEditorView({
onBack, onBack,
onSave, onSave,
adminTargetUserId, adminTargetUserId,
existingFolders = [],
}: { }: {
credential: Credential | null; credential: Credential | null;
activeTab: string; activeTab: string;
onBack: () => void; onBack: () => void;
onSave: (saved: Record<string, unknown>) => void; onSave: (saved: Record<string, unknown>) => void;
// When set, saves go to another user's credentials via the admin
// impersonation endpoints.
adminTargetUserId?: string; adminTargetUserId?: string;
existingFolders?: string[];
}) { }) {
const [credForm, setCredForm] = useState(() => ({ const [credForm, setCredForm] = useState(() => ({
name: credential?.name ?? "", name: credential?.name ?? "",
@@ -155,7 +155,15 @@ export function CredentialEditorView({
placeholder="e.g. Server Keys" placeholder="e.g. Server Keys"
value={credForm.folder} value={credForm.folder}
onChange={(e) => setCredField("folder", e.target.value)} onChange={(e) => setCredField("folder", e.target.value)}
list="cred-folder-suggestions"
/> />
{existingFolders.length > 0 && (
<datalist id="cred-folder-suggestions">
{existingFolders.map((f) => (
<option key={f} value={f} />
))}
</datalist>
)}
</div> </div>
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> <label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
+11 -2
View File
@@ -11,6 +11,10 @@ import { Button } from "@/components/button";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input"; import { PasswordInput } from "@/components/password-input";
import { Slider } from "@/components/slider"; import { Slider } from "@/components/slider";
import {
TERMINAL_FONT_ZOOM_MIN,
TERMINAL_FONT_ZOOM_MAX,
} from "@/features/terminal/terminal-font-zoom";
import { import {
Globe, Globe,
Layers, // --- tmux-monitor --- Layers, // --- tmux-monitor ---
@@ -385,6 +389,11 @@ export function HostEditor({
{t("hosts.oidcUsernameHint")} {t("hosts.oidcUsernameHint")}
</p> </p>
)} )}
{authMethod === "tailscale" && (
<p className="text-[10px] text-muted-foreground/60">
{t("hosts.tailscaleUsernameHint")}
</p>
)}
</div> </div>
{authMethod === "password" && ( {authMethod === "password" && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -950,8 +959,8 @@ export function HostEditor({
</span> </span>
</div> </div>
<Slider <Slider
min={8} min={TERMINAL_FONT_ZOOM_MIN}
max={24} max={TERMINAL_FONT_ZOOM_MAX}
step={1} step={1}
value={[form.fontSize]} value={[form.fontSize]}
onValueChange={([v]) => setField("fontSize", v)} onValueChange={([v]) => setField("fontSize", v)}
+7
View File
@@ -431,6 +431,13 @@ export function HostManager({
: (editingCredential as Credential) : (editingCredential as Credential)
} }
activeTab={activeCredentialTab} activeTab={activeCredentialTab}
existingFolders={Array.from(
new Set(
credentials
.map((c) => c.folder)
.filter((f): f is string => !!f),
),
).sort()}
onBack={() => { onBack={() => {
setEditingCredential(null); setEditingCredential(null);
setActiveCredentialTab("general"); setActiveCredentialTab("general");
+15 -2
View File
@@ -81,15 +81,28 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
rdpPort: h.rdpPort ?? (h.connectionType === "rdp" ? h.port : 3389), rdpPort: h.rdpPort ?? (h.connectionType === "rdp" ? h.port : 3389),
vncPort: h.vncPort ?? (h.connectionType === "vnc" ? h.port : 5900), vncPort: h.vncPort ?? (h.connectionType === "vnc" ? h.port : 5900),
telnetPort: h.telnetPort ?? (h.connectionType === "telnet" ? h.port : 23), 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, rdpUser: h.rdpUser,
rdpPassword: h.rdpPassword ?? "", rdpPassword: h.rdpPassword ?? "",
domain: h.rdpDomain, domain: h.rdpDomain,
security: h.rdpSecurity, security: h.rdpSecurity,
ignoreCert: h.rdpIgnoreCert ?? false, ignoreCert: h.rdpIgnoreCert ?? false,
vncAuthType: h.vncAuthType ?? (h.vncCredentialId ? "credential" : "direct"), vncAuthType:
vncCredentialId: h.vncCredentialId ?? null, (h.vncAuthType as "direct" | "credential") ??
(h.vncCredentialId ? "credential" : "direct"),
vncCredentialId:
h.vncCredentialId != null ? String(h.vncCredentialId) : undefined,
vncPassword: h.vncPassword ?? "", vncPassword: h.vncPassword ?? "",
vncUser: h.vncUser, vncUser: h.vncUser,
telnetAuthType:
(h.telnetAuthType as "direct" | "credential") ??
(h.telnetCredentialId ? "credential" : "direct"),
telnetCredentialId:
h.telnetCredentialId != null ? String(h.telnetCredentialId) : undefined,
telnetUser: h.telnetUser, telnetUser: h.telnetUser,
telnetPassword: h.telnetPassword ?? "", telnetPassword: h.telnetPassword ?? "",
quickActions: (h.quickActions ?? []).map((a: HostQuickAction) => ({ quickActions: (h.quickActions ?? []).map((a: HostQuickAction) => ({
@@ -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");
});
});
@@ -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,
});
}
});
});
@@ -263,6 +263,14 @@ describe("highlightTerminalOutput", () => {
expect(out).toContain(`${ESC}[91mERROR`); 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)", () => { 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 // Some shells print "success~/new/dir" or "success/path" after a cd command
const out = highlightTerminalOutput("success~/home/user/projects"); const out = highlightTerminalOutput("success~/home/user/projects");