mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: general bug fixes
This commit is contained in:
@@ -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 }}"
|
||||||
+14
-7
@@ -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:");
|
||||||
const cookies = await mainWindow.webContents.session.cookies.get({
|
|
||||||
url: cookieUrl,
|
let jwt;
|
||||||
name: "jwt",
|
if (mainWindow?.webContents?.session) {
|
||||||
});
|
const cookies = await mainWindow.webContents.session.cookies.get({
|
||||||
const jwt = cookies[0]?.value;
|
url: cookieUrl,
|
||||||
|
name: "jwt",
|
||||||
|
});
|
||||||
|
jwt = cookies[0]?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jwt) {
|
||||||
|
jwt = getRememberedElectronAuthCookie("jwt", cookieUrl)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
if (!jwt) return {};
|
if (!jwt) return {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+4
-1
@@ -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" />
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,65 +464,91 @@ 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 {
|
||||||
const tunnelPort = await new Promise<number>((resolve, reject) => {
|
socks5ProxyChain =
|
||||||
const sshClient = new Client();
|
typeof hostRecord.socks5ProxyChain === "string"
|
||||||
sshClient.on("ready", () => {
|
? JSON.parse(hostRecord.socks5ProxyChain as string)
|
||||||
const server = net.createServer((sock) => {
|
: (hostRecord.socks5ProxyChain as ProxyNode[]);
|
||||||
sshClient.forwardOut(
|
} catch {
|
||||||
"127.0.0.1",
|
socks5ProxyChain = [];
|
||||||
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);
|
|
||||||
|
|
||||||
const connectOpts: Record<string, unknown> = {
|
const proxyConfig: SOCKS5Config | null =
|
||||||
host: jumpHost.ip,
|
hostRecord.useSocks5 &&
|
||||||
port: jumpHost.port || 22,
|
(hostRecord.socks5Host || socks5ProxyChain.length > 0)
|
||||||
username: jumpHost.username,
|
? {
|
||||||
readyTimeout: 30000,
|
useSocks5: hostRecord.useSocks5 as boolean,
|
||||||
};
|
socks5Host: hostRecord.socks5Host as string | undefined,
|
||||||
if (jumpHost.key) {
|
socks5Port: hostRecord.socks5Port as number | undefined,
|
||||||
connectOpts.privateKey = jumpHost.key;
|
socks5Username: hostRecord.socks5Username as
|
||||||
if (jumpHost.keyPassword)
|
| string
|
||||||
connectOpts.passphrase = jumpHost.keyPassword;
|
| undefined,
|
||||||
} else if (jumpHost.password) {
|
socks5Password: hostRecord.socks5Password as
|
||||||
connectOpts.password = jumpHost.password;
|
| string
|
||||||
}
|
| undefined,
|
||||||
sshClient.connect(connectOpts);
|
socks5ProxyChain,
|
||||||
});
|
}
|
||||||
hostname = "127.0.0.1";
|
: null;
|
||||||
port = tunnelPort;
|
|
||||||
guacLogger.info("SSH tunnel established for guacamole", {
|
const jumpClient = await createJumpHostChain(
|
||||||
operation: "guac_ssh_tunnel",
|
jumpHosts,
|
||||||
hostId,
|
userId,
|
||||||
tunnelPort,
|
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 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) {
|
} 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+/);
|
diskPercent = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, (worst.usedBytes / worst.totalBytes) * 100),
|
||||||
|
);
|
||||||
|
|
||||||
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
const humanRow =
|
||||||
totalHuman = humanParts[1] || null;
|
humanRows.length === bytesRows.length
|
||||||
usedHuman = humanParts[2] || null;
|
? humanRows[worst.index]
|
||||||
availableHuman = humanParts[3] || null;
|
: humanRows.find((row) => row.mount === bytesRows[worst.index].mount);
|
||||||
|
if (humanRow) {
|
||||||
const totalBytes = Number(bytesParts[1]);
|
totalHuman = humanRow.parts[1] || null;
|
||||||
const usedBytes = Number(bytesParts[2]);
|
usedHuman = humanRow.parts[2] || null;
|
||||||
|
availableHuman = humanRow.parts[3] || null;
|
||||||
if (
|
|
||||||
Number.isFinite(totalBytes) &&
|
|
||||||
Number.isFinite(usedBytes) &&
|
|
||||||
totalBytes > 0
|
|
||||||
) {
|
|
||||||
diskPercent = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(100, (usedBytes / totalBytes) * 100),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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;
|
let connectHost = ip;
|
||||||
try {
|
if (connectsViaJumpHosts) {
|
||||||
const resolution = await resolveHostForSshConnect(ip);
|
// The target is only reachable through the jump host's network (e.g. a
|
||||||
connectHost = resolution.host;
|
// VPN-only address), so DNS must be resolved there, not on this host.
|
||||||
if (resolution.resolvedAddress && resolution.resolvedAddress !== ip) {
|
sendLog(
|
||||||
sendLog(
|
"dns",
|
||||||
"dns",
|
"info",
|
||||||
"success",
|
`Skipping local address resolution of ${ip} (resolved by jump host)`,
|
||||||
`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);
|
} else {
|
||||||
return;
|
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}`);
|
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+33
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
const updated = await getSSHHosts();
|
? await bulkImportSSHHosts(toImport, false)
|
||||||
onHostsChanged(updated);
|
: { success: 0, updated: 0, skipped: 0, failed: 0 };
|
||||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
|
||||||
|
if (toImport.length) {
|
||||||
|
const updated = await getSSHHosts();
|
||||||
|
onHostsChanged(updated);
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
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);
|
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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,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)}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user