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