feat: add VNC support with noVNC integration

- Introduced a new VNC viewer panel in the frontend, allowing users to connect to VNC sessions.
- Implemented VNC connection handling with optional SSH tunneling in the backend.
- Added a new SSH key manager for managing SSH keys within the terminal.
- Created a trigger rules engine to monitor terminal output and trigger actions based on defined patterns.
- Added CSS styles for the VNC viewer and its components.
- Updated database schema to support VNC protocol in profiles.
- Implemented WebSocket proxy for VNC connections to facilitate communication between the frontend and backend.
This commit is contained in:
Andy Dixon
2026-05-21 12:52:57 +01:00
parent 523d9b02b4
commit 96fedb9348
26 changed files with 3162 additions and 315 deletions
+12
View File
@@ -22,6 +22,7 @@
<link rel="stylesheet" href="/src/css/forms.css" /> <link rel="stylesheet" href="/src/css/forms.css" />
<link rel="stylesheet" href="/src/css/modals.css" /> <link rel="stylesheet" href="/src/css/modals.css" />
<link rel="stylesheet" href="/src/css/github.css" /> <link rel="stylesheet" href="/src/css/github.css" />
<link rel="stylesheet" href="/src/css/vnc.css" />
</head> </head>
<body> <body>
<div id="app" class="app"> <div id="app" class="app">
@@ -76,6 +77,13 @@
> >
<span>+ New</span> <span>+ New</span>
</button> </button>
<button
class="btn btn-ghost btn-sm"
id="btn-command-palette"
title="Command Palette (⌘K)"
>
<span>⌘K</span>
</button>
<button <button
class="btn btn-ghost btn-icon btn-sm" class="btn btn-ghost btn-icon btn-sm"
id="btn-import" id="btn-import"
@@ -305,6 +313,8 @@
<script src="/src/js/profiles.js"></script> <script src="/src/js/profiles.js"></script>
<script src="/src/js/raw.js"></script> <script src="/src/js/raw.js"></script>
<script src="/src/js/github.js"></script> <script src="/src/js/github.js"></script>
<script src="/src/js/lib/novnc.min.js"></script>
<script src="/src/js/vnc.js"></script>
<script src="/src/js/settings.js"></script> <script src="/src/js/settings.js"></script>
<script src="/src/js/commands.js"></script> <script src="/src/js/commands.js"></script>
<script src="/src/js/secrets.js"></script> <script src="/src/js/secrets.js"></script>
@@ -312,6 +322,8 @@
<script src="/src/js/sftp.js"></script> <script src="/src/js/sftp.js"></script>
<script src="/src/js/splitpane.js"></script> <script src="/src/js/splitpane.js"></script>
<script src="/src/js/snippets.js"></script> <script src="/src/js/snippets.js"></script>
<script src="/src/js/sshkeys.js"></script>
<script src="/src/js/triggers.js"></script>
<script src="/src/js/app.js"></script> <script src="/src/js/app.js"></script>
</body> </body>
</html> </html>
+476
View File
@@ -9,11 +9,487 @@
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@novnc/novnc": "^1.5.0",
"xterm": "5.3.0", "xterm": "5.3.0",
"xterm-addon-fit": "0.8.0", "xterm-addon-fit": "0.8.0",
"xterm-addon-search": "0.13.0", "xterm-addon-search": "0.13.0",
"xterm-addon-unicode11": "0.6.0", "xterm-addon-unicode11": "0.6.0",
"xterm-addon-web-links": "0.9.0" "xterm-addon-web-links": "0.9.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
} }
}, },
"node_modules/xterm": { "node_modules/xterm": {
+6 -1
View File
@@ -5,15 +5,20 @@
"description": "Frontend for Nexus Terminal desktop SSH client", "description": "Frontend for Nexus Terminal desktop SSH client",
"scripts": { "scripts": {
"install:assets": "node scripts/copy-assets.js", "install:assets": "node scripts/copy-assets.js",
"bundle:novnc": "node scripts/bundle-novnc.js",
"build": "node scripts/build.js", "build": "node scripts/build.js",
"dev": "node scripts/build.js --watch", "dev": "node scripts/build.js --watch",
"postinstall": "npm run install:assets" "postinstall": "npm run install:assets && npm run bundle:novnc"
}, },
"dependencies": { "dependencies": {
"@novnc/novnc": "^1.5.0",
"xterm": "5.3.0", "xterm": "5.3.0",
"xterm-addon-fit": "0.8.0", "xterm-addon-fit": "0.8.0",
"xterm-addon-search": "0.13.0", "xterm-addon-search": "0.13.0",
"xterm-addon-unicode11": "0.6.0", "xterm-addon-unicode11": "0.6.0",
"xterm-addon-web-links": "0.9.0" "xterm-addon-web-links": "0.9.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
} }
} }
+34
View File
@@ -0,0 +1,34 @@
/**
* bundle-novnc.js — Uses esbuild to bundle noVNC's RFB class into a single
* script that exposes window.noVNC.RFB for use in the frontend.
*
* noVNC 1.5+ uses top-level await which is incompatible with IIFE format,
* so we bundle as ESM then wrap in an async IIFE via banner/footer.
*/
"use strict";
const esbuild = require("esbuild");
const path = require("path");
const entryPoint = path.resolve(__dirname, "novnc-entry.js");
const outFile = path.resolve(__dirname, "..", "src", "js", "lib", "novnc.min.js");
esbuild
.build({
entryPoints: [entryPoint],
bundle: true,
minify: true,
format: "esm",
outfile: outFile,
target: ["safari15"],
logLevel: "info",
banner: { js: ";(async function() {" },
footer: { js: "})();" },
})
.then(() => {
console.log("[bundle-novnc] noVNC bundled successfully → src/js/lib/novnc.min.js");
})
.catch((err) => {
console.error("[bundle-novnc] Failed:", err.message);
process.exit(1);
});
+4
View File
@@ -0,0 +1,4 @@
// Entry point for esbuild bundling of noVNC.
// Assigns RFB to window.noVNC so it's available as a global in the frontend.
import RFB from "@novnc/novnc";
window.noVNC = { RFB: RFB };
+195
View File
@@ -1020,3 +1020,198 @@
margin: var(--space-1) 0; margin: var(--space-1) 0;
background: var(--border-subtle); background: var(--border-subtle);
} }
/* =======================================================================
SSH KEY MANAGER
======================================================================= */
.sshkey-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: var(--space-3);
}
.sshkey-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 400px;
overflow-y: auto;
}
.sshkey-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
gap: var(--space-3);
}
.sshkey-row:hover {
border-color: var(--border-strong);
}
.sshkey-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sshkey-name {
font-weight: var(--weight-medium);
font-size: var(--text-sm);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sshkey-meta {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.sshkey-actions {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
}
.sshkey-loading,
.sshkey-empty,
.sshkey-error {
padding: var(--space-4);
text-align: center;
color: var(--text-tertiary);
font-size: var(--text-sm);
}
.sshkey-error {
color: var(--error);
}
.sshkey-generate-form {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3);
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
.sshkey-form-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
/* =======================================================================
TRIGGER RULES
======================================================================= */
.trigger-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: var(--space-3);
}
.trigger-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 400px;
overflow-y: auto;
}
.trigger-empty {
padding: var(--space-4);
text-align: center;
color: var(--text-tertiary);
font-size: var(--text-sm);
}
.trigger-row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.trigger-row:hover {
border-color: var(--border-strong);
}
.trigger-toggle {
flex-shrink: 0;
}
.trigger-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.trigger-name {
font-weight: var(--weight-medium);
font-size: var(--text-sm);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.trigger-meta {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.trigger-pattern {
font-size: var(--text-xs);
color: var(--accent);
background: var(--bg-overlay);
padding: 2px 6px;
border-radius: var(--radius-sm);
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trigger-actions {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
}
.trigger-form {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3);
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
.trigger-form .form-row {
display: flex;
gap: var(--space-3);
}
.trigger-form-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
+34
View File
@@ -163,6 +163,40 @@
background: var(--text-tertiary); background: var(--text-tertiary);
} }
/* -----------------------------------------------------------------------
Activity indicator — pulses when background tab has new output
----------------------------------------------------------------------- */
.tab-activity {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: var(--accent);
opacity: 0;
transition: opacity 150ms ease;
}
.tab-has-activity .tab-activity {
opacity: 1;
animation: pulse-dot 1.5s ease infinite;
}
.tab-active .tab-activity {
opacity: 0;
}
/* Trigger flash — brief glow when a trigger rule matches */
.tab-trigger-flash {
animation: trigger-flash 2s ease;
}
@keyframes trigger-flash {
0%, 100% { background: var(--bg-hover); }
15% { background: rgba(239, 83, 80, 0.25); }
30% { background: var(--bg-hover); }
45% { background: rgba(239, 83, 80, 0.15); }
}
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
SFTP toggle button — revealed on hover (SSH tabs only) SFTP toggle button — revealed on hover (SSH tabs only)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
+1
View File
@@ -57,6 +57,7 @@
--protocol-telnet: #ffa726; --protocol-telnet: #ffa726;
--protocol-raw: #ab47bc; --protocol-raw: #ab47bc;
--protocol-github: #7c8aff; --protocol-github: #7c8aff;
--protocol-vnc: #e85d4a;
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Spacing scale (4 px base unit) Spacing scale (4 px base unit)
+76
View File
@@ -0,0 +1,76 @@
/* VNC viewer styles */
.vnc-viewer-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--bg-base);
}
.vnc-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: var(--bg-elevated);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.vnc-status {
font-size: 0.75rem;
color: var(--text-secondary);
margin-right: auto;
}
.vnc-status-connected {
color: var(--protocol-ssh);
}
.vnc-status-error {
color: var(--danger);
}
.btn-vnc-action {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-vnc-action:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.vnc-display {
flex: 1;
position: relative;
overflow: hidden;
background: #000;
}
.vnc-display canvas {
width: 100% !important;
height: 100% !important;
object-fit: contain;
}
/* Protocol colour for VNC */
.proto-vnc {
color: var(--protocol-vnc);
}
.badge-vnc {
background: var(--protocol-vnc);
color: #fff;
}
.sidebar-item-indicator--vnc {
background: var(--protocol-vnc);
}
+30
View File
@@ -20,6 +20,8 @@
ExportImportUI, ExportImportUI,
SFTPBrowser, SFTPBrowser,
SnippetLibrary, SnippetLibrary,
SSHKeyManager,
TriggerEngine,
stateLabel, stateLabel,
} = window.NexusTerminal; } = window.NexusTerminal;
@@ -40,6 +42,8 @@
this.exportImport = new ExportImportUI(this); this.exportImport = new ExportImportUI(this);
this.sftp = new SFTPBrowser(this); this.sftp = new SFTPBrowser(this);
this.snippets = new SnippetLibrary(this); this.snippets = new SnippetLibrary(this);
this.sshKeys = new SSHKeyManager(this);
this.triggers = new TriggerEngine(this);
this.updateInfo = null; this.updateInfo = null;
} }
@@ -159,6 +163,12 @@
// Incoming session data (terminal output) // Incoming session data (terminal output)
window.runtime.EventsOn("session:data", (payload) => { window.runtime.EventsOn("session:data", (payload) => {
this.terminals.handleData(payload.sessionID, payload.data); this.terminals.handleData(payload.sessionID, payload.data);
this.tabs.markActivity(payload.sessionID);
// Check trigger rules against decoded text
try {
const raw = atob(payload.data);
this.triggers.check(payload.sessionID, raw);
} catch (_) { /* noop */ }
}); });
// Session state transitions (connecting → connected → reconnecting → disconnected) // Session state transitions (connecting → connected → reconnecting → disconnected)
@@ -225,6 +235,7 @@
// Session closed by backend // Session closed by backend
window.runtime.EventsOn("session:closed", (payload) => { window.runtime.EventsOn("session:closed", (payload) => {
this.sftp.close(payload.sessionID); this.sftp.close(payload.sessionID);
this.triggers.cleanup(payload.sessionID);
this.tabs.handleSessionClosed(payload.sessionID); this.tabs.handleSessionClosed(payload.sessionID);
this.updateStatusBar(); this.updateStatusBar();
}); });
@@ -259,6 +270,14 @@
); );
}); });
// VNC WebSocket proxy ready — connect the viewer
window.runtime.EventsOn("session:vnc:ready", (payload) => {
window.NexusTerminal.VNCViewer.connect(
payload.sessionID,
payload.wsURL,
);
});
// App-wide commands from the native menu // App-wide commands from the native menu
window.runtime.EventsOn("app:open-about", () => { window.runtime.EventsOn("app:open-about", () => {
this.settings.openTab("about"); this.settings.openTab("about");
@@ -362,6 +381,7 @@
this.terminals.destroyTerminal(sessionId); this.terminals.destroyTerminal(sessionId);
this.rawViewer.destroyViewer(sessionId); this.rawViewer.destroyViewer(sessionId);
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId); window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
window.NexusTerminal.VNCViewer.destroyViewer(sessionId);
this.state.removeSession(sessionId); this.state.removeSession(sessionId);
this.tabs.removeTab(sessionId); this.tabs.removeTab(sessionId);
this.updateStatusBar(); this.updateStatusBar();
@@ -509,6 +529,7 @@
setupUI() { setupUI() {
var btnEl = document.getElementById("btn-new-connection"); var btnEl = document.getElementById("btn-new-connection");
this._bindClick("btn-new-connection", () => this.profileEditor.openNew()); this._bindClick("btn-new-connection", () => this.profileEditor.openNew());
this._bindClick("btn-command-palette", () => this.commandPalette.toggle());
this._bindClick("btn-settings", () => this.settings.open()); this._bindClick("btn-settings", () => this.settings.open());
this._bindClick("btn-import", () => this.importProfiles()); this._bindClick("btn-import", () => this.importProfiles());
this._bindClick("btn-export", () => this.exportProfiles()); this._bindClick("btn-export", () => this.exportProfiles());
@@ -763,6 +784,15 @@
profile.displayName || profile.host, profile.displayName || profile.host,
); );
} }
} else if (profile.protocol === "vnc") {
const container = document.getElementById(panelId);
if (container) {
window.NexusTerminal.VNCViewer.createViewer(
sessionInfo.id,
container,
profile,
);
}
} else { } else {
this.terminals.createTerminal(sessionInfo.id, panelId, profile); this.terminals.createTerminal(sessionInfo.id, panelId, profile);
} }
+331 -294
View File
@@ -1,27 +1,27 @@
(function() { (function () {
/** /**
* Nexus Terminal — command palette (Ctrl+K). * Nexus Terminal — command palette (Ctrl+K).
* Provides a searchable overlay for quick access to application commands, * Provides a searchable overlay for quick access to application commands,
* profile connections, and utility actions. * profile connections, and utility actions.
*/ */
const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal; const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal;
class CommandPalette { class CommandPalette {
/** /**
* @param {App} app * @param {App} app
*/ */
constructor(app) { constructor(app) {
this.app = app; this.app = app;
/** @type {HTMLElement|null} */ /** @type {HTMLElement|null} */
this._overlay = null; this._overlay = null;
/** @type {string} Current filter query. */ /** @type {string} Current filter query. */
this._query = ''; this._query = "";
/** @type {number} Index of the highlighted command. */ /** @type {number} Index of the highlighted command. */
this._selectedIndex = 0; this._selectedIndex = 0;
} }
// ── Public API ─────────────────────────────────────────────────── // ── Public API ───────────────────────────────────────────────────
@@ -30,31 +30,31 @@ class CommandPalette {
* Toggles the command palette open/closed. * Toggles the command palette open/closed.
*/ */
toggle() { toggle() {
if (this._overlay) { if (this._overlay) {
this.close(); this.close();
} else { } else {
this.open(); this.open();
} }
} }
/** /**
* Opens the command palette. * Opens the command palette.
*/ */
open() { open() {
if (this._overlay) return; if (this._overlay) return;
this._query = ''; this._query = "";
this._selectedIndex = 0; this._selectedIndex = 0;
this._render(); this._render();
} }
/** /**
* Closes the command palette. * Closes the command palette.
*/ */
close() { close() {
if (this._overlay) { if (this._overlay) {
this._overlay.remove(); this._overlay.remove();
this._overlay = null; this._overlay = null;
} }
} }
// ── Command definitions ────────────────────────────────────────── // ── Command definitions ──────────────────────────────────────────
@@ -64,165 +64,184 @@ class CommandPalette {
* @returns {Array<{id: string, label: string, shortcut?: string, section?: string, action: Function}>} * @returns {Array<{id: string, label: string, shortcut?: string, section?: string, action: Function}>}
*/ */
getCommands() { getCommands() {
const cmds = []; const cmds = [];
// Profile connections // Profile connections
const profiles = this.app.state.profiles || []; const profiles = this.app.state.profiles || [];
for (const p of profiles) { for (const p of profiles) {
cmds.push({ cmds.push({
id: `connect-${p.id}`, id: `connect-${p.id}`,
label: `Connect to ${p.displayName || p.host}`, label: `Connect to ${p.displayName || p.host}`,
section: 'Connections', section: "Connections",
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`, detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
action: () => this.app.connectProfile(p.id), action: () => this.app.connectProfile(p.id),
}); });
} }
// New connections // New connections
cmds.push({ cmds.push({
id: 'new-ssh', id: "new-ssh",
label: 'New SSH Connection', label: "New SSH Connection",
section: 'Actions', section: "Actions",
shortcut: '⌘N', shortcut: "⌘N",
action: () => this.app.profileEditor.openNew('ssh'), action: () => this.app.profileEditor.openNew("ssh"),
}); });
cmds.push({ cmds.push({
id: 'new-telnet', id: "new-telnet",
label: 'New Telnet Connection', label: "New Telnet Connection",
section: 'Actions', section: "Actions",
action: () => this.app.profileEditor.openNew('telnet'), action: () => this.app.profileEditor.openNew("telnet"),
}); });
cmds.push({ cmds.push({
id: 'new-raw', id: "new-raw",
label: 'New RAW Connection', label: "New RAW Connection",
section: 'Actions', section: "Actions",
action: () => this.app.profileEditor.openNew('raw'), action: () => this.app.profileEditor.openNew("raw"),
}); });
// Quick connect // Quick connect
cmds.push({ cmds.push({
id: 'quick-connect', id: "quick-connect",
label: 'Quick Connect…', label: "Quick Connect…",
section: 'Actions', section: "Actions",
action: () => this.app.quickConnect(), action: () => this.app.quickConnect(),
}); });
// Tab actions // Tab actions
cmds.push({ cmds.push({
id: 'close-tab', id: "close-tab",
label: 'Close Tab', label: "Close Tab",
section: 'Tabs', section: "Tabs",
shortcut: '⌘W', shortcut: "⌘W",
action: () => this.app.tabs.closeActive(), action: () => this.app.tabs.closeActive(),
}); });
cmds.push({ cmds.push({
id: 'next-tab', id: "next-tab",
label: 'Next Tab', label: "Next Tab",
section: 'Tabs', section: "Tabs",
shortcut: '⌘Tab', shortcut: "⌘Tab",
action: () => { action: () => {
const next = this.app.tabs.getNextTab(); const next = this.app.tabs.getNextTab();
if (next) this.app.tabs.setActive(next); if (next) this.app.tabs.setActive(next);
}, },
}); });
cmds.push({ cmds.push({
id: 'prev-tab', id: "prev-tab",
label: 'Previous Tab', label: "Previous Tab",
section: 'Tabs', section: "Tabs",
shortcut: '⌘⇧Tab', shortcut: "⌘⇧Tab",
action: () => { action: () => {
const prev = this.app.tabs.getPrevTab(); const prev = this.app.tabs.getPrevTab();
if (prev) this.app.tabs.setActive(prev); if (prev) this.app.tabs.setActive(prev);
}, },
}); });
// Settings & utility // Settings & utility
cmds.push({
id: "settings",
label: "Settings",
section: "Application",
action: () => this.app.settings.open(),
});
cmds.push({
id: "import-profiles",
label: "Import Profiles",
section: "Application",
action: () => this.app.importProfiles(),
});
cmds.push({
id: "export-profiles",
label: "Export Profiles",
section: "Application",
action: () => this.app.exportProfiles(),
});
// Secret store
if (this.app.state.secretStoreUnlocked) {
cmds.push({ cmds.push({
id: 'settings', id: "lock-secrets",
label: 'Settings', label: "Lock Secret Store",
section: 'Application', section: "Security",
action: () => this.app.settings.open(), action: async () => {
try {
await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info("Secret store locked");
} catch (err) {
this.app.notifications.error("Failed to lock: " + err);
}
},
});
} else {
cmds.push({
id: "unlock-secrets",
label: "Unlock Secret Store",
section: "Security",
action: async () => {
try {
const pw =
await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success("Secret store unlocked");
} catch (err) {
if (err.message !== "User cancelled") {
this.app.notifications.error("Unlock failed: " + err);
}
}
},
});
}
// SSH Key Manager
cmds.push({
id: "ssh-keys",
label: "SSH Key Manager",
section: "Security",
action: () => this.app.sshKeys.open(),
});
// Trigger Rules
cmds.push({
id: "trigger-rules",
label: "Trigger Rules",
section: "Terminal",
action: () => this.app.triggers.open(),
});
// Terminal actions (only when a session is active)
if (this.app.state.activeSessionId) {
cmds.push({
id: "split-vertical",
label: "Split Pane Right",
section: "Terminal",
shortcut: "⌘⇧D",
action: () => this.app.splitPane("vertical"),
}); });
cmds.push({ cmds.push({
id: 'import-profiles', id: "split-horizontal",
label: 'Import Profiles', label: "Split Pane Down",
section: 'Application', section: "Terminal",
action: () => this.app.importProfiles(), shortcut: "⌘⇧E",
action: () => this.app.splitPane("horizontal"),
}); });
cmds.push({ cmds.push({
id: 'export-profiles', id: "clear-terminal",
label: 'Export Profiles', label: "Clear Terminal",
section: 'Application', section: "Terminal",
action: () => this.app.exportProfiles(), action: () =>
this.app.terminals.clearTerminal(this.app.state.activeSessionId),
}); });
cmds.push({
id: "reset-terminal",
label: "Reset Terminal",
section: "Terminal",
action: () =>
this.app.terminals.resetTerminal(this.app.state.activeSessionId),
});
}
// Secret store return cmds;
if (this.app.state.secretStoreUnlocked) {
cmds.push({
id: 'lock-secrets',
label: 'Lock Secret Store',
section: 'Security',
action: async () => {
try {
await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info('Secret store locked');
} catch (err) {
this.app.notifications.error('Failed to lock: ' + err);
}
},
});
} else {
cmds.push({
id: 'unlock-secrets',
label: 'Unlock Secret Store',
section: 'Security',
action: async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success('Secret store unlocked');
} catch (err) {
if (err.message !== 'User cancelled') {
this.app.notifications.error('Unlock failed: ' + err);
}
}
},
});
}
// Terminal actions (only when a session is active)
if (this.app.state.activeSessionId) {
cmds.push({
id: 'split-vertical',
label: 'Split Pane Right',
section: 'Terminal',
shortcut: '⌘⇧D',
action: () => this.app.splitPane('vertical'),
});
cmds.push({
id: 'split-horizontal',
label: 'Split Pane Down',
section: 'Terminal',
shortcut: '⌘⇧E',
action: () => this.app.splitPane('horizontal'),
});
cmds.push({
id: 'clear-terminal',
label: 'Clear Terminal',
section: 'Terminal',
action: () => this.app.terminals.clearTerminal(this.app.state.activeSessionId),
});
cmds.push({
id: 'reset-terminal',
label: 'Reset Terminal',
section: 'Terminal',
action: () => this.app.terminals.resetTerminal(this.app.state.activeSessionId),
});
}
return cmds;
} }
/** /**
@@ -231,14 +250,17 @@ class CommandPalette {
* @returns {Array<Object>} * @returns {Array<Object>}
*/ */
filter(query) { filter(query) {
if (!query) return this.getCommands(); if (!query) return this.getCommands();
return this.getCommands() return this.getCommands()
.map((cmd) => { .map((cmd) => {
const result = fuzzyMatch(cmd.label + ' ' + (cmd.detail || ''), query); const result = fuzzyMatch(
return { ...cmd, ...result }; cmd.label + " " + (cmd.detail || ""),
}) query,
.filter((c) => c.match) );
.sort((a, b) => b.score - a.score); return { ...cmd, ...result };
})
.filter((c) => c.match)
.sort((a, b) => b.score - a.score);
} }
/** /**
@@ -246,82 +268,85 @@ class CommandPalette {
* @param {Object} command * @param {Object} command
*/ */
execute(command) { execute(command) {
this.close(); this.close();
if (command && typeof command.action === 'function') { if (command && typeof command.action === "function") {
command.action(); command.action();
} }
} }
// ── Rendering ──────────────────────────────────────────────────── // ── Rendering ────────────────────────────────────────────────────
/** @private */ /** @private */
_render() { _render() {
const overlay = createElement('div', 'palette-overlay'); const overlay = createElement("div", "palette-overlay");
const palette = createElement('div', 'palette'); const palette = createElement("div", "palette");
// Search input // Search input
const input = createElement('input', 'palette-input', { const input = createElement("input", "palette-input", {
type: 'text', type: "text",
placeholder: 'Type a command…', placeholder: "Type a command…",
autocomplete: 'off', autocomplete: "off",
spellcheck: 'false', spellcheck: "false",
}); });
palette.appendChild(input); palette.appendChild(input);
// Results list // Results list
const list = createElement('div', 'palette-list'); const list = createElement("div", "palette-list");
palette.appendChild(list); palette.appendChild(list);
overlay.appendChild(palette); overlay.appendChild(palette);
document.body.appendChild(overlay); document.body.appendChild(overlay);
this._overlay = overlay; this._overlay = overlay;
// Initial render // Initial render
this._renderList(list, this.filter('')); this._renderList(list, this.filter(""));
// Focus the input // Focus the input
input.focus(); input.focus();
// Event handlers // Event handlers
input.addEventListener('input', () => { input.addEventListener("input", () => {
this._query = input.value; this._query = input.value;
this._selectedIndex = 0; this._selectedIndex = 0;
this._renderList(list, this.filter(this._query)); this._renderList(list, this.filter(this._query));
}); });
input.addEventListener('keydown', (e) => { input.addEventListener("keydown", (e) => {
const items = list.querySelectorAll('.palette-item'); const items = list.querySelectorAll(".palette-item");
switch (e.key) { switch (e.key) {
case 'ArrowDown': case "ArrowDown":
e.preventDefault(); e.preventDefault();
this._selectedIndex = Math.min(this._selectedIndex + 1, items.length - 1); this._selectedIndex = Math.min(
this._highlightItem(items); this._selectedIndex + 1,
break; items.length - 1,
case 'ArrowUp': );
e.preventDefault(); this._highlightItem(items);
this._selectedIndex = Math.max(this._selectedIndex - 1, 0); break;
this._highlightItem(items); case "ArrowUp":
break; e.preventDefault();
case 'Enter': { this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
e.preventDefault(); this._highlightItem(items);
const selected = items[this._selectedIndex]; break;
if (selected && selected._command) { case "Enter": {
this.execute(selected._command); e.preventDefault();
} const selected = items[this._selectedIndex];
break; if (selected && selected._command) {
} this.execute(selected._command);
case 'Escape':
e.preventDefault();
this.close();
break;
} }
}); break;
}
case "Escape":
e.preventDefault();
this.close();
break;
}
});
// Click outside to close // Click outside to close
overlay.addEventListener('click', (e) => { overlay.addEventListener("click", (e) => {
if (e.target === overlay) this.close(); if (e.target === overlay) this.close();
}); });
} }
/** /**
@@ -331,52 +356,65 @@ class CommandPalette {
* @private * @private
*/ */
_renderList(list, commands) { _renderList(list, commands) {
list.innerHTML = ''; list.innerHTML = "";
let currentSection = null; let currentSection = null;
if (commands.length === 0) { if (commands.length === 0) {
const empty = createElement('div', 'palette-empty', { textContent: 'No commands found' }); const empty = createElement("div", "palette-empty", {
list.appendChild(empty); textContent: "No commands found",
return; });
list.appendChild(empty);
return;
}
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
// Section header
if (cmd.section && cmd.section !== currentSection) {
currentSection = cmd.section;
const sectionEl = createElement("div", "palette-section", {
textContent: cmd.section,
});
list.appendChild(sectionEl);
} }
for (let i = 0; i < commands.length; i++) { const item = createElement(
const cmd = commands[i]; "div",
`palette-item ${i === this._selectedIndex ? "palette-item-active" : ""}`,
);
item._command = cmd;
// Section header // Label
if (cmd.section && cmd.section !== currentSection) { const label = createElement("span", "palette-item-label", {
currentSection = cmd.section; textContent: cmd.label,
const sectionEl = createElement('div', 'palette-section', { textContent: cmd.section }); });
list.appendChild(sectionEl); item.appendChild(label);
}
const item = createElement('div', `palette-item ${i === this._selectedIndex ? 'palette-item-active' : ''}`); // Detail
item._command = cmd; if (cmd.detail) {
const detail = createElement("span", "palette-item-detail", {
// Label textContent: cmd.detail,
const label = createElement('span', 'palette-item-label', { textContent: cmd.label }); });
item.appendChild(label); item.appendChild(detail);
// Detail
if (cmd.detail) {
const detail = createElement('span', 'palette-item-detail', { textContent: cmd.detail });
item.appendChild(detail);
}
// Shortcut badge
if (cmd.shortcut) {
const badge = createElement('span', 'palette-item-shortcut', { textContent: cmd.shortcut });
item.appendChild(badge);
}
item.addEventListener('click', () => this.execute(cmd));
item.addEventListener('mouseenter', () => {
this._selectedIndex = i;
this._highlightItem(list.querySelectorAll('.palette-item'));
});
list.appendChild(item);
} }
// Shortcut badge
if (cmd.shortcut) {
const badge = createElement("span", "palette-item-shortcut", {
textContent: cmd.shortcut,
});
item.appendChild(badge);
}
item.addEventListener("click", () => this.execute(cmd));
item.addEventListener("mouseenter", () => {
this._selectedIndex = i;
this._highlightItem(list.querySelectorAll(".palette-item"));
});
list.appendChild(item);
}
} }
/** /**
@@ -385,17 +423,16 @@ class CommandPalette {
* @private * @private
*/ */
_highlightItem(items) { _highlightItem(items) {
items.forEach((el, i) => { items.forEach((el, i) => {
el.classList.toggle('palette-item-active', i === this._selectedIndex); el.classList.toggle("palette-item-active", i === this._selectedIndex);
}); });
const active = items[this._selectedIndex]; const active = items[this._selectedIndex];
if (active) { if (active) {
active.scrollIntoView({ block: 'nearest' }); active.scrollIntoView({ block: "nearest" });
} }
} }
} }
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.CommandPalette = CommandPalette;
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.CommandPalette = CommandPalette;
})(); })();
+181 -3
View File
@@ -65,6 +65,27 @@
refreshIntervalSecs: 300, refreshIntervalSecs: 300,
}, },
}, },
vnc: {
port: 5900,
protocol: "vnc",
vnc: {
vncHost: "",
vncPort: 5900,
secretMode: "prompt",
viewOnly: false,
scaleToFit: true,
quality: 6,
sharedSession: true,
sshTunnel: false,
sshHost: "",
sshPort: 22,
sshUsername: "",
sshAuthMethod: "publickey",
sshSecretMode: "saved",
sshPrivateKey: "",
sshAgentAuth: false,
},
},
}; };
class ProfileEditor { class ProfileEditor {
@@ -141,7 +162,7 @@
drawer.appendChild(header); drawer.appendChild(header);
// ── Protocol selector tabs ────────────────────────────── // ── Protocol selector tabs ──────────────────────────────
const protocols = ["ssh", "telnet", "raw", "github"]; const protocols = ["ssh", "telnet", "raw", "github", "vnc"];
const tabBar = createElement("div", "drawer-protocol-tabs"); const tabBar = createElement("div", "drawer-protocol-tabs");
for (const proto of protocols) { for (const proto of protocols) {
const label = proto === "github" ? "GitHub" : proto.toUpperCase(); const label = proto === "github" ? "GitHub" : proto.toUpperCase();
@@ -184,10 +205,15 @@
body.appendChild(s), body.appendChild(s),
); );
break; break;
case "vnc":
this._renderVNCSections(profile).forEach((s) =>
body.appendChild(s),
);
break;
} }
// Terminal section (SSH and Telnet only) // Terminal section (SSH and Telnet only)
if (profile.protocol !== "raw" && profile.protocol !== "github") { if (profile.protocol !== "raw" && profile.protocol !== "github" && profile.protocol !== "vnc") {
body.appendChild(this._renderTerminalSection(profile)); body.appendChild(this._renderTerminalSection(profile));
} }
@@ -922,6 +948,131 @@
return sections; return sections;
} }
/** @private */
_renderVNCSections(profile) {
const sections = [];
const vnc = profile.vnc || DEFAULTS.vnc.vnc;
// VNC connection settings
sections.push(
this._renderSection("VNC Server", [
this._field("text", "vnc.vncHost", "VNC Host", vnc.vncHost, {
placeholder: "192.168.1.100",
}),
this._field("number", "vnc.vncPort", "VNC Port", vnc.vncPort, {
min: 1,
max: 65535,
}),
this._field(
"select",
"vnc.secretMode",
"Password Storage",
vnc.secretMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "prompt", label: "Ask every time" },
],
},
),
]),
);
// Display settings
sections.push(
this._renderSection("Display", [
this._field("checkbox", "vnc.viewOnly", "View Only", vnc.viewOnly),
this._field(
"checkbox",
"vnc.scaleToFit",
"Scale to Fit",
vnc.scaleToFit,
),
this._field(
"checkbox",
"vnc.sharedSession",
"Shared Session",
vnc.sharedSession,
),
this._field("number", "vnc.quality", "Quality (1-9)", vnc.quality, {
min: 1,
max: 9,
}),
]),
);
// SSH tunnel section
sections.push(
this._renderSection("SSH Tunnel", [
this._field(
"checkbox",
"vnc.sshTunnel",
"Enable SSH Tunnel",
vnc.sshTunnel,
),
this._field("text", "vnc.sshHost", "SSH Host", vnc.sshHost, {
placeholder: "jump.example.com",
}),
this._field("number", "vnc.sshPort", "SSH Port", vnc.sshPort, {
min: 1,
max: 65535,
}),
this._field(
"text",
"vnc.sshUsername",
"SSH Username",
vnc.sshUsername,
{
placeholder: "admin",
},
),
this._field(
"select",
"vnc.sshAuthMethod",
"SSH Auth Method",
vnc.sshAuthMethod,
{
options: [
{ value: "publickey", label: "Public Key" },
{ value: "password", label: "Password" },
],
},
),
this._field(
"text",
"vnc.sshPrivateKey",
"SSH Private Key Path",
vnc.sshPrivateKey,
{
placeholder: "~/.ssh/id_ed25519",
},
),
this._field(
"checkbox",
"vnc.sshAgentAuth",
"Use SSH Agent",
vnc.sshAgentAuth,
),
this._field(
"select",
"vnc.sshSecretMode",
"SSH Secret Storage",
vnc.sshSecretMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "prompt", label: "Ask every time" },
],
},
),
]),
);
return sections;
}
/** @private */ /** @private */
_renderTerminalSection(profile) { _renderTerminalSection(profile) {
return this._renderSection("Terminal", [ return this._renderSection("Terminal", [
@@ -2102,6 +2253,26 @@
p.github.secretMode = val("github.secretMode") || "saved"; p.github.secretMode = val("github.secretMode") || "saved";
p.github.refreshIntervalSecs = val("github.refreshIntervalSecs") || 300; p.github.refreshIntervalSecs = val("github.refreshIntervalSecs") || 300;
} }
// VNC-specific
if (p.protocol === "vnc") {
if (!p.vnc) p.vnc = {};
p.vnc.vncHost = val("vnc.vncHost") || "";
p.vnc.vncPort = val("vnc.vncPort") || 5900;
p.vnc.secretMode = val("vnc.secretMode") || "prompt";
p.vnc.viewOnly = val("vnc.viewOnly") || false;
p.vnc.scaleToFit = val("vnc.scaleToFit") !== false;
p.vnc.quality = val("vnc.quality") || 6;
p.vnc.sharedSession = val("vnc.sharedSession") !== false;
p.vnc.sshTunnel = val("vnc.sshTunnel") || false;
p.vnc.sshHost = val("vnc.sshHost") || "";
p.vnc.sshPort = val("vnc.sshPort") || 22;
p.vnc.sshUsername = val("vnc.sshUsername") || "";
p.vnc.sshAuthMethod = val("vnc.sshAuthMethod") || "publickey";
p.vnc.sshPrivateKey = val("vnc.sshPrivateKey") || "";
p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false;
p.vnc.sshSecretMode = val("vnc.sshSecretMode") || "saved";
}
} }
// ── Validation ─────────────────────────────────────────────────── // ── Validation ───────────────────────────────────────────────────
@@ -2114,7 +2285,12 @@
*/ */
_validate(profile) { _validate(profile) {
const errors = []; const errors = [];
if (!profile.host || !profile.host.trim()) { if (profile.protocol === "vnc") {
const vncHost = (profile.vnc && profile.vnc.vncHost) || profile.host;
if (!vncHost || !vncHost.trim()) {
errors.push("VNC Host is required (in General or VNC Server section).");
}
} else if (!profile.host || !profile.host.trim()) {
errors.push("Host is required."); errors.push("Host is required.");
} }
if (!profile.port || profile.port < 1 || profile.port > 65535) { if (!profile.port || profile.port < 1 || profile.port > 65535) {
@@ -2283,6 +2459,8 @@
ssh: null, ssh: null,
telnet: null, telnet: null,
raw: null, raw: null,
github: null,
vnc: null,
}; };
const defaults = DEFAULTS[protocol] || {}; const defaults = DEFAULTS[protocol] || {};
+281
View File
@@ -0,0 +1,281 @@
(function () {
/**
* Nexus Terminal — SSH Key Manager.
* Panel for listing, generating, copying, and deleting SSH key pairs.
*/
const { createElement, dismissContextMenus } = window.NexusTerminal;
class SSHKeyManager {
constructor(app) {
this.app = app;
this._backdrop = null;
}
// ── Public API ───────────────────────────────────────────────────
open() {
if (this._backdrop) return;
this._render();
this._loadKeys();
}
close() {
if (this._backdrop) {
this._backdrop.classList.remove("is-visible");
const modal = this._backdrop.querySelector(".modal");
if (modal) modal.classList.remove("is-visible");
setTimeout(() => {
if (this._backdrop) {
this._backdrop.remove();
this._backdrop = null;
}
}, 200);
}
}
// ── Rendering ────────────────────────────────────────────────────
_render() {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">SSH Key Manager</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="sshkey-toolbar">
<button class="btn btn-sm btn-accent sshkey-generate-btn">Generate New Key</button>
</div>
<div class="sshkey-list"></div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
this._backdrop = backdrop;
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
// Events
modal.querySelector(".modal-close").addEventListener("click", () => this.close());
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) this.close();
});
modal.querySelector(".sshkey-generate-btn").addEventListener("click", () => this._showGenerateForm());
}
async _loadKeys() {
const list = this._backdrop.querySelector(".sshkey-list");
if (!list) return;
list.innerHTML = '<div class="sshkey-loading">Loading keys…</div>';
try {
const keys = await window.go.api.Service.ListSSHKeyPairs();
if (keys.length === 0) {
list.innerHTML = '<div class="sshkey-empty">No SSH keys found in ~/.ssh/</div>';
return;
}
list.innerHTML = "";
for (const key of keys) {
list.appendChild(this._renderKeyRow(key));
}
} catch (err) {
list.innerHTML = `<div class="sshkey-error">Failed to load keys: ${err}</div>`;
}
}
_renderKeyRow(key) {
const row = createElement("div", "sshkey-row");
const info = createElement("div", "sshkey-info");
const name = createElement("div", "sshkey-name", { textContent: key.name });
const meta = createElement("div", "sshkey-meta", {
textContent: key.type.toUpperCase() + (key.bits ? ` ${key.bits}` : ""),
});
info.appendChild(name);
info.appendChild(meta);
const actions = createElement("div", "sshkey-actions");
const copyBtn = createElement("button", "btn btn-xs", { textContent: "Copy Public Key" });
copyBtn.addEventListener("mousedown", (e) => e.preventDefault());
copyBtn.addEventListener("click", () => this._copyPublicKey(key));
const deleteBtn = createElement("button", "btn btn-xs btn-danger", { textContent: "Delete" });
deleteBtn.addEventListener("mousedown", (e) => e.preventDefault());
deleteBtn.addEventListener("click", () => this._confirmDelete(key));
actions.appendChild(copyBtn);
actions.appendChild(deleteBtn);
row.appendChild(info);
row.appendChild(actions);
return row;
}
async _copyPublicKey(key) {
try {
const pubKey = await window.go.api.Service.GetSSHPublicKey(key.name);
if (window.runtime && window.runtime.ClipboardSetText) {
window.runtime.ClipboardSetText(pubKey);
} else {
await navigator.clipboard.writeText(pubKey);
}
this.app.notifications.success("Public key copied to clipboard");
} catch (err) {
this.app.notifications.error("Failed to copy: " + err);
}
}
_confirmDelete(key) {
const confirmed = this._showConfirm(
"Delete Key",
`Delete key pair "${key.name}"? This cannot be undone.`,
"Delete",
);
confirmed.then((ok) => {
if (ok) this._deleteKey(key);
});
}
async _deleteKey(key) {
try {
await window.go.api.Service.DeleteSSHKey(key.name);
this.app.notifications.success(`Key "${key.name}" deleted`);
this._loadKeys();
} catch (err) {
this.app.notifications.error("Delete failed: " + err);
}
}
// ── Generate form ────────────────────────────────────────────────
_showGenerateForm() {
const list = this._backdrop.querySelector(".sshkey-list");
if (!list) return;
const form = createElement("div", "sshkey-generate-form");
form.innerHTML = `
<div class="form-group">
<label class="form-label">Key Name</label>
<input type="text" class="form-input sshkey-name-input" placeholder="id_ed25519_nexus" autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label class="form-label">Type</label>
<select class="form-input sshkey-type-select">
<option value="ed25519" selected>Ed25519 (recommended)</option>
<option value="rsa">RSA</option>
<option value="ecdsa">ECDSA</option>
</select>
</div>
<div class="form-group sshkey-bits-group" style="display:none">
<label class="form-label">Bits</label>
<select class="form-input sshkey-bits-select">
<option value="4096" selected>4096</option>
<option value="2048">2048</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Passphrase (optional)</label>
<input type="password" class="form-input sshkey-pass-input" placeholder="Leave blank for no passphrase" autocomplete="off" />
</div>
<div class="sshkey-form-actions">
<button class="btn btn-sm sshkey-cancel-btn">Cancel</button>
<button class="btn btn-sm btn-accent sshkey-create-btn">Generate</button>
</div>
`;
// Insert form above the key list
list.parentNode.insertBefore(form, list);
list.style.display = "none";
// Show bits field for RSA
const typeSelect = form.querySelector(".sshkey-type-select");
const bitsGroup = form.querySelector(".sshkey-bits-group");
typeSelect.addEventListener("change", () => {
bitsGroup.style.display = typeSelect.value === "rsa" ? "" : "none";
});
form.querySelector(".sshkey-cancel-btn").addEventListener("click", () => {
form.remove();
list.style.display = "";
});
form.querySelector(".sshkey-create-btn").addEventListener("click", async () => {
const nameInput = form.querySelector(".sshkey-name-input");
const name = nameInput.value.trim();
const keyType = typeSelect.value;
const bits = parseInt(form.querySelector(".sshkey-bits-select").value, 10);
const passphrase = form.querySelector(".sshkey-pass-input").value;
if (!name) {
this.app.notifications.error("Key name is required");
return;
}
try {
await window.go.api.Service.GenerateSSHKey(name, keyType, bits, passphrase);
this.app.notifications.success(`Key "${name}" generated successfully`);
form.remove();
list.style.display = "";
this._loadKeys();
} catch (err) {
this.app.notifications.error("Generation failed: " + err);
}
});
form.querySelector(".sshkey-name-input").focus();
}
// ── Confirmation helper ──────────────────────────────────────────
_showConfirm(title, message, confirmLabel) {
return new Promise((resolve) => {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">${title}</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-sm);color:var(--text-secondary)">${message}</p>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-danger confirm-ok">${confirmLabel}</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const close = (val) => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
resolve(val);
};
modal.querySelector(".modal-close").addEventListener("click", () => close(false));
modal.querySelector(".confirm-cancel").addEventListener("click", () => close(false));
modal.querySelector(".confirm-ok").addEventListener("click", () => close(true));
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close(false);
});
});
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SSHKeyManager = SSHKeyManager;
})();
+23 -1
View File
@@ -100,9 +100,12 @@
setActive(sessionId) { setActive(sessionId) {
this.app.state.setActiveSession(sessionId); this.app.state.setActiveSession(sessionId);
// Highlight active tab // Highlight active tab and clear activity indicator
this.tabList.querySelectorAll(".tab").forEach((t) => { this.tabList.querySelectorAll(".tab").forEach((t) => {
t.classList.toggle("tab-active", t.dataset.sessionId === sessionId); t.classList.toggle("tab-active", t.dataset.sessionId === sessionId);
if (t.dataset.sessionId === sessionId) {
t.classList.remove("tab-has-activity");
}
}); });
// Show corresponding panel, hide others // Show corresponding panel, hide others
@@ -425,6 +428,21 @@
return [...this.order]; return [...this.order];
} }
/**
* Marks a background tab as having new activity.
* Does nothing if the session is currently active.
* @param {string} sessionId
*/
markActivity(sessionId) {
if (sessionId === this.app.state.activeSessionId) return;
const tabEl = this.tabList.querySelector(
`[data-session-id="${sessionId}"]`,
);
if (tabEl && !tabEl.classList.contains("tab-has-activity")) {
tabEl.classList.add("tab-has-activity");
}
}
/** /**
* Returns the session ID of the next tab (wrapping round). * Returns the session ID of the next tab (wrapping round).
* @returns {string|null} * @returns {string|null}
@@ -488,6 +506,10 @@
); );
tab.appendChild(status); tab.appendChild(status);
// Activity indicator (hidden by default, shown when background tab has output)
const activity = createElement("span", "tab-activity");
tab.appendChild(activity);
// Pin indicator // Pin indicator
if (sessionInfo.pinned) { if (sessionInfo.pinned) {
tab.classList.add("tab-pinned"); tab.classList.add("tab-pinned");
+84
View File
@@ -233,6 +233,90 @@ const THEMES = {
brightWhite: '#c0caf5', brightWhite: '#c0caf5',
}, },
}, },
'catppuccin-mocha': {
label: 'Catppuccin Mocha',
theme: {
background: '#1e1e2e',
foreground: '#cdd6f4',
cursor: '#f5e0dc',
cursorAccent: '#1e1e2e',
selectionBackground: 'rgba(88, 91, 112, 0.5)',
selectionForeground: '#cdd6f4',
black: '#45475a',
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#f5c2e7',
cyan: '#94e2d5',
white: '#bac2de',
brightBlack: '#585b70',
brightRed: '#f38ba8',
brightGreen: '#a6e3a1',
brightYellow: '#f9e2af',
brightBlue: '#89b4fa',
brightMagenta: '#f5c2e7',
brightCyan: '#94e2d5',
brightWhite: '#a6adc8',
},
},
'rose-pine': {
label: 'Rosé Pine',
theme: {
background: '#191724',
foreground: '#e0def4',
cursor: '#524f67',
cursorAccent: '#e0def4',
selectionBackground: 'rgba(110, 106, 134, 0.4)',
selectionForeground: '#e0def4',
black: '#26233a',
red: '#eb6f92',
green: '#31748f',
yellow: '#f6c177',
blue: '#9ccfd8',
magenta: '#c4a7e7',
cyan: '#ebbcba',
white: '#e0def4',
brightBlack: '#6e6a86',
brightRed: '#eb6f92',
brightGreen: '#31748f',
brightYellow: '#f6c177',
brightBlue: '#9ccfd8',
brightMagenta: '#c4a7e7',
brightCyan: '#ebbcba',
brightWhite: '#e0def4',
},
},
'solarized-light': {
label: 'Solarized Light',
theme: {
background: '#fdf6e3',
foreground: '#657b83',
cursor: '#586e75',
cursorAccent: '#fdf6e3',
selectionBackground: 'rgba(7, 54, 66, 0.2)',
selectionForeground: '#073642',
black: '#073642',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#002b36',
brightRed: '#cb4b16',
brightGreen: '#586e75',
brightYellow: '#657b83',
brightBlue: '#839496',
brightMagenta: '#6c71c4',
brightCyan: '#93a1a1',
brightWhite: '#fdf6e3',
},
},
}; };
/** /**
+360
View File
@@ -0,0 +1,360 @@
(function () {
/**
* Nexus Terminal — Trigger Rules Engine.
* Monitors terminal output and fires actions (highlight, notify, sound)
* when patterns match. Rules are stored in localStorage.
*/
const { createElement } = window.NexusTerminal;
const STORAGE_KEY = "nexus:trigger-rules";
class TriggerEngine {
constructor(app) {
this.app = app;
this._rules = this._load();
this._backdrop = null;
// Buffer per session to accumulate partial lines
this._buffers = new Map();
}
// ── Public API ───────────────────────────────────────────────────
/**
* Checks incoming terminal data against all enabled rules.
* Called from the session:data event handler.
* @param {string} sessionId
* @param {string} text - decoded terminal text
*/
check(sessionId, text) {
if (this._rules.length === 0) return;
// Accumulate text and check line by line
const buf = (this._buffers.get(sessionId) || "") + text;
const lines = buf.split("\n");
// Keep the last incomplete line in the buffer
this._buffers.set(sessionId, lines.pop());
for (const line of lines) {
this._checkLine(sessionId, line);
}
}
/**
* Opens the trigger rules management panel.
*/
open() {
if (this._backdrop) return;
this._render();
}
/**
* Closes the trigger rules panel.
*/
close() {
if (this._backdrop) {
this._backdrop.classList.remove("is-visible");
const modal = this._backdrop.querySelector(".modal");
if (modal) modal.classList.remove("is-visible");
setTimeout(() => {
if (this._backdrop) {
this._backdrop.remove();
this._backdrop = null;
}
}, 200);
}
}
/**
* Cleans up session buffer on disconnect.
* @param {string} sessionId
*/
cleanup(sessionId) {
this._buffers.delete(sessionId);
}
// ── Rule matching ────────────────────────────────────────────────
_checkLine(sessionId, line) {
// Strip ANSI escape sequences for matching
const clean = line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
for (const rule of this._rules) {
if (!rule.enabled) continue;
let matched = false;
if (rule.isRegex) {
try {
const re = new RegExp(rule.pattern, rule.caseSensitive ? "" : "i");
matched = re.test(clean);
} catch (_) {
// Invalid regex — skip
}
} else {
const haystack = rule.caseSensitive ? clean : clean.toLowerCase();
const needle = rule.caseSensitive ? rule.pattern : rule.pattern.toLowerCase();
matched = haystack.includes(needle);
}
if (!matched) continue;
// Fire actions
if (rule.action === "notify" || rule.action === "both") {
const session = this.app.state.getSession(sessionId);
const label = session ? (session.tabName || session.profileName) : sessionId;
this.app.notifications.info(`[${label}] Trigger: ${rule.name || rule.pattern}`);
}
if (rule.action === "highlight" || rule.action === "both") {
// Flash the tab briefly
const tabEl = document.querySelector(`.tab[data-session-id="${sessionId}"]`);
if (tabEl) {
tabEl.classList.add("tab-trigger-flash");
setTimeout(() => tabEl.classList.remove("tab-trigger-flash"), 2000);
}
}
if (rule.action === "sound") {
this._playBeep();
}
}
}
_playBeep() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
gain.gain.value = 0.1;
osc.start();
osc.stop(ctx.currentTime + 0.15);
} catch (_) {
// Audio not available — silent
}
}
// ── Storage ──────────────────────────────────────────────────────
_load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (_) {
return [];
}
}
_save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._rules));
} catch (_) {
// localStorage full or unavailable
}
}
// ── UI ───────────────────────────────────────────────────────────
_render() {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Trigger Rules</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-xs);color:var(--text-tertiary);margin-bottom:var(--space-3)">
Rules match against terminal output and fire actions when a pattern is detected.
</p>
<div class="trigger-toolbar">
<button class="btn btn-sm btn-accent trigger-add-btn">Add Rule</button>
</div>
<div class="trigger-list"></div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
this._backdrop = backdrop;
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
modal.querySelector(".modal-close").addEventListener("click", () => this.close());
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) this.close();
});
modal.querySelector(".trigger-add-btn").addEventListener("click", () => this._addRule());
this._renderList();
}
_renderList() {
const list = this._backdrop.querySelector(".trigger-list");
if (!list) return;
list.innerHTML = "";
if (this._rules.length === 0) {
list.innerHTML = '<div class="trigger-empty">No trigger rules defined</div>';
return;
}
for (let i = 0; i < this._rules.length; i++) {
list.appendChild(this._renderRuleRow(this._rules[i], i));
}
}
_renderRuleRow(rule, index) {
const row = createElement("div", "trigger-row");
const toggle = createElement("input", "trigger-toggle");
toggle.type = "checkbox";
toggle.checked = rule.enabled;
toggle.addEventListener("change", () => {
this._rules[index].enabled = toggle.checked;
this._save();
});
const info = createElement("div", "trigger-info");
const name = createElement("div", "trigger-name", {
textContent: rule.name || rule.pattern,
});
const meta = createElement("div", "trigger-meta", {
textContent: `${rule.isRegex ? "Regex" : "Text"} · ${rule.action} · ${rule.caseSensitive ? "Case-sensitive" : "Case-insensitive"}`,
});
info.appendChild(name);
info.appendChild(meta);
const pattern = createElement("code", "trigger-pattern", {
textContent: rule.pattern,
});
const actions = createElement("div", "trigger-actions");
const editBtn = createElement("button", "btn btn-xs", { textContent: "Edit" });
editBtn.addEventListener("mousedown", (e) => e.preventDefault());
editBtn.addEventListener("click", () => this._editRule(index));
const delBtn = createElement("button", "btn btn-xs btn-danger", { textContent: "✕" });
delBtn.addEventListener("mousedown", (e) => e.preventDefault());
delBtn.addEventListener("click", () => {
this._rules.splice(index, 1);
this._save();
this._renderList();
});
actions.appendChild(editBtn);
actions.appendChild(delBtn);
row.appendChild(toggle);
row.appendChild(info);
row.appendChild(pattern);
row.appendChild(actions);
return row;
}
_addRule() {
this._showRuleForm(null, (rule) => {
this._rules.push(rule);
this._save();
this._renderList();
});
}
_editRule(index) {
this._showRuleForm(this._rules[index], (rule) => {
this._rules[index] = rule;
this._save();
this._renderList();
});
}
_showRuleForm(existing, onSave) {
const list = this._backdrop.querySelector(".trigger-list");
const toolbar = this._backdrop.querySelector(".trigger-toolbar");
if (!list) return;
// Hide list and toolbar
list.style.display = "none";
if (toolbar) toolbar.style.display = "none";
const form = createElement("div", "trigger-form");
form.innerHTML = `
<div class="form-group">
<label class="form-label">Name (optional)</label>
<input type="text" class="form-input trigger-name-input" placeholder="e.g. Error alert" value="${existing ? (existing.name || "") : ""}" />
</div>
<div class="form-group">
<label class="form-label">Pattern</label>
<input type="text" class="form-input trigger-pattern-input" placeholder="e.g. ERROR|FATAL|panic" value="${existing ? existing.pattern : ""}" />
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Match Type</label>
<select class="form-input trigger-type-select">
<option value="text"${existing && !existing.isRegex ? " selected" : ""}>Plain text</option>
<option value="regex"${existing && existing.isRegex ? " selected" : ""}>Regex</option>
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Action</label>
<select class="form-input trigger-action-select">
<option value="notify"${existing && existing.action === "notify" ? " selected" : ""}>Notify</option>
<option value="highlight"${existing && existing.action === "highlight" ? " selected" : ""}>Highlight tab</option>
<option value="both"${existing && existing.action === "both" ? " selected" : ""}>Notify + Highlight</option>
<option value="sound"${existing && existing.action === "sound" ? " selected" : ""}>Sound</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" class="trigger-case-check"${existing && existing.caseSensitive ? " checked" : ""} />
Case-sensitive
</label>
</div>
<div class="trigger-form-actions">
<button class="btn btn-sm trigger-cancel-btn">Cancel</button>
<button class="btn btn-sm btn-accent trigger-save-btn">${existing ? "Save" : "Add"}</button>
</div>
`;
list.parentNode.insertBefore(form, list);
form.querySelector(".trigger-cancel-btn").addEventListener("click", () => {
form.remove();
list.style.display = "";
if (toolbar) toolbar.style.display = "";
});
form.querySelector(".trigger-save-btn").addEventListener("click", () => {
const pattern = form.querySelector(".trigger-pattern-input").value.trim();
if (!pattern) {
this.app.notifications.error("Pattern is required");
return;
}
const rule = {
name: form.querySelector(".trigger-name-input").value.trim(),
pattern: pattern,
isRegex: form.querySelector(".trigger-type-select").value === "regex",
action: form.querySelector(".trigger-action-select").value,
caseSensitive: form.querySelector(".trigger-case-check").checked,
enabled: existing ? existing.enabled : true,
};
form.remove();
list.style.display = "";
if (toolbar) toolbar.style.display = "";
onSave(rule);
});
form.querySelector(".trigger-pattern-input").focus();
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.TriggerEngine = TriggerEngine;
})();
+1
View File
@@ -211,6 +211,7 @@ function protocolColour(protocol) {
case 'telnet': return 'proto-telnet'; case 'telnet': return 'proto-telnet';
case 'raw': return 'proto-raw'; case 'raw': return 'proto-raw';
case 'github': return 'proto-github'; case 'github': return 'proto-github';
case 'vnc': return 'proto-vnc';
default: return ''; default: return '';
} }
} }
+287
View File
@@ -0,0 +1,287 @@
(function () {
/**
* Nexus Terminal — VNC viewer.
* Renders a noVNC canvas inside a session panel, connecting to the local
* WebSocket proxy started by the Go backend.
*/
const { createElement, $ } = window.NexusTerminal;
class VNCViewer {
constructor() {
/** @type {Map<string, VNCViewerInstance>} sessionID → viewer */
this._viewers = new Map();
}
/**
* Creates a VNC viewer panel for a session inside the given container.
* @param {string} sessionID
* @param {HTMLElement} container - The .session-panel element.
* @param {object} profile - The profile object with VNC config.
*/
createViewer(sessionID, container, profile) {
if (this._viewers.has(sessionID)) return;
if (!container) return;
const viewer = new VNCViewerInstance(sessionID, container, profile);
this._viewers.set(sessionID, viewer);
}
/**
* Connects the viewer to the WebSocket URL from the backend.
* @param {string} sessionID
* @param {string} wsURL
*/
connect(sessionID, wsURL) {
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.connect(wsURL);
}
}
/**
* Destroys a viewer for the given session.
* @param {string} sessionID
*/
destroyViewer(sessionID) {
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.destroy();
this._viewers.delete(sessionID);
}
}
/**
* Resizes the VNC display for the given session.
* @param {string} sessionID
*/
resize(sessionID) {
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.resize();
}
}
}
class VNCViewerInstance {
/**
* @param {string} sessionID
* @param {HTMLElement} container
* @param {object} profile
*/
constructor(sessionID, container, profile) {
this.sessionID = sessionID;
this.container = container;
this.profile = profile;
this.rfb = null;
this._buildUI();
}
_buildUI() {
this.container.innerHTML = "";
this.container.classList.add("vnc-viewer-panel");
// Toolbar
this.toolbar = createElement("div", "vnc-toolbar");
const ctrlAltDel = createElement("button", "btn btn-sm btn-vnc-action", {
textContent: "Ctrl+Alt+Del",
title: "Send Ctrl+Alt+Del",
});
ctrlAltDel.addEventListener("click", () => this._sendCtrlAltDel());
const clipboardBtn = createElement("button", "btn btn-sm btn-vnc-action", {
textContent: "Clipboard",
title: "Sync clipboard",
});
clipboardBtn.addEventListener("click", () => this._syncClipboard());
const fullscreenBtn = createElement("button", "btn btn-sm btn-vnc-action", {
textContent: "Fullscreen",
title: "Toggle fullscreen",
});
fullscreenBtn.addEventListener("click", () => this._toggleFullscreen());
this.statusEl = createElement("span", "vnc-status", {
textContent: "Connecting…",
});
this.toolbar.appendChild(this.statusEl);
this.toolbar.appendChild(ctrlAltDel);
this.toolbar.appendChild(clipboardBtn);
this.toolbar.appendChild(fullscreenBtn);
// VNC display container
this.displayEl = createElement("div", "vnc-display");
this.container.appendChild(this.toolbar);
this.container.appendChild(this.displayEl);
}
/**
* Connects to the VNC WebSocket proxy.
* @param {string} wsURL - WebSocket URL (ws://127.0.0.1:<port>)
*/
connect(wsURL) {
if (!window.noVNC || !window.noVNC.RFB) {
this.statusEl.textContent = "Error: noVNC not loaded";
this.statusEl.classList.add("vnc-status-error");
return;
}
try {
const vncConfig = this.profile.vnc || {};
this.rfb = new window.noVNC.RFB(this.displayEl, wsURL, {
shared: vncConfig.sharedSession !== false,
credentials: {},
});
this.rfb.viewOnly = !!vncConfig.viewOnly;
this.rfb.scaleViewport = vncConfig.scaleToFit !== false;
this.rfb.resizeSession = false;
this.rfb.clipViewport = false;
this.rfb.qualityLevel = vncConfig.quality || 6;
this.rfb.compressionLevel = 2;
// Event handlers
this.rfb.addEventListener("connect", () => {
this.statusEl.textContent = "Connected";
this.statusEl.classList.remove("vnc-status-error");
this.statusEl.classList.add("vnc-status-connected");
});
this.rfb.addEventListener("disconnect", (ev) => {
const clean = ev.detail.clean;
this.statusEl.textContent = clean
? "Disconnected"
: "Connection lost";
this.statusEl.classList.remove("vnc-status-connected");
if (!clean) {
this.statusEl.classList.add("vnc-status-error");
}
});
this.rfb.addEventListener("credentialsrequired", () => {
this.statusEl.textContent = "Credentials required";
// TODO: prompt for VNC password via modal
this._promptCredentials();
});
this.rfb.addEventListener("desktopname", (ev) => {
this.statusEl.textContent = "Connected — " + ev.detail.name;
});
} catch (err) {
this.statusEl.textContent = "Error: " + err.message;
this.statusEl.classList.add("vnc-status-error");
}
}
_sendCtrlAltDel() {
if (this.rfb) {
this.rfb.sendCtrlAltDel();
}
}
async _syncClipboard() {
if (!this.rfb) return;
try {
const text = await navigator.clipboard.readText();
if (text) {
this.rfb.clipboardPasteFrom(text);
}
} catch (_) {
// Clipboard API may not be available in WKWebView
}
}
_toggleFullscreen() {
if (!document.fullscreenElement) {
this.container.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}
_promptCredentials() {
// Build a simple modal for VNC password
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">VNC Password</h3>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" class="form-input" id="vnc-password-input" autocomplete="off">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="vnc-password-cancel">Cancel</button>
<button class="btn btn-primary" id="vnc-password-submit">Connect</button>
</div>
`;
document.body.appendChild(backdrop);
document.body.appendChild(modal);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const input = modal.querySelector("#vnc-password-input");
const submit = modal.querySelector("#vnc-password-submit");
const cancel = modal.querySelector("#vnc-password-cancel");
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => {
backdrop.remove();
modal.remove();
}, 200);
};
submit.addEventListener("click", () => {
const pw = input.value;
close();
if (this.rfb && pw) {
this.rfb.sendCredentials({ password: pw });
}
});
cancel.addEventListener("click", () => {
close();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") submit.click();
if (e.key === "Escape") cancel.click();
});
setTimeout(() => input.focus(), 50);
}
resize() {
// noVNC handles resizing internally via ResizeObserver on its canvas,
// but we can trigger a re-scale if needed.
if (this.rfb && this.rfb.scaleViewport) {
this.rfb.scaleViewport = true;
}
}
destroy() {
if (this.rfb) {
this.rfb.disconnect();
this.rfb = null;
}
this.container.innerHTML = "";
this.container.classList.remove("vnc-viewer-panel");
}
}
// Export to global namespace.
window.NexusTerminal.VNCViewer = new VNCViewer();
})();
+204
View File
@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"net" "net"
"os" "os"
"os/exec"
"path" "path"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -912,6 +913,16 @@ func (s *Service) DownloadGitHubRepoArchive(sessionID string, fullName string, r
return path, nil return path, nil
} }
// ---------------------------------------------------------------------------
// VNC
// ---------------------------------------------------------------------------
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
// The frontend connects noVNC to this URL for display.
func (s *Service) GetVNCWebSocketURL(sessionID string) (string, error) {
return s.sessions.GetVNCWebSocketURL(sessionID)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Port forwarding // Port forwarding
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1457,3 +1468,196 @@ func derefProfiles(ptrs []*models.Profile) []models.Profile {
} }
return result return result
} }
// ---------------------------------------------------------------------------
// SSH Key Management
// ---------------------------------------------------------------------------
// SSHKeyInfo describes a key found in ~/.ssh/.
type SSHKeyInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Bits string `json:"bits"`
PublicKey string `json:"publicKey"`
PrivateKey string `json:"privateKeyPath"`
}
// ListSSHKeyPairs returns detailed info about SSH key pairs in ~/.ssh/.
func (s *Service) ListSSHKeyPairs() ([]SSHKeyInfo, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
entries, err := os.ReadDir(sshDir)
if err != nil {
if os.IsNotExist(err) {
return []SSHKeyInfo{}, nil
}
return nil, fmt.Errorf("unable to read .ssh directory: %w", err)
}
var keys []SSHKeyInfo
seen := make(map[string]bool)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Look for .pub files and match them with private keys
if !strings.HasSuffix(name, ".pub") {
continue
}
baseName := strings.TrimSuffix(name, ".pub")
privPath := filepath.Join(sshDir, baseName)
// Verify private key exists
if _, err := os.Stat(privPath); err != nil {
continue
}
if seen[baseName] {
continue
}
seen[baseName] = true
// Read public key content
pubBytes, err := os.ReadFile(filepath.Join(sshDir, name))
if err != nil {
continue
}
pubContent := strings.TrimSpace(string(pubBytes))
// Determine key type from public key content
keyType := "unknown"
parts := strings.Fields(pubContent)
if len(parts) >= 1 {
switch {
case strings.Contains(parts[0], "ed25519"):
keyType = "ed25519"
case strings.Contains(parts[0], "ecdsa"):
keyType = "ecdsa"
case strings.Contains(parts[0], "rsa"):
keyType = "rsa"
case strings.Contains(parts[0], "dsa"):
keyType = "dsa"
}
}
keys = append(keys, SSHKeyInfo{
Name: baseName,
Type: keyType,
PublicKey: pubContent,
PrivateKey: privPath,
})
}
if keys == nil {
keys = []SSHKeyInfo{}
}
return keys, nil
}
// GenerateSSHKey generates a new SSH key pair.
func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphrase string) error {
if name == "" {
return fmt.Errorf("key name is required")
}
// Sanitise name — only allow alphanumeric, hyphens, underscores
for _, ch := range name {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
return fmt.Errorf("key name contains invalid characters (use alphanumeric, hyphens, underscores)")
}
}
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
// Ensure .ssh directory exists
if err := os.MkdirAll(sshDir, 0700); err != nil {
return fmt.Errorf("unable to create .ssh directory: %w", err)
}
keyPath := filepath.Join(sshDir, name)
// Check if key already exists
if _, err := os.Stat(keyPath); err == nil {
return fmt.Errorf("key %q already exists", name)
}
if keyType == "" {
keyType = "ed25519"
}
// Build ssh-keygen arguments
args := []string{"-t", keyType, "-f", keyPath, "-C", name + "@nexus-terminal"}
if passphrase != "" {
args = append(args, "-N", passphrase)
} else {
args = append(args, "-N", "")
}
if keyType == "rsa" && bits > 0 {
args = append(args, "-b", fmt.Sprintf("%d", bits))
}
// #nosec G204 — arguments are sanitised above
cmd := exec.Command("ssh-keygen", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("ssh-keygen failed: %s", stderr.String())
}
return nil
}
// GetSSHPublicKey returns the content of a public key file.
func (s *Service) GetSSHPublicKey(name string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("unable to determine home directory: %w", err)
}
pubPath := filepath.Join(home, ".ssh", name+".pub")
data, err := os.ReadFile(pubPath)
if err != nil {
return "", fmt.Errorf("unable to read public key: %w", err)
}
return strings.TrimSpace(string(data)), nil
}
// DeleteSSHKey removes an SSH key pair.
func (s *Service) DeleteSSHKey(name string) error {
if name == "" {
return fmt.Errorf("key name is required")
}
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
privPath := filepath.Join(sshDir, name)
pubPath := privPath + ".pub"
// Verify paths are within .ssh directory (prevent directory traversal)
absPriv, _ := filepath.Abs(privPath)
absSsh, _ := filepath.Abs(sshDir)
if !strings.HasPrefix(absPriv, absSsh+string(os.PathSeparator)) {
return fmt.Errorf("invalid key name")
}
var errs []string
if err := os.Remove(privPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, "private key: "+err.Error())
}
if err := os.Remove(pubPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, "public key: "+err.Error())
}
if len(errs) > 0 {
return fmt.Errorf("failed to delete: %s", strings.Join(errs, "; "))
}
return nil
}
+13
View File
@@ -12,6 +12,7 @@ const (
DefaultTelnetPort = 23 DefaultTelnetPort = 23
DefaultRAWPort = 23 DefaultRAWPort = 23
DefaultGitHubPort = 443 DefaultGitHubPort = 443
DefaultVNCPort = 5900
) )
// Default timeout values (in seconds). // Default timeout values (in seconds).
@@ -108,6 +109,18 @@ func NewProfile(protocol Protocol) Profile {
SecretMode: SecretModeSaved, SecretMode: SecretModeSaved,
RefreshIntervalSecs: 300, RefreshIntervalSecs: 300,
} }
case ProtocolVNC:
p.Port = DefaultVNCPort
p.VNC = &VNCConfig{
VNCPort: DefaultVNCPort,
SecretMode: SecretModeSaved,
ScaleToFit: true,
SharedSession: true,
Quality: 6,
SSHPort: DefaultSSHPort,
SSHAuthMethod: "publickey",
SSHSecretMode: SecretModeSaved,
}
} }
return p return p
+38
View File
@@ -12,6 +12,7 @@ const (
ProtocolTelnet Protocol = "telnet" ProtocolTelnet Protocol = "telnet"
ProtocolRAW Protocol = "raw" ProtocolRAW Protocol = "raw"
ProtocolGitHub Protocol = "github" ProtocolGitHub Protocol = "github"
ProtocolVNC Protocol = "vnc"
) )
// HostKeyPolicy determines how unknown or changed SSH host keys are handled. // HostKeyPolicy determines how unknown or changed SSH host keys are handled.
@@ -80,6 +81,9 @@ type Profile struct {
// GitHub-specific configuration // GitHub-specific configuration
GitHub *GitHubConfig `json:"github,omitempty"` GitHub *GitHubConfig `json:"github,omitempty"`
// VNC-specific configuration
VNC *VNCConfig `json:"vnc,omitempty"`
} }
// SSHConfig holds SSH-specific settings for a connection profile. // SSHConfig holds SSH-specific settings for a connection profile.
@@ -192,6 +196,40 @@ type GitHubConfig struct {
RefreshIntervalSecs int `json:"refreshIntervalSecs"` RefreshIntervalSecs int `json:"refreshIntervalSecs"`
} }
// VNCConfig holds VNC-specific settings for a connection profile.
type VNCConfig struct {
// VNCHost is the VNC server address (defaults to profile.Host if empty).
VNCHost string `json:"vncHost"`
// VNCPort is the VNC server port (5900 + display number, default 5900).
VNCPort int `json:"vncPort"`
// SecretMode controls how the VNC password is stored.
SecretMode SecretMode `json:"secretMode"`
// ViewOnly disables sending keyboard and mouse events to the server.
ViewOnly bool `json:"viewOnly"`
// ScaleToFit scales the remote framebuffer to fit the panel.
ScaleToFit bool `json:"scaleToFit"`
// Quality controls JPEG compression quality (1-9, 0 = default).
Quality int `json:"quality"`
// SharedSession allows other VNC viewers to remain connected.
SharedSession bool `json:"sharedSession"`
// SSH tunnel settings — if enabled, VNC traffic is tunnelled through an SSH connection.
SSHTunnel bool `json:"sshTunnel"`
SSHHost string `json:"sshHost"`
SSHPort int `json:"sshPort"`
SSHUsername string `json:"sshUsername"`
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
SSHSecretMode SecretMode `json:"sshSecretMode"`
SSHPrivateKey string `json:"sshPrivateKeyPath"`
SSHAgentAuth bool `json:"sshAgentAuth"`
}
// GitHubRepo is a single repository returned to the frontend for display. // GitHubRepo is a single repository returned to the frontend for display.
type GitHubRepo struct { type GitHubRepo struct {
FullName string `json:"fullName"` FullName string `json:"fullName"`
+120
View File
@@ -7,6 +7,8 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"net"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -24,6 +26,7 @@ import (
"github.com/andydixon/nexus/internal/sshclient" "github.com/andydixon/nexus/internal/sshclient"
"github.com/andydixon/nexus/internal/storage" "github.com/andydixon/nexus/internal/storage"
"github.com/andydixon/nexus/internal/telnetclient" "github.com/andydixon/nexus/internal/telnetclient"
"github.com/andydixon/nexus/internal/vncclient"
) )
// Session wraps a protocol client with metadata and state. // Session wraps a protocol client with metadata and state.
@@ -34,6 +37,7 @@ type Session struct {
rawClient *rawclient.Client rawClient *rawclient.Client
githubClient *githubclient.Client githubClient *githubclient.Client
githubCancel context.CancelFunc // stops the auto-refresh goroutine githubCancel context.CancelFunc // stops the auto-refresh goroutine
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
profile *models.Profile profile *models.Profile
recorder *recording.Recorder // active recording, nil if not recording recorder *recording.Recorder // active recording, nil if not recording
@@ -123,6 +127,8 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
err = m.connectRAW(sess) err = m.connectRAW(sess)
case models.ProtocolGitHub: case models.ProtocolGitHub:
err = m.connectGitHub(sess, getSecret) err = m.connectGitHub(sess, getSecret)
case models.ProtocolVNC:
err = m.connectVNC(sess, getSecret)
default: default:
err = fmt.Errorf("unsupported protocol %q", profile.Protocol) err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
} }
@@ -867,6 +873,116 @@ func (m *Manager) DownloadGitHubArchive(sessionID string, fullName string, ref s
return nil return nil
} }
// connectVNC establishes a VNC connection (with optional SSH tunnel) and
// emits the local WebSocket proxy URL to the frontend.
func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (string, error)) error {
profile := sess.profile
vncConf := profile.VNC
if vncConf == nil {
return fmt.Errorf("VNC configuration missing")
}
// Fall back to the profile-level host if VNC-specific host is empty.
vncHost := vncConf.VNCHost
if vncHost == "" {
vncHost = profile.Host
}
cfg := vncclient.Config{
VNCHost: vncHost,
VNCPort: vncConf.VNCPort,
OnError: func(err error) {
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sess.Info.ID,
"state": string(models.SessionError),
"error": err.Error(),
})
},
}
// SSH tunnel configuration
if vncConf.SSHTunnel {
cfg.SSHTunnel = true
cfg.SSHHost = vncConf.SSHHost
cfg.SSHPort = vncConf.SSHPort
cfg.SSHUsername = vncConf.SSHUsername
cfg.SSHAuthMethod = vncConf.SSHAuthMethod
cfg.SSHAgentAuth = vncConf.SSHAgentAuth
// Resolve SSH credentials
switch vncConf.SSHAuthMethod {
case "password":
getPass := m.buildSecretCallback(profile.ID, "vnc-ssh-password", vncConf.SSHSecretMode, getSecret)
password, err := getPass()
if err != nil {
return fmt.Errorf("SSH password: %w", err)
}
cfg.SSHPassword = password
case "publickey":
if vncConf.SSHPrivateKey != "" {
keyData, err := os.ReadFile(vncConf.SSHPrivateKey)
if err != nil {
return fmt.Errorf("reading SSH key: %w", err)
}
cfg.SSHPrivateKey = keyData
// Get passphrase if needed
getPassphrase := m.buildSecretCallback(profile.ID, "vnc-ssh-passphrase", vncConf.SSHSecretMode, getSecret)
passphrase, _ := getPassphrase()
cfg.SSHPassphrase = passphrase
}
}
// SSH agent support
if vncConf.SSHAgentAuth {
socketPath := os.Getenv("SSH_AUTH_SOCK")
if socketPath != "" {
agentConn, err := net.Dial("unix", socketPath)
if err == nil {
cfg.SSHAgentConn = agentConn
}
}
}
} else {
// Direct VNC — may need a VNC password
getVNCPass := m.buildSecretCallback(profile.ID, "vnc-password", vncConf.SecretMode, getSecret)
password, _ := getVNCPass()
// VNC auth is handled by the RFB protocol in the frontend (noVNC),
// but we store it in case we need server-side auth negotiation later.
_ = password
}
client := vncclient.NewClient(cfg)
wsURL, err := client.Connect(cfg)
if err != nil {
return err
}
sess.vncClient = client
// Emit the WebSocket URL so the frontend can connect noVNC.
m.emitEvent("session:vnc:ready", map[string]interface{}{
"sessionID": sess.Info.ID,
"wsURL": wsURL,
})
return nil
}
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) {
m.mu.RLock()
sess, ok := m.sessions[sessionID]
m.mu.RUnlock()
if !ok {
return "", fmt.Errorf("sessions: session %q not found", sessionID)
}
if sess.vncClient == nil {
return "", fmt.Errorf("sessions: session %q is not a VNC connection", sessionID)
}
return sess.vncClient.WSURL(), nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Session recording // Session recording
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1370,6 +1486,10 @@ func (m *Manager) closeSession(sess *Session) {
if sess.githubCancel != nil { if sess.githubCancel != nil {
sess.githubCancel() sess.githubCancel()
} }
if sess.vncClient != nil {
sess.vncClient.Close()
sess.vncClient = nil
}
m.mu.Lock() m.mu.Lock()
sess.Info.State = models.SessionDisconnected sess.Info.State = models.SessionDisconnected
@@ -0,0 +1,57 @@
-- Migration 004: Add VNC protocol support
-- Adds the 'vnc' value to the protocol CHECK constraint and a vnc_config column.
-- Step 1: Create the new table with the updated constraint.
CREATE TABLE IF NOT EXISTS profiles_new (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT DEFAULT '',
protocol TEXT NOT NULL CHECK(protocol IN ('ssh', 'telnet', 'raw', 'github', 'vnc')),
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
folder TEXT DEFAULT '',
environment TEXT DEFAULT '',
notes TEXT DEFAULT '',
favourite INTEGER DEFAULT 0,
last_connected TEXT,
connect_timeout INTEGER DEFAULT 30,
read_timeout INTEGER DEFAULT 0,
terminal_font_family TEXT DEFAULT '',
terminal_font_size INTEGER DEFAULT 14,
terminal_scrollback INTEGER DEFAULT 10000,
terminal_cursor_style TEXT DEFAULT 'block',
ssh_config TEXT,
telnet_config TEXT,
raw_config TEXT,
github_config TEXT,
vnc_config TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Step 2: Copy existing data (vnc_config will be NULL for legacy rows).
INSERT OR IGNORE INTO profiles_new (
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at
)
SELECT
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, NULL,
created_at, updated_at
FROM profiles;
-- Step 3: Drop old table and rename.
DROP TABLE profiles;
ALTER TABLE profiles_new RENAME TO profiles;
-- Step 4: Record the migration version.
INSERT INTO schema_version (version, applied_at) VALUES (4, datetime('now'));
+26 -16
View File
@@ -110,15 +110,16 @@ func (s *Store) CreateProfile(p *models.Profile) error {
telnetJSON, _ := json.Marshal(p.Telnet) telnetJSON, _ := json.Marshal(p.Telnet)
rawJSON, _ := json.Marshal(p.RAW) rawJSON, _ := json.Marshal(p.RAW)
githubJSON, _ := json.Marshal(p.GitHub) githubJSON, _ := json.Marshal(p.GitHub)
vncJSON, _ := json.Marshal(p.VNC)
_, err := s.db.Exec(`INSERT INTO profiles ( _, err := s.db.Exec(`INSERT INTO profiles (
id, display_name, description, protocol, host, port, username, id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.ID, p.DisplayName, p.Description, string(p.Protocol), p.ID, p.DisplayName, p.Description, string(p.Protocol),
p.Host, p.Port, p.Username, p.Host, p.Port, p.Username,
string(tagsJSON), p.Folder, p.Environment, p.Notes, string(tagsJSON), p.Folder, p.Environment, p.Notes,
@@ -126,7 +127,7 @@ func (s *Store) CreateProfile(p *models.Profile) error {
p.ConnectTimeout, p.ReadTimeout, p.ConnectTimeout, p.ReadTimeout,
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle, p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON), nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339), p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
) )
if err != nil { if err != nil {
@@ -142,7 +143,7 @@ func (s *Store) GetProfile(id string) (*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE id = ?`, id) FROM profiles WHERE id = ?`, id)
@@ -163,13 +164,14 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
telnetJSON, _ := json.Marshal(p.Telnet) telnetJSON, _ := json.Marshal(p.Telnet)
rawJSON, _ := json.Marshal(p.RAW) rawJSON, _ := json.Marshal(p.RAW)
githubJSON, _ := json.Marshal(p.GitHub) githubJSON, _ := json.Marshal(p.GitHub)
vncJSON, _ := json.Marshal(p.VNC)
res, err := s.db.Exec(`UPDATE profiles SET res, err := s.db.Exec(`UPDATE profiles SET
display_name=?, description=?, protocol=?, host=?, port=?, username=?, display_name=?, description=?, protocol=?, host=?, port=?, username=?,
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?, tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
connect_timeout=?, read_timeout=?, connect_timeout=?, read_timeout=?,
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?, terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?,
updated_at=? updated_at=?
WHERE id=?`, WHERE id=?`,
p.DisplayName, p.Description, string(p.Protocol), p.DisplayName, p.Description, string(p.Protocol),
@@ -179,7 +181,7 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
p.ConnectTimeout, p.ReadTimeout, p.ConnectTimeout, p.ReadTimeout,
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle, p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON), nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
p.UpdatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
p.ID, p.ID,
) )
@@ -213,7 +215,7 @@ func (s *Store) ListProfiles() ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles ORDER BY display_name`) FROM profiles ORDER BY display_name`)
if err != nil { if err != nil {
@@ -232,7 +234,7 @@ func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles FROM profiles
WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ? WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ?
@@ -251,7 +253,7 @@ func (s *Store) ListProfilesByProtocol(protocol string) ([]*models.Profile, erro
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol) FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
if err != nil { if err != nil {
@@ -269,7 +271,7 @@ func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q) FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
if err != nil { if err != nil {
@@ -286,7 +288,7 @@ func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE folder = ? ORDER BY display_name`, folder) FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
if err != nil { if err != nil {
@@ -303,7 +305,7 @@ func (s *Store) ListFavourites() ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE favourite = 1 ORDER BY display_name`) FROM profiles WHERE favourite = 1 ORDER BY display_name`)
if err != nil { if err != nil {
@@ -566,7 +568,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
p := &models.Profile{} p := &models.Profile{}
var ( var (
protocol, tagsJSON string protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON sql.NullString sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
lastConn sql.NullString lastConn sql.NullString
favourite int favourite int
createdAt, updatedAt string createdAt, updatedAt string
@@ -579,7 +581,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
&favourite, &lastConn, &favourite, &lastConn,
&p.ConnectTimeout, &p.ReadTimeout, &p.ConnectTimeout, &p.ReadTimeout,
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle, &p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
&createdAt, &updatedAt, &createdAt, &updatedAt,
) )
if err != nil { if err != nil {
@@ -618,6 +620,10 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
p.GitHub = &models.GitHubConfig{} p.GitHub = &models.GitHubConfig{}
_ = json.Unmarshal([]byte(githubJSON.String), p.GitHub) _ = json.Unmarshal([]byte(githubJSON.String), p.GitHub)
} }
if vncJSON.Valid && vncJSON.String != "" {
p.VNC = &models.VNCConfig{}
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
}
return p, nil return p, nil
} }
@@ -629,7 +635,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
p := &models.Profile{} p := &models.Profile{}
var ( var (
protocol, tagsJSON string protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON sql.NullString sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
lastConn sql.NullString lastConn sql.NullString
favourite int favourite int
createdAt, updatedAt string createdAt, updatedAt string
@@ -642,7 +648,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
&favourite, &lastConn, &favourite, &lastConn,
&p.ConnectTimeout, &p.ReadTimeout, &p.ConnectTimeout, &p.ReadTimeout,
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle, &p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
&createdAt, &updatedAt, &createdAt, &updatedAt,
) )
if err != nil { if err != nil {
@@ -681,6 +687,10 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
p.GitHub = &models.GitHubConfig{} p.GitHub = &models.GitHubConfig{}
_ = json.Unmarshal([]byte(githubJSON.String), p.GitHub) _ = json.Unmarshal([]byte(githubJSON.String), p.GitHub)
} }
if vncJSON.Valid && vncJSON.String != "" {
p.VNC = &models.VNCConfig{}
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
}
result = append(result, p) result = append(result, p)
} }
+2
View File
@@ -95,6 +95,8 @@ func ValidateProfile(p *models.Profile) error {
} }
case models.ProtocolGitHub: case models.ProtocolGitHub:
// GitHub profiles require no host-level validation beyond the common checks. // GitHub profiles require no host-level validation beyond the common checks.
case models.ProtocolVNC:
// VNC profiles use vnc.vncHost or the profile-level host; validated at connect time.
default: default:
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol)) errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
} }
+286
View File
@@ -0,0 +1,286 @@
// Package vncclient provides a WebSocket-to-TCP proxy for VNC connections,
// with optional SSH tunnel support.
package vncclient
import (
"context"
"fmt"
"net"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)
// Client manages a VNC session, optionally tunnelled through SSH.
type Client struct {
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
listener net.Listener
sshClient *ssh.Client
wsURL string
connected bool
onError func(error)
}
// Config holds the configuration for a VNC connection.
type Config struct {
// VNC server address
VNCHost string
VNCPort int
// SSH tunnel settings
SSHTunnel bool
SSHHost string
SSHPort int
SSHUsername string
SSHAuthMethod string // "password", "publickey"
SSHPassword string
SSHPrivateKey []byte
SSHPassphrase string
SSHAgentAuth bool
SSHAgentConn net.Conn
// Callbacks
OnError func(error)
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 65536,
WriteBufferSize: 65536,
}
// NewClient creates a new VNC proxy client.
func NewClient(cfg Config) *Client {
ctx, cancel := context.WithCancel(context.Background())
return &Client{
ctx: ctx,
cancel: cancel,
onError: cfg.OnError,
}
}
// Connect establishes the VNC connection (with optional SSH tunnel) and starts
// a local WebSocket proxy. Returns the WebSocket URL for the frontend.
func (c *Client) Connect(cfg Config) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.connected {
return c.wsURL, nil
}
var vncConn net.Conn
var err error
vncAddr := fmt.Sprintf("%s:%d", cfg.VNCHost, cfg.VNCPort)
if cfg.SSHTunnel {
// Establish SSH connection first
sshAddr := fmt.Sprintf("%s:%d", cfg.SSHHost, cfg.SSHPort)
sshConfig := &ssh.ClientConfig{
User: cfg.SSHUsername,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: use known hosts
Timeout: 15 * time.Second,
}
// Configure auth methods
var authMethods []ssh.AuthMethod
if cfg.SSHAgentAuth && cfg.SSHAgentConn != nil {
agent := newSSHAgent(cfg.SSHAgentConn)
if agent != nil {
authMethods = append(authMethods, agent)
}
}
if len(cfg.SSHPrivateKey) > 0 {
var signer ssh.Signer
if cfg.SSHPassphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(cfg.SSHPrivateKey, []byte(cfg.SSHPassphrase))
} else {
signer, err = ssh.ParsePrivateKey(cfg.SSHPrivateKey)
}
if err == nil {
authMethods = append(authMethods, ssh.PublicKeys(signer))
}
}
if cfg.SSHPassword != "" {
authMethods = append(authMethods, ssh.Password(cfg.SSHPassword))
}
sshConfig.Auth = authMethods
c.sshClient, err = ssh.Dial("tcp", sshAddr, sshConfig)
if err != nil {
return "", fmt.Errorf("SSH connection failed: %w", err)
}
// Dial VNC through the SSH tunnel
// When tunnelling, VNC address is relative to the SSH server
tunnelAddr := vncAddr
if cfg.VNCHost == "" || cfg.VNCHost == cfg.SSHHost {
tunnelAddr = fmt.Sprintf("127.0.0.1:%d", cfg.VNCPort)
}
vncConn, err = c.sshClient.Dial("tcp", tunnelAddr)
if err != nil {
c.sshClient.Close()
c.sshClient = nil
return "", fmt.Errorf("VNC tunnel dial failed: %w", err)
}
} else {
// Direct TCP connection to VNC server
vncConn, err = net.DialTimeout("tcp", vncAddr, 15*time.Second)
if err != nil {
return "", fmt.Errorf("VNC connection failed: %w", err)
}
}
// Start local WebSocket proxy
wsURL, err := c.startProxy(vncConn)
if err != nil {
vncConn.Close()
if c.sshClient != nil {
c.sshClient.Close()
c.sshClient = nil
}
return "", fmt.Errorf("failed to start WebSocket proxy: %w", err)
}
c.wsURL = wsURL
c.connected = true
return wsURL, nil
}
// startProxy creates a local HTTP server that upgrades to WebSocket and bridges
// traffic to the VNC TCP connection.
func (c *Client) startProxy(vncConn net.Conn) (string, error) {
// Listen on a random available port on localhost
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
}
c.listener = listener
port := listener.Addr().(*net.TCPAddr).Port
wsURL := fmt.Sprintf("ws://127.0.0.1:%d", port)
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer ws.Close()
// Bridge WebSocket ↔ VNC TCP in both directions
done := make(chan struct{}, 2)
// VNC → WebSocket
go func() {
defer func() { done <- struct{}{} }()
buf := make([]byte, 65536)
for {
n, err := vncConn.Read(buf)
if n > 0 {
if writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); writeErr != nil {
return
}
}
if err != nil {
return
}
}
}()
// WebSocket → VNC
go func() {
defer func() { done <- struct{}{} }()
for {
_, msg, err := ws.ReadMessage()
if err != nil {
return
}
if _, err := vncConn.Write(msg); err != nil {
return
}
}
}()
// Wait for either direction to close
select {
case <-done:
case <-c.ctx.Done():
}
})
server := &http.Server{
Handler: mux,
ReadTimeout: 0, // VNC is long-lived
WriteTimeout: 0,
}
go func() {
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
if c.onError != nil {
c.onError(fmt.Errorf("VNC proxy error: %w", err))
}
}
}()
// Close server when context is cancelled
go func() {
<-c.ctx.Done()
server.Close()
vncConn.Close()
}()
return wsURL, nil
}
// Close shuts down the VNC proxy and any SSH tunnel.
func (c *Client) Close() {
c.mu.Lock()
defer c.mu.Unlock()
c.cancel()
c.connected = false
if c.listener != nil {
c.listener.Close()
c.listener = nil
}
if c.sshClient != nil {
c.sshClient.Close()
c.sshClient = nil
}
}
// IsConnected returns whether the VNC session is active.
func (c *Client) IsConnected() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.connected
}
// WSURL returns the WebSocket URL for the frontend to connect to.
func (c *Client) WSURL() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.wsURL
}
// newSSHAgent creates an SSH agent auth method from the given connection.
func newSSHAgent(conn net.Conn) ssh.AuthMethod {
if conn == nil {
return nil
}
ag := agent.NewClient(conn)
signers, err := ag.Signers()
if err != nil || len(signers) == 0 {
return nil
}
return ssh.PublicKeys(signers...)
}