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:
@@ -22,6 +22,7 @@
|
||||
<link rel="stylesheet" href="/src/css/forms.css" />
|
||||
<link rel="stylesheet" href="/src/css/modals.css" />
|
||||
<link rel="stylesheet" href="/src/css/github.css" />
|
||||
<link rel="stylesheet" href="/src/css/vnc.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="app">
|
||||
@@ -76,6 +77,13 @@
|
||||
>
|
||||
<span>+ New</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
id="btn-command-palette"
|
||||
title="Command Palette (⌘K)"
|
||||
>
|
||||
<span>⌘K</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-icon btn-sm"
|
||||
id="btn-import"
|
||||
@@ -305,6 +313,8 @@
|
||||
<script src="/src/js/profiles.js"></script>
|
||||
<script src="/src/js/raw.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/commands.js"></script>
|
||||
<script src="/src/js/secrets.js"></script>
|
||||
@@ -312,6 +322,8 @@
|
||||
<script src="/src/js/sftp.js"></script>
|
||||
<script src="/src/js/splitpane.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+476
@@ -9,11 +9,487 @@
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "^1.5.0",
|
||||
"xterm": "5.3.0",
|
||||
"xterm-addon-fit": "0.8.0",
|
||||
"xterm-addon-search": "0.13.0",
|
||||
"xterm-addon-unicode11": "0.6.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": {
|
||||
|
||||
@@ -5,15 +5,20 @@
|
||||
"description": "Frontend for Nexus Terminal desktop SSH client",
|
||||
"scripts": {
|
||||
"install:assets": "node scripts/copy-assets.js",
|
||||
"bundle:novnc": "node scripts/bundle-novnc.js",
|
||||
"build": "node scripts/build.js",
|
||||
"dev": "node scripts/build.js --watch",
|
||||
"postinstall": "npm run install:assets"
|
||||
"postinstall": "npm run install:assets && npm run bundle:novnc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "^1.5.0",
|
||||
"xterm": "5.3.0",
|
||||
"xterm-addon-fit": "0.8.0",
|
||||
"xterm-addon-search": "0.13.0",
|
||||
"xterm-addon-unicode11": "0.6.0",
|
||||
"xterm-addon-web-links": "0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.24.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -1020,3 +1020,198 @@
|
||||
margin: var(--space-1) 0;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -163,6 +163,40 @@
|
||||
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)
|
||||
----------------------------------------------------------------------- */
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
--protocol-telnet: #ffa726;
|
||||
--protocol-raw: #ab47bc;
|
||||
--protocol-github: #7c8aff;
|
||||
--protocol-vnc: #e85d4a;
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Spacing scale (4 px base unit)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -20,6 +20,8 @@
|
||||
ExportImportUI,
|
||||
SFTPBrowser,
|
||||
SnippetLibrary,
|
||||
SSHKeyManager,
|
||||
TriggerEngine,
|
||||
stateLabel,
|
||||
} = window.NexusTerminal;
|
||||
|
||||
@@ -40,6 +42,8 @@
|
||||
this.exportImport = new ExportImportUI(this);
|
||||
this.sftp = new SFTPBrowser(this);
|
||||
this.snippets = new SnippetLibrary(this);
|
||||
this.sshKeys = new SSHKeyManager(this);
|
||||
this.triggers = new TriggerEngine(this);
|
||||
this.updateInfo = null;
|
||||
}
|
||||
|
||||
@@ -159,6 +163,12 @@
|
||||
// Incoming session data (terminal output)
|
||||
window.runtime.EventsOn("session:data", (payload) => {
|
||||
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)
|
||||
@@ -225,6 +235,7 @@
|
||||
// Session closed by backend
|
||||
window.runtime.EventsOn("session:closed", (payload) => {
|
||||
this.sftp.close(payload.sessionID);
|
||||
this.triggers.cleanup(payload.sessionID);
|
||||
this.tabs.handleSessionClosed(payload.sessionID);
|
||||
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
|
||||
window.runtime.EventsOn("app:open-about", () => {
|
||||
this.settings.openTab("about");
|
||||
@@ -362,6 +381,7 @@
|
||||
this.terminals.destroyTerminal(sessionId);
|
||||
this.rawViewer.destroyViewer(sessionId);
|
||||
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
|
||||
window.NexusTerminal.VNCViewer.destroyViewer(sessionId);
|
||||
this.state.removeSession(sessionId);
|
||||
this.tabs.removeTab(sessionId);
|
||||
this.updateStatusBar();
|
||||
@@ -509,6 +529,7 @@
|
||||
setupUI() {
|
||||
var btnEl = document.getElementById("btn-new-connection");
|
||||
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-import", () => this.importProfiles());
|
||||
this._bindClick("btn-export", () => this.exportProfiles());
|
||||
@@ -763,6 +784,15 @@
|
||||
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 {
|
||||
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
|
||||
}
|
||||
|
||||
+331
-294
@@ -1,27 +1,27 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — command palette (Ctrl+K).
|
||||
* Provides a searchable overlay for quick access to application commands,
|
||||
* profile connections, and utility actions.
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — command palette (Ctrl+K).
|
||||
* Provides a searchable overlay for quick access to application commands,
|
||||
* profile connections, and utility actions.
|
||||
*/
|
||||
|
||||
const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal;
|
||||
const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal;
|
||||
|
||||
class CommandPalette {
|
||||
class CommandPalette {
|
||||
/**
|
||||
* @param {App} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.app = app;
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
this._overlay = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._overlay = null;
|
||||
|
||||
/** @type {string} Current filter query. */
|
||||
this._query = '';
|
||||
/** @type {string} Current filter query. */
|
||||
this._query = "";
|
||||
|
||||
/** @type {number} Index of the highlighted command. */
|
||||
this._selectedIndex = 0;
|
||||
/** @type {number} Index of the highlighted command. */
|
||||
this._selectedIndex = 0;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
@@ -30,31 +30,31 @@ class CommandPalette {
|
||||
* Toggles the command palette open/closed.
|
||||
*/
|
||||
toggle() {
|
||||
if (this._overlay) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
if (this._overlay) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the command palette.
|
||||
*/
|
||||
open() {
|
||||
if (this._overlay) return;
|
||||
this._query = '';
|
||||
this._selectedIndex = 0;
|
||||
this._render();
|
||||
if (this._overlay) return;
|
||||
this._query = "";
|
||||
this._selectedIndex = 0;
|
||||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the command palette.
|
||||
*/
|
||||
close() {
|
||||
if (this._overlay) {
|
||||
this._overlay.remove();
|
||||
this._overlay = null;
|
||||
}
|
||||
if (this._overlay) {
|
||||
this._overlay.remove();
|
||||
this._overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command definitions ──────────────────────────────────────────
|
||||
@@ -64,165 +64,184 @@ class CommandPalette {
|
||||
* @returns {Array<{id: string, label: string, shortcut?: string, section?: string, action: Function}>}
|
||||
*/
|
||||
getCommands() {
|
||||
const cmds = [];
|
||||
const cmds = [];
|
||||
|
||||
// Profile connections
|
||||
const profiles = this.app.state.profiles || [];
|
||||
for (const p of profiles) {
|
||||
cmds.push({
|
||||
id: `connect-${p.id}`,
|
||||
label: `Connect to ${p.displayName || p.host}`,
|
||||
section: 'Connections',
|
||||
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
|
||||
action: () => this.app.connectProfile(p.id),
|
||||
});
|
||||
}
|
||||
// Profile connections
|
||||
const profiles = this.app.state.profiles || [];
|
||||
for (const p of profiles) {
|
||||
cmds.push({
|
||||
id: `connect-${p.id}`,
|
||||
label: `Connect to ${p.displayName || p.host}`,
|
||||
section: "Connections",
|
||||
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
|
||||
action: () => this.app.connectProfile(p.id),
|
||||
});
|
||||
}
|
||||
|
||||
// New connections
|
||||
cmds.push({
|
||||
id: 'new-ssh',
|
||||
label: 'New SSH Connection',
|
||||
section: 'Actions',
|
||||
shortcut: '⌘N',
|
||||
action: () => this.app.profileEditor.openNew('ssh'),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'new-telnet',
|
||||
label: 'New Telnet Connection',
|
||||
section: 'Actions',
|
||||
action: () => this.app.profileEditor.openNew('telnet'),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'new-raw',
|
||||
label: 'New RAW Connection',
|
||||
section: 'Actions',
|
||||
action: () => this.app.profileEditor.openNew('raw'),
|
||||
});
|
||||
// New connections
|
||||
cmds.push({
|
||||
id: "new-ssh",
|
||||
label: "New SSH Connection",
|
||||
section: "Actions",
|
||||
shortcut: "⌘N",
|
||||
action: () => this.app.profileEditor.openNew("ssh"),
|
||||
});
|
||||
cmds.push({
|
||||
id: "new-telnet",
|
||||
label: "New Telnet Connection",
|
||||
section: "Actions",
|
||||
action: () => this.app.profileEditor.openNew("telnet"),
|
||||
});
|
||||
cmds.push({
|
||||
id: "new-raw",
|
||||
label: "New RAW Connection",
|
||||
section: "Actions",
|
||||
action: () => this.app.profileEditor.openNew("raw"),
|
||||
});
|
||||
|
||||
// Quick connect
|
||||
cmds.push({
|
||||
id: 'quick-connect',
|
||||
label: 'Quick Connect…',
|
||||
section: 'Actions',
|
||||
action: () => this.app.quickConnect(),
|
||||
});
|
||||
// Quick connect
|
||||
cmds.push({
|
||||
id: "quick-connect",
|
||||
label: "Quick Connect…",
|
||||
section: "Actions",
|
||||
action: () => this.app.quickConnect(),
|
||||
});
|
||||
|
||||
// Tab actions
|
||||
cmds.push({
|
||||
id: 'close-tab',
|
||||
label: 'Close Tab',
|
||||
section: 'Tabs',
|
||||
shortcut: '⌘W',
|
||||
action: () => this.app.tabs.closeActive(),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'next-tab',
|
||||
label: 'Next Tab',
|
||||
section: 'Tabs',
|
||||
shortcut: '⌘Tab',
|
||||
action: () => {
|
||||
const next = this.app.tabs.getNextTab();
|
||||
if (next) this.app.tabs.setActive(next);
|
||||
},
|
||||
});
|
||||
cmds.push({
|
||||
id: 'prev-tab',
|
||||
label: 'Previous Tab',
|
||||
section: 'Tabs',
|
||||
shortcut: '⌘⇧Tab',
|
||||
action: () => {
|
||||
const prev = this.app.tabs.getPrevTab();
|
||||
if (prev) this.app.tabs.setActive(prev);
|
||||
},
|
||||
});
|
||||
// Tab actions
|
||||
cmds.push({
|
||||
id: "close-tab",
|
||||
label: "Close Tab",
|
||||
section: "Tabs",
|
||||
shortcut: "⌘W",
|
||||
action: () => this.app.tabs.closeActive(),
|
||||
});
|
||||
cmds.push({
|
||||
id: "next-tab",
|
||||
label: "Next Tab",
|
||||
section: "Tabs",
|
||||
shortcut: "⌘Tab",
|
||||
action: () => {
|
||||
const next = this.app.tabs.getNextTab();
|
||||
if (next) this.app.tabs.setActive(next);
|
||||
},
|
||||
});
|
||||
cmds.push({
|
||||
id: "prev-tab",
|
||||
label: "Previous Tab",
|
||||
section: "Tabs",
|
||||
shortcut: "⌘⇧Tab",
|
||||
action: () => {
|
||||
const prev = this.app.tabs.getPrevTab();
|
||||
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({
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
section: 'Application',
|
||||
action: () => this.app.settings.open(),
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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({
|
||||
id: 'import-profiles',
|
||||
label: 'Import Profiles',
|
||||
section: 'Application',
|
||||
action: () => this.app.importProfiles(),
|
||||
id: "split-horizontal",
|
||||
label: "Split Pane Down",
|
||||
section: "Terminal",
|
||||
shortcut: "⌘⇧E",
|
||||
action: () => this.app.splitPane("horizontal"),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'export-profiles',
|
||||
label: 'Export Profiles',
|
||||
section: 'Application',
|
||||
action: () => this.app.exportProfiles(),
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
// Secret store
|
||||
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;
|
||||
return cmds;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,14 +250,17 @@ class CommandPalette {
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
filter(query) {
|
||||
if (!query) return this.getCommands();
|
||||
return this.getCommands()
|
||||
.map((cmd) => {
|
||||
const result = fuzzyMatch(cmd.label + ' ' + (cmd.detail || ''), query);
|
||||
return { ...cmd, ...result };
|
||||
})
|
||||
.filter((c) => c.match)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
if (!query) return this.getCommands();
|
||||
return this.getCommands()
|
||||
.map((cmd) => {
|
||||
const result = fuzzyMatch(
|
||||
cmd.label + " " + (cmd.detail || ""),
|
||||
query,
|
||||
);
|
||||
return { ...cmd, ...result };
|
||||
})
|
||||
.filter((c) => c.match)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,82 +268,85 @@ class CommandPalette {
|
||||
* @param {Object} command
|
||||
*/
|
||||
execute(command) {
|
||||
this.close();
|
||||
if (command && typeof command.action === 'function') {
|
||||
command.action();
|
||||
}
|
||||
this.close();
|
||||
if (command && typeof command.action === "function") {
|
||||
command.action();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────
|
||||
|
||||
/** @private */
|
||||
_render() {
|
||||
const overlay = createElement('div', 'palette-overlay');
|
||||
const palette = createElement('div', 'palette');
|
||||
const overlay = createElement("div", "palette-overlay");
|
||||
const palette = createElement("div", "palette");
|
||||
|
||||
// Search input
|
||||
const input = createElement('input', 'palette-input', {
|
||||
type: 'text',
|
||||
placeholder: 'Type a command…',
|
||||
autocomplete: 'off',
|
||||
spellcheck: 'false',
|
||||
});
|
||||
// Search input
|
||||
const input = createElement("input", "palette-input", {
|
||||
type: "text",
|
||||
placeholder: "Type a command…",
|
||||
autocomplete: "off",
|
||||
spellcheck: "false",
|
||||
});
|
||||
|
||||
palette.appendChild(input);
|
||||
palette.appendChild(input);
|
||||
|
||||
// Results list
|
||||
const list = createElement('div', 'palette-list');
|
||||
palette.appendChild(list);
|
||||
// Results list
|
||||
const list = createElement("div", "palette-list");
|
||||
palette.appendChild(list);
|
||||
|
||||
overlay.appendChild(palette);
|
||||
document.body.appendChild(overlay);
|
||||
this._overlay = overlay;
|
||||
overlay.appendChild(palette);
|
||||
document.body.appendChild(overlay);
|
||||
this._overlay = overlay;
|
||||
|
||||
// Initial render
|
||||
this._renderList(list, this.filter(''));
|
||||
// Initial render
|
||||
this._renderList(list, this.filter(""));
|
||||
|
||||
// Focus the input
|
||||
input.focus();
|
||||
// Focus the input
|
||||
input.focus();
|
||||
|
||||
// Event handlers
|
||||
input.addEventListener('input', () => {
|
||||
this._query = input.value;
|
||||
this._selectedIndex = 0;
|
||||
this._renderList(list, this.filter(this._query));
|
||||
});
|
||||
// Event handlers
|
||||
input.addEventListener("input", () => {
|
||||
this._query = input.value;
|
||||
this._selectedIndex = 0;
|
||||
this._renderList(list, this.filter(this._query));
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const items = list.querySelectorAll('.palette-item');
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this._selectedIndex = Math.min(this._selectedIndex + 1, items.length - 1);
|
||||
this._highlightItem(items);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
|
||||
this._highlightItem(items);
|
||||
break;
|
||||
case 'Enter': {
|
||||
e.preventDefault();
|
||||
const selected = items[this._selectedIndex];
|
||||
if (selected && selected._command) {
|
||||
this.execute(selected._command);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
break;
|
||||
input.addEventListener("keydown", (e) => {
|
||||
const items = list.querySelectorAll(".palette-item");
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
this._selectedIndex = Math.min(
|
||||
this._selectedIndex + 1,
|
||||
items.length - 1,
|
||||
);
|
||||
this._highlightItem(items);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
|
||||
this._highlightItem(items);
|
||||
break;
|
||||
case "Enter": {
|
||||
e.preventDefault();
|
||||
const selected = items[this._selectedIndex];
|
||||
if (selected && selected._command) {
|
||||
this.execute(selected._command);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside to close
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) this.close();
|
||||
});
|
||||
// Click outside to close
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) this.close();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,52 +356,65 @@ class CommandPalette {
|
||||
* @private
|
||||
*/
|
||||
_renderList(list, commands) {
|
||||
list.innerHTML = '';
|
||||
let currentSection = null;
|
||||
list.innerHTML = "";
|
||||
let currentSection = null;
|
||||
|
||||
if (commands.length === 0) {
|
||||
const empty = createElement('div', 'palette-empty', { textContent: 'No commands found' });
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
if (commands.length === 0) {
|
||||
const empty = createElement("div", "palette-empty", {
|
||||
textContent: "No commands found",
|
||||
});
|
||||
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 cmd = commands[i];
|
||||
const item = createElement(
|
||||
"div",
|
||||
`palette-item ${i === this._selectedIndex ? "palette-item-active" : ""}`,
|
||||
);
|
||||
item._command = cmd;
|
||||
|
||||
// Section header
|
||||
if (cmd.section && cmd.section !== currentSection) {
|
||||
currentSection = cmd.section;
|
||||
const sectionEl = createElement('div', 'palette-section', { textContent: cmd.section });
|
||||
list.appendChild(sectionEl);
|
||||
}
|
||||
// Label
|
||||
const label = createElement("span", "palette-item-label", {
|
||||
textContent: cmd.label,
|
||||
});
|
||||
item.appendChild(label);
|
||||
|
||||
const item = createElement('div', `palette-item ${i === this._selectedIndex ? 'palette-item-active' : ''}`);
|
||||
item._command = cmd;
|
||||
|
||||
// Label
|
||||
const label = createElement('span', 'palette-item-label', { textContent: cmd.label });
|
||||
item.appendChild(label);
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,17 +423,16 @@ class CommandPalette {
|
||||
* @private
|
||||
*/
|
||||
_highlightItem(items) {
|
||||
items.forEach((el, i) => {
|
||||
el.classList.toggle('palette-item-active', i === this._selectedIndex);
|
||||
});
|
||||
const active = items[this._selectedIndex];
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
items.forEach((el, i) => {
|
||||
el.classList.toggle("palette-item-active", i === this._selectedIndex);
|
||||
});
|
||||
const active = items[this._selectedIndex];
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.CommandPalette = CommandPalette;
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.CommandPalette = CommandPalette;
|
||||
})();
|
||||
|
||||
+181
-3
@@ -65,6 +65,27 @@
|
||||
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 {
|
||||
@@ -141,7 +162,7 @@
|
||||
drawer.appendChild(header);
|
||||
|
||||
// ── Protocol selector tabs ──────────────────────────────
|
||||
const protocols = ["ssh", "telnet", "raw", "github"];
|
||||
const protocols = ["ssh", "telnet", "raw", "github", "vnc"];
|
||||
const tabBar = createElement("div", "drawer-protocol-tabs");
|
||||
for (const proto of protocols) {
|
||||
const label = proto === "github" ? "GitHub" : proto.toUpperCase();
|
||||
@@ -184,10 +205,15 @@
|
||||
body.appendChild(s),
|
||||
);
|
||||
break;
|
||||
case "vnc":
|
||||
this._renderVNCSections(profile).forEach((s) =>
|
||||
body.appendChild(s),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
@@ -922,6 +948,131 @@
|
||||
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 */
|
||||
_renderTerminalSection(profile) {
|
||||
return this._renderSection("Terminal", [
|
||||
@@ -2102,6 +2253,26 @@
|
||||
p.github.secretMode = val("github.secretMode") || "saved";
|
||||
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 ───────────────────────────────────────────────────
|
||||
@@ -2114,7 +2285,12 @@
|
||||
*/
|
||||
_validate(profile) {
|
||||
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.");
|
||||
}
|
||||
if (!profile.port || profile.port < 1 || profile.port > 65535) {
|
||||
@@ -2283,6 +2459,8 @@
|
||||
ssh: null,
|
||||
telnet: null,
|
||||
raw: null,
|
||||
github: null,
|
||||
vnc: null,
|
||||
};
|
||||
|
||||
const defaults = DEFAULTS[protocol] || {};
|
||||
|
||||
@@ -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">×</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">×</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
@@ -100,9 +100,12 @@
|
||||
setActive(sessionId) {
|
||||
this.app.state.setActiveSession(sessionId);
|
||||
|
||||
// Highlight active tab
|
||||
// Highlight active tab and clear activity indicator
|
||||
this.tabList.querySelectorAll(".tab").forEach((t) => {
|
||||
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
|
||||
@@ -425,6 +428,21 @@
|
||||
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 {string|null}
|
||||
@@ -488,6 +506,10 @@
|
||||
);
|
||||
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
|
||||
if (sessionInfo.pinned) {
|
||||
tab.classList.add("tab-pinned");
|
||||
|
||||
@@ -233,6 +233,90 @@ const THEMES = {
|
||||
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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">×</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;
|
||||
})();
|
||||
@@ -211,6 +211,7 @@ function protocolColour(protocol) {
|
||||
case 'telnet': return 'proto-telnet';
|
||||
case 'raw': return 'proto-raw';
|
||||
case 'github': return 'proto-github';
|
||||
case 'vnc': return 'proto-vnc';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -912,6 +913,16 @@ func (s *Service) DownloadGitHubRepoArchive(sessionID string, fullName string, r
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1457,3 +1468,196 @@ func derefProfiles(ptrs []*models.Profile) []models.Profile {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const (
|
||||
DefaultTelnetPort = 23
|
||||
DefaultRAWPort = 23
|
||||
DefaultGitHubPort = 443
|
||||
DefaultVNCPort = 5900
|
||||
)
|
||||
|
||||
// Default timeout values (in seconds).
|
||||
@@ -108,6 +109,18 @@ func NewProfile(protocol Protocol) Profile {
|
||||
SecretMode: SecretModeSaved,
|
||||
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
|
||||
|
||||
@@ -12,6 +12,7 @@ const (
|
||||
ProtocolTelnet Protocol = "telnet"
|
||||
ProtocolRAW Protocol = "raw"
|
||||
ProtocolGitHub Protocol = "github"
|
||||
ProtocolVNC Protocol = "vnc"
|
||||
)
|
||||
|
||||
// HostKeyPolicy determines how unknown or changed SSH host keys are handled.
|
||||
@@ -80,6 +81,9 @@ type Profile struct {
|
||||
|
||||
// GitHub-specific configuration
|
||||
GitHub *GitHubConfig `json:"github,omitempty"`
|
||||
|
||||
// VNC-specific configuration
|
||||
VNC *VNCConfig `json:"vnc,omitempty"`
|
||||
}
|
||||
|
||||
// SSHConfig holds SSH-specific settings for a connection profile.
|
||||
@@ -192,6 +196,40 @@ type GitHubConfig struct {
|
||||
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.
|
||||
type GitHubRepo struct {
|
||||
FullName string `json:"fullName"`
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -24,6 +26,7 @@ import (
|
||||
"github.com/andydixon/nexus/internal/sshclient"
|
||||
"github.com/andydixon/nexus/internal/storage"
|
||||
"github.com/andydixon/nexus/internal/telnetclient"
|
||||
"github.com/andydixon/nexus/internal/vncclient"
|
||||
)
|
||||
|
||||
// Session wraps a protocol client with metadata and state.
|
||||
@@ -34,6 +37,7 @@ type Session struct {
|
||||
rawClient *rawclient.Client
|
||||
githubClient *githubclient.Client
|
||||
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
|
||||
profile *models.Profile
|
||||
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)
|
||||
case models.ProtocolGitHub:
|
||||
err = m.connectGitHub(sess, getSecret)
|
||||
case models.ProtocolVNC:
|
||||
err = m.connectVNC(sess, getSecret)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
|
||||
}
|
||||
@@ -867,6 +873,116 @@ func (m *Manager) DownloadGitHubArchive(sessionID string, fullName string, ref s
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1370,6 +1486,10 @@ func (m *Manager) closeSession(sess *Session) {
|
||||
if sess.githubCancel != nil {
|
||||
sess.githubCancel()
|
||||
}
|
||||
if sess.vncClient != nil {
|
||||
sess.vncClient.Close()
|
||||
sess.vncClient = nil
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
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
@@ -110,15 +110,16 @@ func (s *Store) CreateProfile(p *models.Profile) error {
|
||||
telnetJSON, _ := json.Marshal(p.Telnet)
|
||||
rawJSON, _ := json.Marshal(p.RAW)
|
||||
githubJSON, _ := json.Marshal(p.GitHub)
|
||||
vncJSON, _ := json.Marshal(p.VNC)
|
||||
|
||||
_, err := s.db.Exec(`INSERT INTO profiles (
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, p.DisplayName, p.Description, string(p.Protocol),
|
||||
p.Host, p.Port, p.Username,
|
||||
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.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||
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),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -142,7 +143,7 @@ func (s *Store) GetProfile(id string) (*models.Profile, error) {
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE id = ?`, id)
|
||||
|
||||
@@ -163,13 +164,14 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
|
||||
telnetJSON, _ := json.Marshal(p.Telnet)
|
||||
rawJSON, _ := json.Marshal(p.RAW)
|
||||
githubJSON, _ := json.Marshal(p.GitHub)
|
||||
vncJSON, _ := json.Marshal(p.VNC)
|
||||
|
||||
res, err := s.db.Exec(`UPDATE profiles SET
|
||||
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=?,
|
||||
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?,
|
||||
updated_at=?
|
||||
WHERE id=?`,
|
||||
p.DisplayName, p.Description, string(p.Protocol),
|
||||
@@ -179,7 +181,7 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
|
||||
p.ConnectTimeout, p.ReadTimeout,
|
||||
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||
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.ID,
|
||||
)
|
||||
@@ -213,7 +215,7 @@ func (s *Store) ListProfiles() ([]*models.Profile, error) {
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles ORDER BY display_name`)
|
||||
if err != nil {
|
||||
@@ -232,7 +234,7 @@ func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) {
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles
|
||||
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,
|
||||
connect_timeout, read_timeout,
|
||||
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
|
||||
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
|
||||
if err != nil {
|
||||
@@ -269,7 +271,7 @@ func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) {
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
|
||||
if err != nil {
|
||||
@@ -286,7 +288,7 @@ func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) {
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
|
||||
if err != nil {
|
||||
@@ -303,7 +305,7 @@ func (s *Store) ListFavourites() ([]*models.Profile, error) {
|
||||
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,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE favourite = 1 ORDER BY display_name`)
|
||||
if err != nil {
|
||||
@@ -566,7 +568,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
||||
p := &models.Profile{}
|
||||
var (
|
||||
protocol, tagsJSON string
|
||||
sshJSON, telnetJSON, rawJSON, githubJSON sql.NullString
|
||||
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
@@ -579,7 +581,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
||||
&favourite, &lastConn,
|
||||
&p.ConnectTimeout, &p.ReadTimeout,
|
||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON,
|
||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -618,6 +620,10 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
||||
p.GitHub = &models.GitHubConfig{}
|
||||
_ = 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
|
||||
}
|
||||
@@ -629,7 +635,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
||||
p := &models.Profile{}
|
||||
var (
|
||||
protocol, tagsJSON string
|
||||
sshJSON, telnetJSON, rawJSON, githubJSON sql.NullString
|
||||
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
@@ -642,7 +648,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
||||
&favourite, &lastConn,
|
||||
&p.ConnectTimeout, &p.ReadTimeout,
|
||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON,
|
||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -681,6 +687,10 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
||||
p.GitHub = &models.GitHubConfig{}
|
||||
_ = 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)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ func ValidateProfile(p *models.Profile) error {
|
||||
}
|
||||
case models.ProtocolGitHub:
|
||||
// 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:
|
||||
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
Reference in New Issue
Block a user