10 Commits
Author SHA1 Message Date
Andy Dixon 1ea24bf7fa feat: add custom app icon replacement for macOS builds 2026-05-21 13:35:39 +01:00
Andy Dixon 0b6f611110 fix: update dependencies for improved stability and performance 2026-05-21 13:17:52 +01:00
Andy Dixon a7cf28b404 feat: implement local terminal support with shell configuration and UI integration 2026-05-21 13:13:06 +01:00
Andy Dixon 3ff04feb92 Refactor: Improve code readability and formatting across multiple files
- Enhanced formatting in triggers.js for better readability, including consistent line breaks and indentation.
- Updated utility functions in utils.js for improved clarity and organization, ensuring consistent formatting and spacing.
- Refactored VNC button creation in vnc.js to enhance readability with better structured code.
- Modified SQL migration script to improve readability by aligning SQL statements and enhancing the structure of the INSERT statement.
2026-05-21 12:53:08 +01:00
Andy Dixon 96fedb9348 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.
2026-05-21 12:52:57 +01:00
Andy Dixon 523d9b02b4 Refactor SnippetLibrary and State classes for improved readability and consistency
- Updated formatting and indentation in snippets.js for better code clarity.
- Enhanced error handling and notifications in sendToActiveSession method.
- Improved variable resolution logic for command snippets.
- Added new methods for managing session state in state.js.
- Introduced new split pane functionality in terminal.js for better user experience.
- Enhanced modal handling and user notifications across various components.
2026-05-21 12:15:49 +01:00
Andy Dixon 421bdb9ee8 feat: add latency indicator and broadcast target selection
- Added a latency indicator to the status bar, displaying round-trip time for SSH sessions.
- Implemented functionality to select specific sessions for broadcasting input, allowing users to manage broadcast targets.
- Enhanced SFTP functionality with bookmark management, enabling users to bookmark directories and navigate to them easily.
- Introduced a modal for user-defined snippet variables, allowing dynamic command execution with placeholders.
- Added port forwarding management for SSH sessions, including the ability to start and stop port forwards.
- Improved terminal output export functionality, allowing users to download or copy terminal output to clipboard.
2026-05-21 12:09:43 +01:00
Andy Dixon c9b5eb10eb Refactor CSS and JavaScript for improved readability and consistency
- Updated CSS styles for buttons, form controls, badges, and notifications to use consistent indentation and formatting.
- Enhanced JavaScript code in sftp.js for better readability by breaking long lines and ensuring consistent formatting.
2026-05-21 11:28:47 +01:00
Andy Dixon 96c603f3f4 Add context menus and SFTP transfer progress handling
- Implemented context menu styles in components.css.
- Added SFTP transfer progress queue and related styles in sftp.css.
- Enhanced app.js to handle SFTP transfer progress events.
- Updated sftp.js to manage transfer progress and render the transfer queue.
- Modified terminal.js to include context menu shortcuts.
- Adjusted manager.go to emit SFTP transfer progress events with direction.
- Updated sshclient.go to modify terminal modes for better performance.
2026-05-21 11:28:37 +01:00
Andy Dixon 23aaa1b53e Linux build stuff 2026-04-28 14:56:13 +01:00
49 changed files with 6892 additions and 1728 deletions
+1
View File
@@ -29,6 +29,7 @@ If a session drops, use the **Reconnect** button in the disconnected overlay.
- Product overview: [docs/overview.md](docs/overview.md) - Product overview: [docs/overview.md](docs/overview.md)
- Quick start (shareable): [docs/quick-start.md](docs/quick-start.md) - Quick start (shareable): [docs/quick-start.md](docs/quick-start.md)
- User manual: [docs/user-manual.md](docs/user-manual.md) - User manual: [docs/user-manual.md](docs/user-manual.md)
- Build and release: [docs/build-and-release.md](docs/build-and-release.md)
## Keyboard shortcuts ## Keyboard shortcuts
+18
View File
@@ -16,6 +16,14 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; } warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
err() { echo -e "${RED}[nexus]${NC} $1" >&2; } err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
build_tags() {
if command -v pkg-config >/dev/null 2>&1 \
&& pkg-config --exists webkit2gtk-4.1 \
&& ! pkg-config --exists webkit2gtk-4.0; then
echo "webkit2_41"
fi
}
build_version() { build_version() {
local tag local tag
local commit local commit
@@ -100,6 +108,8 @@ build_target() {
local version="$4" local version="$4"
local ldflags local ldflags
ldflags="$(build_ldflags "${version}")" ldflags="$(build_ldflags "${version}")"
local tags
tags="$(build_tags)"
local platform="${target_os}/${target_arch}" local platform="${target_os}/${target_arch}"
local cc="" local cc=""
@@ -114,10 +124,18 @@ build_target() {
log "Building ${platform}…" log "Building ${platform}…"
if [ -n "$cc" ]; then if [ -n "$cc" ]; then
if [ -n "$tags" ]; then
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags"
else
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
fi
else
if [ -n "$tags" ]; then
CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags"
else else
CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
fi fi
fi
} }
main() { main() {
+26
View File
@@ -17,6 +17,14 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; } warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
err() { echo -e "${RED}[nexus]${NC} $1" >&2; } err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
build_tags() {
if command -v pkg-config >/dev/null 2>&1 \
&& pkg-config --exists webkit2gtk-4.1 \
&& ! pkg-config --exists webkit2gtk-4.0; then
echo "webkit2_41"
fi
}
build_version() { build_version() {
local tag local tag
local commit local commit
@@ -81,7 +89,25 @@ build() {
log "Building production binary…" log "Building production binary…"
local version local version
version="$(build_version)" version="$(build_version)"
local tags
tags="$(build_tags)"
if [ -n "$tags" ]; then
log "Using Wails build tags: ${tags}"
wails build -tags "$tags" -ldflags "$(build_ldflags "${version}")"
else
wails build -ldflags "$(build_ldflags "${version}")" wails build -ldflags "$(build_ldflags "${version}")"
fi
# Replace Wails-generated icon with custom app icon on macOS.
if [[ "$OSTYPE" == darwin* ]]; then
local app_bundle="build/bin/nexus-terminal.app"
local custom_icns="build/darwin/AppIcon.icns"
if [ -d "$app_bundle" ] && [ -f "$custom_icns" ]; then
cp "$custom_icns" "$app_bundle/Contents/Resources/iconfile.icns"
log "Replaced app icon with custom AppIcon.icns"
fi
fi
log "Build complete: build/bin/" log "Build complete: build/bin/"
} }
+77
View File
@@ -0,0 +1,77 @@
# Build and Release
This project uses [Wails](https://wails.io/) for desktop packaging.
## Ubuntu 24.04 prerequisites
Install the Linux build toolchain and Wails runtime headers with:
```bash
sudo apt-get update
sudo apt-get install -y \
build-essential \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
pkg-config \
zip
```
Notes:
- Ubuntu 24.04 does not ship `libwebkit2gtk-4.0-dev`.
- Wails `v2.12.0` supports Ubuntu 24.04 by building with the `webkit2_41` tag.
- The local build scripts detect that package layout automatically and add the tag when needed.
You will also need these tools available in `PATH`:
- `go`
- `node`
- `npm`
- `wails`
Install the Wails CLI with:
```bash
go install github.com/wailsapp/wails/v2/cmd/wails@latest
export PATH="$PATH:$(go env GOPATH)/bin"
```
## Build commands
Build the app binary for the current host:
```bash
./build.sh
```
Build a packaged release for the current host platform:
```bash
./release.sh
```
By default, `./release.sh` now builds only the current host OS/architecture. On this Ubuntu 24.04 `amd64` host that means `linux/amd64`.
## Optional cross-compilers
If you want to add extra Linux/Windows targets from Ubuntu, install the relevant cross-compiler packages first:
```bash
sudo apt-get install -y \
gcc-aarch64-linux-gnu \
gcc-mingw-w64-x86-64
```
Then request explicit targets:
```bash
NEXUS_RELEASE_TARGETS=linux/amd64,windows/amd64 ./release.sh
```
## Platform caveats
- macOS targets cannot be cross-built from Linux with Wails. Build those on macOS.
- `windows/arm64` is not covered by the default Ubuntu toolchain here.
- `linux/arm64` needs more than `gcc-aarch64-linux-gnu`; it also needs an `arm64` sysroot and target GTK/WebKit development libraries for CGO to link cleanly.
- Tested on Ubuntu 24.04 `amd64`: `linux/amd64` and `windows/amd64` build successfully; `linux/arm64` does not in the default host setup above.
+13
View File
@@ -22,6 +22,7 @@
<link rel="stylesheet" href="/src/css/forms.css" /> <link rel="stylesheet" href="/src/css/forms.css" />
<link rel="stylesheet" href="/src/css/modals.css" /> <link rel="stylesheet" href="/src/css/modals.css" />
<link rel="stylesheet" href="/src/css/github.css" /> <link rel="stylesheet" href="/src/css/github.css" />
<link rel="stylesheet" href="/src/css/vnc.css" />
</head> </head>
<body> <body>
<div id="app" class="app"> <div id="app" class="app">
@@ -76,6 +77,13 @@
> >
<span>+ New</span> <span>+ New</span>
</button> </button>
<button
class="btn btn-ghost btn-sm"
id="btn-command-palette"
title="Command Palette (⌘K)"
>
<span>⌘K</span>
</button>
<button <button
class="btn btn-ghost btn-icon btn-sm" class="btn btn-ghost btn-icon btn-sm"
id="btn-import" id="btn-import"
@@ -267,6 +275,7 @@
Disconnected Disconnected
</span> </span>
<span id="status-protocol"></span> <span id="status-protocol"></span>
<span id="status-latency"></span>
<span id="status-session-count">0 sessions</span> <span id="status-session-count">0 sessions</span>
<span class="status-spacer"></span> <span class="status-spacer"></span>
<button <button
@@ -304,6 +313,8 @@
<script src="/src/js/profiles.js"></script> <script src="/src/js/profiles.js"></script>
<script src="/src/js/raw.js"></script> <script src="/src/js/raw.js"></script>
<script src="/src/js/github.js"></script> <script src="/src/js/github.js"></script>
<script src="/src/js/lib/novnc.min.js"></script>
<script src="/src/js/vnc.js"></script>
<script src="/src/js/settings.js"></script> <script src="/src/js/settings.js"></script>
<script src="/src/js/commands.js"></script> <script src="/src/js/commands.js"></script>
<script src="/src/js/secrets.js"></script> <script src="/src/js/secrets.js"></script>
@@ -311,6 +322,8 @@
<script src="/src/js/sftp.js"></script> <script src="/src/js/sftp.js"></script>
<script src="/src/js/splitpane.js"></script> <script src="/src/js/splitpane.js"></script>
<script src="/src/js/snippets.js"></script> <script src="/src/js/snippets.js"></script>
<script src="/src/js/sshkeys.js"></script>
<script src="/src/js/triggers.js"></script>
<script src="/src/js/app.js"></script> <script src="/src/js/app.js"></script>
</body> </body>
</html> </html>
+476
View File
@@ -9,11 +9,487 @@
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@novnc/novnc": "^1.5.0",
"xterm": "5.3.0", "xterm": "5.3.0",
"xterm-addon-fit": "0.8.0", "xterm-addon-fit": "0.8.0",
"xterm-addon-search": "0.13.0", "xterm-addon-search": "0.13.0",
"xterm-addon-unicode11": "0.6.0", "xterm-addon-unicode11": "0.6.0",
"xterm-addon-web-links": "0.9.0" "xterm-addon-web-links": "0.9.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
} }
}, },
"node_modules/xterm": { "node_modules/xterm": {
+6 -1
View File
@@ -5,15 +5,20 @@
"description": "Frontend for Nexus Terminal desktop SSH client", "description": "Frontend for Nexus Terminal desktop SSH client",
"scripts": { "scripts": {
"install:assets": "node scripts/copy-assets.js", "install:assets": "node scripts/copy-assets.js",
"bundle:novnc": "node scripts/bundle-novnc.js",
"build": "node scripts/build.js", "build": "node scripts/build.js",
"dev": "node scripts/build.js --watch", "dev": "node scripts/build.js --watch",
"postinstall": "npm run install:assets" "postinstall": "npm run install:assets && npm run bundle:novnc"
}, },
"dependencies": { "dependencies": {
"@novnc/novnc": "^1.5.0",
"xterm": "5.3.0", "xterm": "5.3.0",
"xterm-addon-fit": "0.8.0", "xterm-addon-fit": "0.8.0",
"xterm-addon-search": "0.13.0", "xterm-addon-search": "0.13.0",
"xterm-addon-unicode11": "0.6.0", "xterm-addon-unicode11": "0.6.0",
"xterm-addon-web-links": "0.9.0" "xterm-addon-web-links": "0.9.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
} }
} }
+1 -1
View File
@@ -1 +1 @@
ce0d75b28075a7688ba4535c94b4784c 99c6bbb0236daee684251a5f62bbd974
+43
View File
@@ -0,0 +1,43 @@
/**
* bundle-novnc.js — Uses esbuild to bundle noVNC's RFB class into a single
* script that exposes window.noVNC.RFB for use in the frontend.
*
* noVNC 1.5+ uses top-level await which is incompatible with IIFE format,
* so we bundle as ESM then wrap in an async IIFE via banner/footer.
*/
"use strict";
const esbuild = require("esbuild");
const path = require("path");
const entryPoint = path.resolve(__dirname, "novnc-entry.js");
const outFile = path.resolve(
__dirname,
"..",
"src",
"js",
"lib",
"novnc.min.js",
);
esbuild
.build({
entryPoints: [entryPoint],
bundle: true,
minify: true,
format: "esm",
outfile: outFile,
target: ["safari15"],
logLevel: "info",
banner: { js: ";(async function() {" },
footer: { js: "})();" },
})
.then(() => {
console.log(
"[bundle-novnc] noVNC bundled successfully → src/js/lib/novnc.min.js",
);
})
.catch((err) => {
console.error("[bundle-novnc] Failed:", err.message);
process.exit(1);
});
+4
View File
@@ -0,0 +1,4 @@
// Entry point for esbuild bundling of noVNC.
// Assigns RFB to window.noVNC so it's available as a global in the frontend.
import RFB from "@novnc/novnc";
window.noVNC = { RFB: RFB };
+360 -44
View File
@@ -103,7 +103,7 @@
background: rgba(239, 83, 80, 0.12); background: rgba(239, 83, 80, 0.12);
} }
.btn-danger:active { .btn-danger:active {
background: rgba(239, 83, 80, 0.20); background: rgba(239, 83, 80, 0.2);
} }
.btn-danger-filled { .btn-danger-filled {
@@ -172,7 +172,6 @@
margin-left: -1px; margin-left: -1px;
} }
/* ======================================================================= /* =======================================================================
FORM CONTROLS FORM CONTROLS
======================================================================= */ ======================================================================= */
@@ -191,7 +190,7 @@
} }
.form-label-required::after { .form-label-required::after {
content: ' *'; content: " *";
color: var(--error); color: var(--error);
} }
@@ -279,7 +278,7 @@
.form-input.is-error:focus, .form-input.is-error:focus,
.form-select.is-error:focus, .form-select.is-error:focus,
.form-textarea.is-error:focus { .form-textarea.is-error:focus {
box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.20); box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.2);
} }
/* Inline layout (label beside control) */ /* Inline layout (label beside control) */
@@ -325,7 +324,7 @@
} }
.form-checkbox input[type="checkbox"]:checked::after { .form-checkbox input[type="checkbox"]:checked::after {
content: ''; content: "";
position: absolute; position: absolute;
top: 2px; top: 2px;
left: 5px; left: 5px;
@@ -365,7 +364,7 @@
} }
.form-toggle input[type="checkbox"]::after { .form-toggle input[type="checkbox"]::after {
content: ''; content: "";
position: absolute; position: absolute;
top: 2px; top: 2px;
left: 2px; left: 2px;
@@ -373,7 +372,9 @@
height: 14px; height: 14px;
border-radius: 50%; border-radius: 50%;
background: var(--text-secondary); background: var(--text-secondary);
transition: transform var(--transition-normal), background var(--transition-normal); transition:
transform var(--transition-normal),
background var(--transition-normal);
} }
.form-toggle input[type="checkbox"]:checked { .form-toggle input[type="checkbox"]:checked {
@@ -385,7 +386,6 @@
background: var(--text-inverse); background: var(--text-inverse);
} }
/* ======================================================================= /* =======================================================================
BADGES BADGES
======================================================================= */ ======================================================================= */
@@ -429,23 +429,36 @@
gap: var(--space-1); gap: var(--space-1);
} }
.badge-status::before { .badge-status::before {
content: ''; content: "";
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: var(--text-tertiary); background: var(--text-tertiary);
flex-shrink: 0; flex-shrink: 0;
} }
.badge-status.is-connected::before { background: var(--success); } .badge-status.is-connected::before {
.badge-status.is-connecting::before { background: var(--warning); animation: pulse-dot 1.2s ease infinite; } background: var(--success);
.badge-status.is-error::before { background: var(--error); } }
.badge-status.is-disconnected::before { background: var(--text-tertiary); } .badge-status.is-connecting::before {
background: var(--warning);
@keyframes pulse-dot { animation: pulse-dot 1.2s ease infinite;
0%, 100% { opacity: 1; } }
50% { opacity: 0.4; } .badge-status.is-error::before {
background: var(--error);
}
.badge-status.is-disconnected::before {
background: var(--text-tertiary);
} }
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* ======================================================================= /* =======================================================================
CARDS & SURFACES CARDS & SURFACES
@@ -478,7 +491,6 @@
background: var(--bg-active); background: var(--bg-active);
} }
/* ======================================================================= /* =======================================================================
DROPDOWN / CONTEXT MENU DROPDOWN / CONTEXT MENU
======================================================================= */ ======================================================================= */
@@ -530,7 +542,9 @@
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast); transition:
background var(--transition-fast),
color var(--transition-fast);
border: none; border: none;
background: none; background: none;
text-align: left; text-align: left;
@@ -545,7 +559,7 @@
color: var(--error); color: var(--error);
} }
.dropdown-item.is-danger:hover { .dropdown-item.is-danger:hover {
background: rgba(239, 83, 80, 0.10); background: rgba(239, 83, 80, 0.1);
} }
.dropdown-item.is-disabled { .dropdown-item.is-disabled {
@@ -581,7 +595,6 @@
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
/* ======================================================================= /* =======================================================================
NOTIFICATIONS (toast) NOTIFICATIONS (toast)
======================================================================= */ ======================================================================= */
@@ -617,12 +630,24 @@
} }
@keyframes notification-in { @keyframes notification-in {
from { opacity: 0; transform: translateX(20px); } from {
to { opacity: 1; transform: translateX(0); } opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
} }
@keyframes notification-out { @keyframes notification-out {
from { opacity: 1; transform: translateX(0); } from {
to { opacity: 0; transform: translateX(20px); } opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(20px);
}
} }
.notification-icon { .notification-icon {
@@ -640,10 +665,18 @@
background: var(--text-tertiary); background: var(--text-tertiary);
} }
.notification-success .notification-icon { background: var(--success); } .notification-success .notification-icon {
.notification-error .notification-icon { background: var(--error); } background: var(--success);
.notification-warning .notification-icon { background: var(--warning); } }
.notification-info .notification-icon { background: var(--info); } .notification-error .notification-icon {
background: var(--error);
}
.notification-warning .notification-icon {
background: var(--warning);
}
.notification-info .notification-icon {
background: var(--info);
}
.notification-body { .notification-body {
flex: 1; flex: 1;
@@ -673,7 +706,9 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-tertiary); color: var(--text-tertiary);
cursor: pointer; cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast); transition:
background var(--transition-fast),
color var(--transition-fast);
} }
.notification-close:hover { .notification-close:hover {
background: var(--bg-hover); background: var(--bg-hover);
@@ -681,11 +716,18 @@
} }
/* Colour variants (left border accent) */ /* Colour variants (left border accent) */
.notification-success { border-left: 3px solid var(--success); } .notification-success {
.notification-error { border-left: 3px solid var(--error); } border-left: 3px solid var(--success);
.notification-warning { border-left: 3px solid var(--warning); } }
.notification-info { border-left: 3px solid var(--info); } .notification-error {
border-left: 3px solid var(--error);
}
.notification-warning {
border-left: 3px solid var(--warning);
}
.notification-info {
border-left: 3px solid var(--info);
}
/* ======================================================================= /* =======================================================================
DISCLOSURE / ACCORDION DISCLOSURE / ACCORDION
@@ -705,7 +747,9 @@
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast); transition:
background var(--transition-fast),
color var(--transition-fast);
user-select: none; user-select: none;
text-align: left; text-align: left;
background: none; background: none;
@@ -743,7 +787,6 @@
padding: 0 var(--space-4) var(--space-4); padding: 0 var(--space-4) var(--space-4);
} }
/* ======================================================================= /* =======================================================================
SEARCH INPUT SEARCH INPUT
======================================================================= */ ======================================================================= */
@@ -757,7 +800,9 @@
background: var(--bg-input); background: var(--bg-input);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: var(--radius-md); border-radius: var(--radius-md);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast); transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
} }
.search-input::placeholder { .search-input::placeholder {
@@ -780,7 +825,7 @@
} }
.search-wrapper::before { .search-wrapper::before {
content: '⌕'; content: "⌕";
position: absolute; position: absolute;
left: var(--space-3); left: var(--space-3);
top: 50%; top: 50%;
@@ -790,7 +835,6 @@
pointer-events: none; pointer-events: none;
} }
/* ======================================================================= /* =======================================================================
EMPTY STATE EMPTY STATE
======================================================================= */ ======================================================================= */
@@ -832,7 +876,6 @@
gap: var(--space-3); gap: var(--space-3);
} }
/* ======================================================================= /* =======================================================================
TOOLTIP TOOLTIP
======================================================================= */ ======================================================================= */
@@ -858,7 +901,6 @@
opacity: 1; opacity: 1;
} }
/* ======================================================================= /* =======================================================================
DIVIDER DIVIDER
======================================================================= */ ======================================================================= */
@@ -876,7 +918,6 @@
margin: 0 var(--space-2); margin: 0 var(--space-2);
} }
/* ======================================================================= /* =======================================================================
LOADING / SPINNER LOADING / SPINNER
======================================================================= */ ======================================================================= */
@@ -897,5 +938,280 @@
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
}
/* =======================================================================
CONTEXT MENUS
======================================================================= */
.context-menu {
position: fixed;
z-index: var(--z-modal, 1000);
min-width: 180px;
max-width: 260px;
padding: var(--space-1) 0;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.35),
0 1px 4px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.context-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 6px var(--space-3);
border: none;
background: none;
color: var(--text-primary);
font-size: var(--text-sm);
text-align: left;
cursor: pointer;
transition: background var(--transition-fast);
gap: var(--space-3);
}
.context-menu-item:hover {
background: var(--bg-hover);
}
.context-menu-item:active {
background: var(--bg-active);
}
.context-menu-item.context-menu-disabled {
color: var(--text-disabled);
cursor: not-allowed;
}
.context-menu-item.context-menu-disabled:hover {
background: none;
}
.context-menu-item.context-menu-danger {
color: var(--error);
}
.context-menu-item.context-menu-danger:hover {
background: rgba(239, 83, 80, 0.1);
}
.context-menu-label {
flex: 1;
min-width: 0;
}
.context-menu-shortcut {
font-size: 11px;
color: var(--text-tertiary);
flex-shrink: 0;
margin-left: var(--space-3);
}
.context-menu-divider {
height: 1px;
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);
} }
+10
View File
@@ -467,6 +467,16 @@
color: var(--danger); color: var(--danger);
} }
.settings-shell-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.settings-shell-row .form-input {
flex: 1;
}
.form-success { .form-success {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--success); color: var(--success);
+14
View File
@@ -169,6 +169,20 @@
background: var(--text-tertiary); background: var(--text-tertiary);
} }
/* Latency indicator */
#status-latency {
font-variant-numeric: tabular-nums;
}
#status-latency.latency-good {
color: var(--success);
}
#status-latency.latency-warn {
color: var(--warning);
}
#status-latency.latency-bad {
color: var(--error);
}
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Resize handle (between sidebar and workspace) Resize handle (between sidebar and workspace)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
+93
View File
@@ -419,3 +419,96 @@
font-size: 10px; font-size: 10px;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
/* -----------------------------------------------------------------------
Transfer progress queue
----------------------------------------------------------------------- */
.sftp-transfer-queue {
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-elevated);
max-height: 140px;
overflow-y: auto;
}
.sftp-transfer-header {
font-size: 10px;
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
padding: var(--space-1) var(--space-2) 0;
}
.sftp-transfer-row {
display: flex;
align-items: center;
gap: var(--space-1);
padding: 4px var(--space-2);
}
.sftp-transfer-icon {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--accent);
flex-shrink: 0;
width: 16px;
text-align: center;
}
.sftp-transfer-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sftp-transfer-name {
font-size: var(--text-xs);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sftp-transfer-progress {
height: 3px;
background: var(--border-subtle);
border-radius: 2px;
overflow: hidden;
}
.sftp-transfer-bar {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.15s ease-out;
}
.sftp-transfer-meta {
font-size: 10px;
color: var(--text-tertiary);
}
/* -----------------------------------------------------------------------
Bookmark list
----------------------------------------------------------------------- */
.sftp-bookmark-list {
max-height: 250px;
overflow-y: auto;
}
.sftp-bookmark-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast);
}
.sftp-bookmark-row:hover {
background: var(--bg-hover);
}
+21
View File
@@ -253,6 +253,12 @@
.sidebar-item .protocol-indicator.proto-github::before { .sidebar-item .protocol-indicator.proto-github::before {
content: "GH"; content: "GH";
} }
.sidebar-item .protocol-indicator.proto-vnc::before {
content: "⊞";
}
.sidebar-item .protocol-indicator.proto-local::before {
content: "›_";
}
.sidebar-item .protocol-indicator.proto-ssh { .sidebar-item .protocol-indicator.proto-ssh {
background: rgba(78, 205, 196, 0.18); background: rgba(78, 205, 196, 0.18);
@@ -270,6 +276,21 @@
background: rgba(124, 138, 255, 0.18); background: rgba(124, 138, 255, 0.18);
color: var(--protocol-github); color: var(--protocol-github);
} }
.sidebar-item .protocol-indicator.proto-vnc {
background: rgba(232, 93, 74, 0.18);
color: var(--protocol-vnc);
}
.sidebar-item .protocol-indicator.proto-local {
background: rgba(139, 195, 74, 0.18);
color: var(--protocol-local);
}
/* Local machine item separator */
.sidebar-item-local {
border-bottom: 1px solid var(--border-subtle);
margin: var(--space-2) var(--space-2) var(--space-2);
padding-bottom: var(--space-2);
}
/* Text block (name + host) */ /* Text block (name + host) */
.sidebar-item-text { .sidebar-item-text {
+51
View File
@@ -114,6 +114,14 @@
background: rgba(124, 138, 255, 0.15); background: rgba(124, 138, 255, 0.15);
color: var(--protocol-github); color: var(--protocol-github);
} }
.tab-badge.badge-vnc {
background: rgba(232, 93, 74, 0.15);
color: var(--protocol-vnc);
}
.tab-badge.badge-local {
background: rgba(139, 195, 74, 0.15);
color: var(--protocol-local);
}
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Status dot (connection state) Status dot (connection state)
@@ -163,6 +171,49 @@
background: var(--text-tertiary); background: var(--text-tertiary);
} }
/* -----------------------------------------------------------------------
Activity indicator — pulses when background tab has new output
----------------------------------------------------------------------- */
.tab-activity {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: var(--accent);
opacity: 0;
transition: opacity 150ms ease;
}
.tab-has-activity .tab-activity {
opacity: 1;
animation: pulse-dot 1.5s ease infinite;
}
.tab-active .tab-activity {
opacity: 0;
}
/* Trigger flash — brief glow when a trigger rule matches */
.tab-trigger-flash {
animation: trigger-flash 2s ease;
}
@keyframes trigger-flash {
0%,
100% {
background: var(--bg-hover);
}
15% {
background: rgba(239, 83, 80, 0.25);
}
30% {
background: var(--bg-hover);
}
45% {
background: rgba(239, 83, 80, 0.15);
}
}
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
SFTP toggle button — revealed on hover (SSH tabs only) SFTP toggle button — revealed on hover (SSH tabs only)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
+14 -9
View File
@@ -57,6 +57,8 @@
--protocol-telnet: #ffa726; --protocol-telnet: #ffa726;
--protocol-raw: #ab47bc; --protocol-raw: #ab47bc;
--protocol-github: #7c8aff; --protocol-github: #7c8aff;
--protocol-vnc: #e85d4a;
--protocol-local: #8bc34a;
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Spacing scale (4 px base unit) Spacing scale (4 px base unit)
@@ -75,10 +77,11 @@
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Typography Typography
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', --font-sans:
system-ui, sans-serif; "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, sans-serif;
'Cascadia Code', monospace; --font-mono:
"JetBrains Mono", "Fira Code", "SF Mono", Monaco, "Cascadia Code", monospace;
--text-xs: 11px; --text-xs: 11px;
--text-sm: 12px; --text-sm: 12px;
@@ -108,10 +111,10 @@
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Shadows Shadows
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.30); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.40); --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.50); --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.60); --shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.6);
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Transitions Transitions
@@ -145,7 +148,9 @@
Global input overrides — disable macOS/WebKit auto-capitalisation, Global input overrides — disable macOS/WebKit auto-capitalisation,
autocorrect, and spellcheck on all text fields. autocorrect, and spellcheck on all text fields.
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
input, textarea, select { input,
textarea,
select {
autocapitalize: none; autocapitalize: none;
autocorrect: off; autocorrect: off;
spellcheck: false; spellcheck: false;
+78
View File
@@ -0,0 +1,78 @@
/* 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);
}
+305 -10
View File
@@ -20,6 +20,8 @@
ExportImportUI, ExportImportUI,
SFTPBrowser, SFTPBrowser,
SnippetLibrary, SnippetLibrary,
SSHKeyManager,
TriggerEngine,
stateLabel, stateLabel,
} = window.NexusTerminal; } = window.NexusTerminal;
@@ -40,6 +42,8 @@
this.exportImport = new ExportImportUI(this); this.exportImport = new ExportImportUI(this);
this.sftp = new SFTPBrowser(this); this.sftp = new SFTPBrowser(this);
this.snippets = new SnippetLibrary(this); this.snippets = new SnippetLibrary(this);
this.sshKeys = new SSHKeyManager(this);
this.triggers = new TriggerEngine(this);
this.updateInfo = null; this.updateInfo = null;
} }
@@ -159,6 +163,14 @@
// Incoming session data (terminal output) // Incoming session data (terminal output)
window.runtime.EventsOn("session:data", (payload) => { window.runtime.EventsOn("session:data", (payload) => {
this.terminals.handleData(payload.sessionID, payload.data); this.terminals.handleData(payload.sessionID, payload.data);
this.tabs.markActivity(payload.sessionID);
// Check trigger rules against decoded text
try {
const raw = atob(payload.data);
this.triggers.check(payload.sessionID, raw);
} catch (_) {
/* noop */
}
}); });
// Session state transitions (connecting → connected → reconnecting → disconnected) // Session state transitions (connecting → connected → reconnecting → disconnected)
@@ -213,15 +225,19 @@
} }
} }
// Also re-fit to catch any layout changes that occurred while connecting. // Also re-fit to catch any layout changes that occurred while connecting.
// Multiple delays help byobu/tmux render correctly after its status
// bar initialisation completes.
const fit = () => this.terminals.fitSession(payload.sessionID); const fit = () => this.terminals.fitSession(payload.sessionID);
setTimeout(fit, 50); setTimeout(fit, 50);
setTimeout(fit, 300); setTimeout(fit, 300);
setTimeout(fit, 800);
} }
}); });
// Session closed by backend // Session closed by backend
window.runtime.EventsOn("session:closed", (payload) => { window.runtime.EventsOn("session:closed", (payload) => {
this.sftp.close(payload.sessionID); this.sftp.close(payload.sessionID);
this.triggers.cleanup(payload.sessionID);
this.tabs.handleSessionClosed(payload.sessionID); this.tabs.handleSessionClosed(payload.sessionID);
this.updateStatusBar(); this.updateStatusBar();
}); });
@@ -256,6 +272,14 @@
); );
}); });
// VNC WebSocket proxy ready — connect the viewer
window.runtime.EventsOn("session:vnc:ready", (payload) => {
window.NexusTerminal.VNCViewer.connect(
payload.sessionID,
payload.wsURL,
);
});
// App-wide commands from the native menu // App-wide commands from the native menu
window.runtime.EventsOn("app:open-about", () => { window.runtime.EventsOn("app:open-about", () => {
this.settings.openTab("about"); this.settings.openTab("about");
@@ -319,6 +343,11 @@
} }
}); });
// SFTP transfer progress events
window.runtime.EventsOn("session:sftp:progress", (payload) => {
this.sftp.handleTransferProgress(payload);
});
// Password/passphrase prompt requests from the backend // Password/passphrase prompt requests from the backend
window.runtime.EventsOn("session:secret:prompt", async (payload) => { window.runtime.EventsOn("session:secret:prompt", async (payload) => {
try { try {
@@ -354,6 +383,7 @@
this.terminals.destroyTerminal(sessionId); this.terminals.destroyTerminal(sessionId);
this.rawViewer.destroyViewer(sessionId); this.rawViewer.destroyViewer(sessionId);
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId); window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
window.NexusTerminal.VNCViewer.destroyViewer(sessionId);
this.state.removeSession(sessionId); this.state.removeSession(sessionId);
this.tabs.removeTab(sessionId); this.tabs.removeTab(sessionId);
this.updateStatusBar(); this.updateStatusBar();
@@ -501,12 +531,27 @@
setupUI() { setupUI() {
var btnEl = document.getElementById("btn-new-connection"); var btnEl = document.getElementById("btn-new-connection");
this._bindClick("btn-new-connection", () => this.profileEditor.openNew()); this._bindClick("btn-new-connection", () => this.profileEditor.openNew());
this._bindClick("btn-command-palette", () =>
this.commandPalette.toggle(),
);
this._bindClick("btn-settings", () => this.settings.open()); this._bindClick("btn-settings", () => this.settings.open());
this._bindClick("btn-import", () => this.importProfiles()); this._bindClick("btn-import", () => this.importProfiles());
this._bindClick("btn-export", () => this.exportProfiles()); this._bindClick("btn-export", () => this.exportProfiles());
this._bindClick("btn-recordings", () => this.recordings.openBrowser()); this._bindClick("btn-recordings", () => this.recordings.openBrowser());
this._bindClick("btn-new-tab", () => this.profileEditor.openNew()); this._bindClick("btn-new-tab", () => this.profileEditor.openNew());
this._bindClick("btn-broadcast", () => this.toggleBroadcast()); this._bindClick("btn-broadcast", () => this.toggleBroadcast());
// Wire the "Local Machine" sidebar button
this.sidebar.onConnectLocal = () => this.connectLocal();
// Right-click on broadcast button opens target selector
const broadcastBtn = document.getElementById("btn-broadcast");
if (broadcastBtn) {
broadcastBtn.addEventListener("contextmenu", (e) => {
e.preventDefault();
this.selectBroadcastTargets();
});
}
this._bindClick("btn-empty-connect", () => this.profileEditor.openNew()); this._bindClick("btn-empty-connect", () => this.profileEditor.openNew());
this._bindClick("btn-empty-quick", () => this.quickConnect()); this._bindClick("btn-empty-quick", () => this.quickConnect());
@@ -565,6 +610,9 @@
*/ */
toggleBroadcast() { toggleBroadcast() {
this.state.broadcastMode = !this.state.broadcastMode; this.state.broadcastMode = !this.state.broadcastMode;
if (!this.state.broadcastMode) {
this.state.broadcastTargets = null;
}
const btn = document.getElementById("btn-broadcast"); const btn = document.getElementById("btn-broadcast");
if (btn) { if (btn) {
btn.classList.toggle("is-active", this.state.broadcastMode); btn.classList.toggle("is-active", this.state.broadcastMode);
@@ -579,6 +627,115 @@
); );
} }
/**
* Opens a modal to select which sessions receive broadcast input.
*/
selectBroadcastTargets() {
const { createElement, escapeHtml } = window.NexusTerminal;
const sessions = this.state
.getAllSessions()
.filter(
(s) => s.state === "connected" && s.id !== this.state.activeSessionId,
);
if (sessions.length === 0) {
this.notifications.error("No other connected sessions to broadcast to");
return;
}
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
const currentTargets = this.state.broadcastTargets || new Set();
let listHtml = "";
for (const s of sessions) {
const profile = this.state.getProfile(s.profileId);
const label = profile
? profile.displayName || profile.host
: s.id.substring(0, 8);
const checked =
currentTargets.size === 0 || currentTargets.has(s.id)
? "checked"
: "";
listHtml += `
<label style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) 0;font-size:var(--text-sm);cursor:pointer;">
<input type="checkbox" class="broadcast-target-check" data-session-id="${s.id}" ${checked} />
<span style="color:var(--text-primary);">${escapeHtml(label)}</span>
</label>
`;
}
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Broadcast Targets</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-xs);color:var(--text-tertiary);margin-bottom:var(--space-2);">
Select which sessions should receive broadcast input:
</p>
${listHtml}
</div>
<div class="modal-footer">
<button class="btn btn-sm btn-ghost broadcast-cancel">Cancel</button>
<button class="btn btn-sm btn-secondary broadcast-all">Select All</button>
<button class="btn btn-sm btn-primary broadcast-apply">Apply & Enable</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector(".modal-close").addEventListener("click", close);
modal.querySelector(".broadcast-cancel").addEventListener("click", close);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
modal.querySelector(".broadcast-all").addEventListener("click", () => {
modal.querySelectorAll(".broadcast-target-check").forEach((cb) => {
cb.checked = true;
});
});
modal.querySelector(".broadcast-apply").addEventListener("click", () => {
const checks = modal.querySelectorAll(".broadcast-target-check");
const selected = new Set();
checks.forEach((cb) => {
if (cb.checked) selected.add(cb.dataset.sessionId);
});
if (selected.size === 0) {
this.notifications.error("Select at least one target session");
return;
}
// If all are selected, use null (broadcast to all)
this.state.broadcastTargets =
selected.size === sessions.length ? null : selected;
if (!this.state.broadcastMode) {
this.toggleBroadcast();
} else {
const targetLabel =
selected.size === sessions.length
? "all sessions"
: selected.size + " sessions";
this.notifications.info("Broadcasting to " + targetLabel);
}
close();
});
}
// ── Connection lifecycle ───────────────────────────────────────── // ── Connection lifecycle ─────────────────────────────────────────
/** /**
@@ -634,6 +791,15 @@
profile.displayName || profile.host, profile.displayName || profile.host,
); );
} }
} else if (profile.protocol === "vnc") {
const container = document.getElementById(panelId);
if (container) {
window.NexusTerminal.VNCViewer.createViewer(
sessionInfo.id,
container,
profile,
);
}
} else { } else {
this.terminals.createTerminal(sessionInfo.id, panelId, profile); this.terminals.createTerminal(sessionInfo.id, panelId, profile);
} }
@@ -646,6 +812,30 @@
} }
} }
/**
* Opens a local terminal session.
*/
async connectLocal() {
try {
const sessionInfo = await window.go.api.Service.ConnectLocal();
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
const panelId = `panel-${sessionInfo.id}`;
const prefs = this.state.preferences || {};
this.terminals.createTerminal(sessionInfo.id, panelId, {
terminalFontFamily: prefs.defaultFontFamily,
terminalFontSize: prefs.defaultFontSize,
terminalScrollback: prefs.defaultScrollback,
terminalCursorStyle: prefs.defaultCursorStyle,
});
this.notifications.info("Opening local terminal…");
} catch (err) {
this.notifications.error("Local terminal failed: " + err);
}
}
/** /**
* Opens a quick connect dialog for ad-hoc connections without saving a profile. * Opens a quick connect dialog for ad-hoc connections without saving a profile.
*/ */
@@ -865,6 +1055,7 @@
updateStatusBar() { updateStatusBar() {
const connEl = document.getElementById("status-connection"); const connEl = document.getElementById("status-connection");
const countEl = document.getElementById("status-session-count"); const countEl = document.getElementById("status-session-count");
const latencyEl = document.getElementById("status-latency");
const vaultEl = document.getElementById("status-vault"); const vaultEl = document.getElementById("status-vault");
const updateEl = document.getElementById("status-update"); const updateEl = document.getElementById("status-update");
if (!connEl) return; if (!connEl) return;
@@ -884,6 +1075,20 @@
'<span class="status-dot is-disconnected"></span> No active session'; '<span class="status-dot is-disconnected"></span> No active session';
} }
// Latency indicator — only show for connected SSH sessions.
if (latencyEl) {
if (
activeSession &&
activeSession.state === "connected" &&
activeSession.protocol === "ssh"
) {
this._startLatencyPing(activeSession.id);
} else {
this._stopLatencyPing();
latencyEl.textContent = "";
}
}
if (countEl) countEl.textContent = `${connected}/${total} sessions`; if (countEl) countEl.textContent = `${connected}/${total} sessions`;
if (vaultEl) { if (vaultEl) {
vaultEl.textContent = this.state.secretStoreUnlocked vaultEl.textContent = this.state.secretStoreUnlocked
@@ -905,6 +1110,49 @@
} }
} }
_startLatencyPing(sessionID) {
// Don't restart if already pinging the same session.
if (this._latencySessionID === sessionID && this._latencyTimer) return;
this._stopLatencyPing();
this._latencySessionID = sessionID;
const doPing = async () => {
if (!window.go || !window.go.api || !window.go.api.Service) return;
try {
const ms = await window.go.api.Service.PingSession(sessionID);
const el = document.getElementById("status-latency");
if (el && this._latencySessionID === sessionID) {
el.textContent = ms + " ms";
el.className =
ms < 100
? "latency-good"
: ms < 250
? "latency-warn"
: "latency-bad";
}
} catch (_) {
// Session may have disconnected — clear indicator.
const el = document.getElementById("status-latency");
if (el) {
el.textContent = "";
el.className = "";
}
this._stopLatencyPing();
}
};
doPing();
this._latencyTimer = setInterval(doPing, 10000);
}
_stopLatencyPing() {
if (this._latencyTimer) {
clearInterval(this._latencyTimer);
this._latencyTimer = null;
}
this._latencySessionID = null;
}
async checkForUpdates(silent) { async checkForUpdates(silent) {
if (!window.go || !window.go.api || !window.go.api.Service) { if (!window.go || !window.go.api || !window.go.api.Service) {
return; return;
@@ -1183,29 +1431,76 @@
const searchOpts = () => ({ caseSensitive, regex: useRegex }); const searchOpts = () => ({ caseSensitive, regex: useRegex });
let matchIndex = 0;
let totalMatches = 0;
const updateCount = (found) => {
if (!input.value) {
count.textContent = "";
matchIndex = 0;
totalMatches = 0;
return;
}
if (found) {
matchIndex++;
// Count total matches by searching the full buffer
const entry = this.terminals.terminals.get(sessionId);
if (entry) {
const buffer = entry.term.buffer.active;
let total = 0;
const query = input.value;
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (!line) continue;
const text = line.translateToString(true);
if (useRegex) {
try {
const re = new RegExp(query, caseSensitive ? "g" : "gi");
const m = text.match(re);
if (m) total += m.length;
} catch (_) {
/* invalid regex */
}
} else {
const searchText = caseSensitive ? text : text.toLowerCase();
const searchQuery = caseSensitive ? query : query.toLowerCase();
let pos = 0;
while ((pos = searchText.indexOf(searchQuery, pos)) !== -1) {
total++;
pos += searchQuery.length;
}
}
}
totalMatches = total;
if (matchIndex > totalMatches) matchIndex = 1;
count.textContent = matchIndex + " of " + totalMatches;
} else {
count.textContent = "Match found";
}
} else {
matchIndex = 0;
totalMatches = 0;
count.textContent = "No matches";
}
count.style.color = found ? "" : "var(--error)";
};
const doSearch = () => { const doSearch = () => {
const found = this.terminals.search( const found = this.terminals.search(
sessionId, sessionId,
input.value, input.value,
searchOpts(), searchOpts(),
); );
count.textContent = input.value updateCount(found);
? found
? "Match found"
: "No matches"
: "";
}; };
const doPrev = () => { const doPrev = () => {
matchIndex = Math.max(0, matchIndex - 2);
const found = this.terminals.searchPrevious( const found = this.terminals.searchPrevious(
sessionId, sessionId,
input.value, input.value,
searchOpts(), searchOpts(),
); );
count.textContent = input.value updateCount(found);
? found
? "Match found"
: "No matches"
: "";
}; };
const closeSearch = () => { const closeSearch = () => {
+152 -93
View File
@@ -18,7 +18,7 @@ class CommandPalette {
this._overlay = null; this._overlay = null;
/** @type {string} Current filter query. */ /** @type {string} Current filter query. */
this._query = ''; this._query = "";
/** @type {number} Index of the highlighted command. */ /** @type {number} Index of the highlighted command. */
this._selectedIndex = 0; this._selectedIndex = 0;
@@ -42,7 +42,7 @@ class CommandPalette {
*/ */
open() { open() {
if (this._overlay) return; if (this._overlay) return;
this._query = ''; this._query = "";
this._selectedIndex = 0; this._selectedIndex = 0;
this._render(); this._render();
} }
@@ -72,7 +72,7 @@ class CommandPalette {
cmds.push({ cmds.push({
id: `connect-${p.id}`, id: `connect-${p.id}`,
label: `Connect to ${p.displayName || p.host}`, label: `Connect to ${p.displayName || p.host}`,
section: 'Connections', section: "Connections",
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`, detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
action: () => this.app.connectProfile(p.id), action: () => this.app.connectProfile(p.id),
}); });
@@ -80,56 +80,64 @@ class CommandPalette {
// New connections // New connections
cmds.push({ cmds.push({
id: 'new-ssh', id: "new-ssh",
label: 'New SSH Connection', label: "New SSH Connection",
section: 'Actions', section: "Actions",
shortcut: '⌘N', shortcut: "⌘N",
action: () => this.app.profileEditor.openNew('ssh'), action: () => this.app.profileEditor.openNew("ssh"),
}); });
cmds.push({ cmds.push({
id: 'new-telnet', id: "new-telnet",
label: 'New Telnet Connection', label: "New Telnet Connection",
section: 'Actions', section: "Actions",
action: () => this.app.profileEditor.openNew('telnet'), action: () => this.app.profileEditor.openNew("telnet"),
}); });
cmds.push({ cmds.push({
id: 'new-raw', id: "new-raw",
label: 'New RAW Connection', label: "New RAW Connection",
section: 'Actions', section: "Actions",
action: () => this.app.profileEditor.openNew('raw'), action: () => this.app.profileEditor.openNew("raw"),
}); });
// Quick connect // Quick connect
cmds.push({ cmds.push({
id: 'quick-connect', id: "quick-connect",
label: 'Quick Connect…', label: "Quick Connect…",
section: 'Actions', section: "Actions",
action: () => this.app.quickConnect(), action: () => this.app.quickConnect(),
}); });
// Local terminal
cmds.push({
id: "local-terminal",
label: "Open Local Terminal",
section: "Actions",
action: () => this.app.connectLocal(),
});
// Tab actions // Tab actions
cmds.push({ cmds.push({
id: 'close-tab', id: "close-tab",
label: 'Close Tab', label: "Close Tab",
section: 'Tabs', section: "Tabs",
shortcut: '⌘W', shortcut: "⌘W",
action: () => this.app.tabs.closeActive(), action: () => this.app.tabs.closeActive(),
}); });
cmds.push({ cmds.push({
id: 'next-tab', id: "next-tab",
label: 'Next Tab', label: "Next Tab",
section: 'Tabs', section: "Tabs",
shortcut: '⌘Tab', shortcut: "⌘Tab",
action: () => { action: () => {
const next = this.app.tabs.getNextTab(); const next = this.app.tabs.getNextTab();
if (next) this.app.tabs.setActive(next); if (next) this.app.tabs.setActive(next);
}, },
}); });
cmds.push({ cmds.push({
id: 'prev-tab', id: "prev-tab",
label: 'Previous Tab', label: "Previous Tab",
section: 'Tabs', section: "Tabs",
shortcut: '⌘⇧Tab', shortcut: "⌘⇧Tab",
action: () => { action: () => {
const prev = this.app.tabs.getPrevTab(); const prev = this.app.tabs.getPrevTab();
if (prev) this.app.tabs.setActive(prev); if (prev) this.app.tabs.setActive(prev);
@@ -138,73 +146,106 @@ class CommandPalette {
// Settings & utility // Settings & utility
cmds.push({ cmds.push({
id: 'settings', id: "settings",
label: 'Settings', label: "Settings",
section: 'Application', section: "Application",
action: () => this.app.settings.open(), action: () => this.app.settings.open(),
}); });
cmds.push({ cmds.push({
id: 'import-profiles', id: "import-profiles",
label: 'Import Profiles', label: "Import Profiles",
section: 'Application', section: "Application",
action: () => this.app.importProfiles(), action: () => this.app.importProfiles(),
}); });
cmds.push({ cmds.push({
id: 'export-profiles', id: "export-profiles",
label: 'Export Profiles', label: "Export Profiles",
section: 'Application', section: "Application",
action: () => this.app.exportProfiles(), action: () => this.app.exportProfiles(),
}); });
// Secret store // Secret store
if (this.app.state.secretStoreUnlocked) { if (this.app.state.secretStoreUnlocked) {
cmds.push({ cmds.push({
id: 'lock-secrets', id: "lock-secrets",
label: 'Lock Secret Store', label: "Lock Secret Store",
section: 'Security', section: "Security",
action: async () => { action: async () => {
try { try {
await window.go.api.Service.LockSecretStore(); await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false); this.app.state.setSecretStoreState(false);
this.app.notifications.info('Secret store locked'); this.app.notifications.info("Secret store locked");
} catch (err) { } catch (err) {
this.app.notifications.error('Failed to lock: ' + err); this.app.notifications.error("Failed to lock: " + err);
} }
}, },
}); });
} else { } else {
cmds.push({ cmds.push({
id: 'unlock-secrets', id: "unlock-secrets",
label: 'Unlock Secret Store', label: "Unlock Secret Store",
section: 'Security', section: "Security",
action: async () => { action: async () => {
try { try {
const pw = await this.app.secretPrompt.promptMasterPassword(false); const pw =
await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw); await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true); this.app.state.setSecretStoreState(true);
this.app.notifications.success('Secret store unlocked'); this.app.notifications.success("Secret store unlocked");
} catch (err) { } catch (err) {
if (err.message !== 'User cancelled') { if (err.message !== "User cancelled") {
this.app.notifications.error('Unlock failed: ' + err); 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) // Terminal actions (only when a session is active)
if (this.app.state.activeSessionId) { if (this.app.state.activeSessionId) {
cmds.push({ cmds.push({
id: 'clear-terminal', id: "split-vertical",
label: 'Clear Terminal', label: "Split Pane Right",
section: 'Terminal', section: "Terminal",
action: () => this.app.terminals.clearTerminal(this.app.state.activeSessionId), shortcut: "⌘⇧D",
action: () => this.app.splitPane("vertical"),
}); });
cmds.push({ cmds.push({
id: 'reset-terminal', id: "split-horizontal",
label: 'Reset Terminal', label: "Split Pane Down",
section: 'Terminal', section: "Terminal",
action: () => this.app.terminals.resetTerminal(this.app.state.activeSessionId), 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),
}); });
} }
@@ -220,7 +261,10 @@ class CommandPalette {
if (!query) return this.getCommands(); if (!query) return this.getCommands();
return this.getCommands() return this.getCommands()
.map((cmd) => { .map((cmd) => {
const result = fuzzyMatch(cmd.label + ' ' + (cmd.detail || ''), query); const result = fuzzyMatch(
cmd.label + " " + (cmd.detail || ""),
query,
);
return { ...cmd, ...result }; return { ...cmd, ...result };
}) })
.filter((c) => c.match) .filter((c) => c.match)
@@ -233,7 +277,7 @@ class CommandPalette {
*/ */
execute(command) { execute(command) {
this.close(); this.close();
if (command && typeof command.action === 'function') { if (command && typeof command.action === "function") {
command.action(); command.action();
} }
} }
@@ -242,21 +286,21 @@ class CommandPalette {
/** @private */ /** @private */
_render() { _render() {
const overlay = createElement('div', 'palette-overlay'); const overlay = createElement("div", "palette-overlay");
const palette = createElement('div', 'palette'); const palette = createElement("div", "palette");
// Search input // Search input
const input = createElement('input', 'palette-input', { const input = createElement("input", "palette-input", {
type: 'text', type: "text",
placeholder: 'Type a command…', placeholder: "Type a command…",
autocomplete: 'off', autocomplete: "off",
spellcheck: 'false', spellcheck: "false",
}); });
palette.appendChild(input); palette.appendChild(input);
// Results list // Results list
const list = createElement('div', 'palette-list'); const list = createElement("div", "palette-list");
palette.appendChild(list); palette.appendChild(list);
overlay.appendChild(palette); overlay.appendChild(palette);
@@ -264,32 +308,35 @@ class CommandPalette {
this._overlay = overlay; this._overlay = overlay;
// Initial render // Initial render
this._renderList(list, this.filter('')); this._renderList(list, this.filter(""));
// Focus the input // Focus the input
input.focus(); input.focus();
// Event handlers // Event handlers
input.addEventListener('input', () => { input.addEventListener("input", () => {
this._query = input.value; this._query = input.value;
this._selectedIndex = 0; this._selectedIndex = 0;
this._renderList(list, this.filter(this._query)); this._renderList(list, this.filter(this._query));
}); });
input.addEventListener('keydown', (e) => { input.addEventListener("keydown", (e) => {
const items = list.querySelectorAll('.palette-item'); const items = list.querySelectorAll(".palette-item");
switch (e.key) { switch (e.key) {
case 'ArrowDown': case "ArrowDown":
e.preventDefault(); e.preventDefault();
this._selectedIndex = Math.min(this._selectedIndex + 1, items.length - 1); this._selectedIndex = Math.min(
this._selectedIndex + 1,
items.length - 1,
);
this._highlightItem(items); this._highlightItem(items);
break; break;
case 'ArrowUp': case "ArrowUp":
e.preventDefault(); e.preventDefault();
this._selectedIndex = Math.max(this._selectedIndex - 1, 0); this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
this._highlightItem(items); this._highlightItem(items);
break; break;
case 'Enter': { case "Enter": {
e.preventDefault(); e.preventDefault();
const selected = items[this._selectedIndex]; const selected = items[this._selectedIndex];
if (selected && selected._command) { if (selected && selected._command) {
@@ -297,7 +344,7 @@ class CommandPalette {
} }
break; break;
} }
case 'Escape': case "Escape":
e.preventDefault(); e.preventDefault();
this.close(); this.close();
break; break;
@@ -305,7 +352,7 @@ class CommandPalette {
}); });
// Click outside to close // Click outside to close
overlay.addEventListener('click', (e) => { overlay.addEventListener("click", (e) => {
if (e.target === overlay) this.close(); if (e.target === overlay) this.close();
}); });
} }
@@ -317,11 +364,13 @@ class CommandPalette {
* @private * @private
*/ */
_renderList(list, commands) { _renderList(list, commands) {
list.innerHTML = ''; list.innerHTML = "";
let currentSection = null; let currentSection = null;
if (commands.length === 0) { if (commands.length === 0) {
const empty = createElement('div', 'palette-empty', { textContent: 'No commands found' }); const empty = createElement("div", "palette-empty", {
textContent: "No commands found",
});
list.appendChild(empty); list.appendChild(empty);
return; return;
} }
@@ -332,33 +381,44 @@ class CommandPalette {
// Section header // Section header
if (cmd.section && cmd.section !== currentSection) { if (cmd.section && cmd.section !== currentSection) {
currentSection = cmd.section; currentSection = cmd.section;
const sectionEl = createElement('div', 'palette-section', { textContent: cmd.section }); const sectionEl = createElement("div", "palette-section", {
textContent: cmd.section,
});
list.appendChild(sectionEl); list.appendChild(sectionEl);
} }
const item = createElement('div', `palette-item ${i === this._selectedIndex ? 'palette-item-active' : ''}`); const item = createElement(
"div",
`palette-item ${i === this._selectedIndex ? "palette-item-active" : ""}`,
);
item._command = cmd; item._command = cmd;
// Label // Label
const label = createElement('span', 'palette-item-label', { textContent: cmd.label }); const label = createElement("span", "palette-item-label", {
textContent: cmd.label,
});
item.appendChild(label); item.appendChild(label);
// Detail // Detail
if (cmd.detail) { if (cmd.detail) {
const detail = createElement('span', 'palette-item-detail', { textContent: cmd.detail }); const detail = createElement("span", "palette-item-detail", {
textContent: cmd.detail,
});
item.appendChild(detail); item.appendChild(detail);
} }
// Shortcut badge // Shortcut badge
if (cmd.shortcut) { if (cmd.shortcut) {
const badge = createElement('span', 'palette-item-shortcut', { textContent: cmd.shortcut }); const badge = createElement("span", "palette-item-shortcut", {
textContent: cmd.shortcut,
});
item.appendChild(badge); item.appendChild(badge);
} }
item.addEventListener('click', () => this.execute(cmd)); item.addEventListener("click", () => this.execute(cmd));
item.addEventListener('mouseenter', () => { item.addEventListener("mouseenter", () => {
this._selectedIndex = i; this._selectedIndex = i;
this._highlightItem(list.querySelectorAll('.palette-item')); this._highlightItem(list.querySelectorAll(".palette-item"));
}); });
list.appendChild(item); list.appendChild(item);
@@ -372,16 +432,15 @@ class CommandPalette {
*/ */
_highlightItem(items) { _highlightItem(items) {
items.forEach((el, i) => { items.forEach((el, i) => {
el.classList.toggle('palette-item-active', i === this._selectedIndex); el.classList.toggle("palette-item-active", i === this._selectedIndex);
}); });
const active = items[this._selectedIndex]; const active = items[this._selectedIndex];
if (active) { if (active) {
active.scrollIntoView({ block: 'nearest' }); active.scrollIntoView({ block: "nearest" });
} }
} }
} }
window.NexusTerminal = window.NexusTerminal || {}; window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.CommandPalette = CommandPalette; window.NexusTerminal.CommandPalette = CommandPalette;
})(); })();
+185 -3
View File
@@ -65,6 +65,27 @@
refreshIntervalSecs: 300, refreshIntervalSecs: 300,
}, },
}, },
vnc: {
port: 5900,
protocol: "vnc",
vnc: {
vncHost: "",
vncPort: 5900,
secretMode: "prompt",
viewOnly: false,
scaleToFit: true,
quality: 6,
sharedSession: true,
sshTunnel: false,
sshHost: "",
sshPort: 22,
sshUsername: "",
sshAuthMethod: "publickey",
sshSecretMode: "saved",
sshPrivateKey: "",
sshAgentAuth: false,
},
},
}; };
class ProfileEditor { class ProfileEditor {
@@ -141,7 +162,7 @@
drawer.appendChild(header); drawer.appendChild(header);
// ── Protocol selector tabs ────────────────────────────── // ── Protocol selector tabs ──────────────────────────────
const protocols = ["ssh", "telnet", "raw", "github"]; const protocols = ["ssh", "telnet", "raw", "github", "vnc"];
const tabBar = createElement("div", "drawer-protocol-tabs"); const tabBar = createElement("div", "drawer-protocol-tabs");
for (const proto of protocols) { for (const proto of protocols) {
const label = proto === "github" ? "GitHub" : proto.toUpperCase(); const label = proto === "github" ? "GitHub" : proto.toUpperCase();
@@ -184,10 +205,17 @@
body.appendChild(s), body.appendChild(s),
); );
break; break;
case "vnc":
this._renderVNCSections(profile).forEach((s) => body.appendChild(s));
break;
} }
// Terminal section (SSH and Telnet only) // Terminal section (SSH and Telnet only)
if (profile.protocol !== "raw" && profile.protocol !== "github") { if (
profile.protocol !== "raw" &&
profile.protocol !== "github" &&
profile.protocol !== "vnc"
) {
body.appendChild(this._renderTerminalSection(profile)); body.appendChild(this._renderTerminalSection(profile));
} }
@@ -922,6 +950,131 @@
return sections; return sections;
} }
/** @private */
_renderVNCSections(profile) {
const sections = [];
const vnc = profile.vnc || DEFAULTS.vnc.vnc;
// VNC connection settings
sections.push(
this._renderSection("VNC Server", [
this._field("text", "vnc.vncHost", "VNC Host", vnc.vncHost, {
placeholder: "192.168.1.100",
}),
this._field("number", "vnc.vncPort", "VNC Port", vnc.vncPort, {
min: 1,
max: 65535,
}),
this._field(
"select",
"vnc.secretMode",
"Password Storage",
vnc.secretMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "prompt", label: "Ask every time" },
],
},
),
]),
);
// Display settings
sections.push(
this._renderSection("Display", [
this._field("checkbox", "vnc.viewOnly", "View Only", vnc.viewOnly),
this._field(
"checkbox",
"vnc.scaleToFit",
"Scale to Fit",
vnc.scaleToFit,
),
this._field(
"checkbox",
"vnc.sharedSession",
"Shared Session",
vnc.sharedSession,
),
this._field("number", "vnc.quality", "Quality (1-9)", vnc.quality, {
min: 1,
max: 9,
}),
]),
);
// SSH tunnel section
sections.push(
this._renderSection("SSH Tunnel", [
this._field(
"checkbox",
"vnc.sshTunnel",
"Enable SSH Tunnel",
vnc.sshTunnel,
),
this._field("text", "vnc.sshHost", "SSH Host", vnc.sshHost, {
placeholder: "jump.example.com",
}),
this._field("number", "vnc.sshPort", "SSH Port", vnc.sshPort, {
min: 1,
max: 65535,
}),
this._field(
"text",
"vnc.sshUsername",
"SSH Username",
vnc.sshUsername,
{
placeholder: "admin",
},
),
this._field(
"select",
"vnc.sshAuthMethod",
"SSH Auth Method",
vnc.sshAuthMethod,
{
options: [
{ value: "publickey", label: "Public Key" },
{ value: "password", label: "Password" },
],
},
),
this._field(
"text",
"vnc.sshPrivateKey",
"SSH Private Key Path",
vnc.sshPrivateKey,
{
placeholder: "~/.ssh/id_ed25519",
},
),
this._field(
"checkbox",
"vnc.sshAgentAuth",
"Use SSH Agent",
vnc.sshAgentAuth,
),
this._field(
"select",
"vnc.sshSecretMode",
"SSH Secret Storage",
vnc.sshSecretMode,
{
options: [
{ value: "saved", label: "Save to keychain" },
{ value: "session-only", label: "Remember for session" },
{ value: "prompt", label: "Ask every time" },
],
},
),
]),
);
return sections;
}
/** @private */ /** @private */
_renderTerminalSection(profile) { _renderTerminalSection(profile) {
return this._renderSection("Terminal", [ return this._renderSection("Terminal", [
@@ -2102,6 +2255,26 @@
p.github.secretMode = val("github.secretMode") || "saved"; p.github.secretMode = val("github.secretMode") || "saved";
p.github.refreshIntervalSecs = val("github.refreshIntervalSecs") || 300; p.github.refreshIntervalSecs = val("github.refreshIntervalSecs") || 300;
} }
// VNC-specific
if (p.protocol === "vnc") {
if (!p.vnc) p.vnc = {};
p.vnc.vncHost = val("vnc.vncHost") || "";
p.vnc.vncPort = val("vnc.vncPort") || 5900;
p.vnc.secretMode = val("vnc.secretMode") || "prompt";
p.vnc.viewOnly = val("vnc.viewOnly") || false;
p.vnc.scaleToFit = val("vnc.scaleToFit") !== false;
p.vnc.quality = val("vnc.quality") || 6;
p.vnc.sharedSession = val("vnc.sharedSession") !== false;
p.vnc.sshTunnel = val("vnc.sshTunnel") || false;
p.vnc.sshHost = val("vnc.sshHost") || "";
p.vnc.sshPort = val("vnc.sshPort") || 22;
p.vnc.sshUsername = val("vnc.sshUsername") || "";
p.vnc.sshAuthMethod = val("vnc.sshAuthMethod") || "publickey";
p.vnc.sshPrivateKey = val("vnc.sshPrivateKey") || "";
p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false;
p.vnc.sshSecretMode = val("vnc.sshSecretMode") || "saved";
}
} }
// ── Validation ─────────────────────────────────────────────────── // ── Validation ───────────────────────────────────────────────────
@@ -2114,7 +2287,14 @@
*/ */
_validate(profile) { _validate(profile) {
const errors = []; const errors = [];
if (!profile.host || !profile.host.trim()) { if (profile.protocol === "vnc") {
const vncHost = (profile.vnc && profile.vnc.vncHost) || profile.host;
if (!vncHost || !vncHost.trim()) {
errors.push(
"VNC Host is required (in General or VNC Server section).",
);
}
} else if (!profile.host || !profile.host.trim()) {
errors.push("Host is required."); errors.push("Host is required.");
} }
if (!profile.port || profile.port < 1 || profile.port > 65535) { if (!profile.port || profile.port < 1 || profile.port > 65535) {
@@ -2283,6 +2463,8 @@
ssh: null, ssh: null,
telnet: null, telnet: null,
raw: null, raw: null,
github: null,
vnc: null,
}; };
const defaults = DEFAULTS[protocol] || {}; const defaults = DEFAULTS[protocol] || {};
+46
View File
@@ -53,6 +53,12 @@
this._knownHosts = []; this._knownHosts = [];
} }
try {
this._defaultShell = await window.go.api.Service.GetDefaultShell();
} catch (_) {
this._defaultShell = "";
}
this._render(); this._render();
} }
@@ -281,6 +287,45 @@
{ min: 180, max: 500 }, { min: 180, max: 500 },
), ),
); );
// Local shell path with browse button
const shellGroup = createElement("div", "form-group settings-field");
const shellLabel = createElement("label", "form-label", {
textContent: "Local shell",
for: "setting-localShell",
});
shellGroup.appendChild(shellLabel);
const shellRow = createElement("div", "settings-shell-row");
const shellInput = createElement("input", "form-input", {
type: "text",
id: "setting-localShell",
name: "setting-localShell",
value: p.localShell || "",
placeholder: this._defaultShell || "/bin/zsh",
});
shellRow.appendChild(shellInput);
const browseBtn = createElement("button", "btn btn-sm btn-secondary", {
textContent: "Browse…",
type: "button",
});
browseBtn.addEventListener("click", async () => {
try {
const path = await window.go.api.Service.BrowseForShell();
if (path) {
shellInput.value = path;
}
} catch (_) {}
});
shellRow.appendChild(browseBtn);
shellGroup.appendChild(shellRow);
const shellHint = createElement("span", "form-hint", {
textContent: "Leave empty to use system default",
});
shellGroup.appendChild(shellHint);
body.appendChild(shellGroup);
} }
/** /**
@@ -1050,6 +1095,7 @@
"defaultScrollback", "defaultScrollback",
"defaultCursorStyle", "defaultCursorStyle",
"useNativePaste", "useNativePaste",
"localShell",
]; ];
for (const field of fields) { for (const field of fields) {
+412 -18
View File
@@ -231,6 +231,16 @@
var actions = createElement("div", "sftp-toolbar-actions"); var actions = createElement("div", "sftp-toolbar-actions");
var goToBtn = this._toolbarBtn("⤳", "Go to path", "");
goToBtn.addEventListener("click", () => this._promptGoToPath(sessionId));
actions.appendChild(goToBtn);
var bookmarkBtn = this._toolbarBtn("★", "Bookmarks", "");
bookmarkBtn.addEventListener("click", () =>
this._showBookmarks(sessionId),
);
actions.appendChild(bookmarkBtn);
var mkdirBtn = this._toolbarBtn("+", "New folder", "is-accent"); var mkdirBtn = this._toolbarBtn("+", "New folder", "is-accent");
mkdirBtn.addEventListener("click", () => this._promptMkdir(sessionId)); mkdirBtn.addEventListener("click", () => this._promptMkdir(sessionId));
actions.appendChild(mkdirBtn); actions.appendChild(mkdirBtn);
@@ -426,7 +436,7 @@
if (!isParent) { if (!isParent) {
row.addEventListener("click", function (e) { row.addEventListener("click", function (e) {
e.stopPropagation(); e.stopPropagation();
self._selectFile(sessionId, entry, row); self._selectFile(sessionId, entry, row, e);
}); });
} }
@@ -468,52 +478,104 @@
/* ── Selection & context actions ──────────────────────────────── */ /* ── Selection & context actions ──────────────────────────────── */
_selectFile(sessionId, entry, rowEl) { _selectFile(sessionId, entry, rowEl, event) {
var state = this.panels.get(sessionId); var state = this.panels.get(sessionId);
if (!state) return; if (!state) return;
// Deselect previous // Initialise multi-select set if needed
var prev = state.el.querySelector(".sftp-file-row.is-selected"); if (!state.selectedFiles) state.selectedFiles = new Map();
if (prev) prev.classList.remove("is-selected");
// If clicking the same entry, deselect var multiKey = event && (event.metaKey || event.ctrlKey);
if (state.selected === entry.path) {
if (multiKey) {
// Toggle this entry in the multi-selection
if (state.selectedFiles.has(entry.path)) {
state.selectedFiles.delete(entry.path);
rowEl.classList.remove("is-selected");
} else {
state.selectedFiles.set(entry.path, entry);
rowEl.classList.add("is-selected");
}
} else {
// Single click without modifier — clear all, select this one
state.el
.querySelectorAll(".sftp-file-row.is-selected")
.forEach(function (r) {
r.classList.remove("is-selected");
});
// If clicking the same entry (and it was the only selection), deselect
if (
state.selectedFiles.size === 1 &&
state.selectedFiles.has(entry.path)
) {
state.selectedFiles.clear();
state.selected = null; state.selected = null;
var ctxBar = this._getCtxBar(sessionId); var ctxBar = this._getCtxBar(sessionId);
if (ctxBar) ctxBar.style.display = "none"; if (ctxBar) ctxBar.style.display = "none";
return; return;
} }
state.selected = entry.path; state.selectedFiles.clear();
state.selectedFiles.set(entry.path, entry);
rowEl.classList.add("is-selected"); rowEl.classList.add("is-selected");
this._showContextBar(sessionId, entry);
} }
_showContextBar(sessionId, entry) { state.selected = state.selectedFiles.size > 0 ? entry.path : null;
this._showContextBar(sessionId, null);
}
_showContextBar(sessionId, _unused) {
var state = this.panels.get(sessionId);
if (!state) return;
var ctxBar = this._getCtxBar(sessionId); var ctxBar = this._getCtxBar(sessionId);
if (!ctxBar) return; if (!ctxBar) return;
if (!state.selectedFiles || state.selectedFiles.size === 0) {
ctxBar.style.display = "none";
return;
}
ctxBar.innerHTML = ""; ctxBar.innerHTML = "";
ctxBar.style.display = "flex"; ctxBar.style.display = "flex";
var self = this;
var entries = Array.from(state.selectedFiles.values());
var count = entries.length;
var isSingle = count === 1;
var entry = entries[0];
var label = createElement("span", "sftp-ctx-label", { var label = createElement("span", "sftp-ctx-label", {
textContent: entry.name, textContent: isSingle ? entry.name : count + " items selected",
title: entry.path, title: isSingle
? entry.path
: entries
.map(function (e) {
return e.name;
})
.join(", "),
}); });
ctxBar.appendChild(label); ctxBar.appendChild(label);
var self = this; // Download button — only for files (single or batch)
var downloadableFiles = entries.filter(function (e) {
if (!entry.isDir) { return !e.isDir;
});
if (downloadableFiles.length > 0) {
var dlLabel =
downloadableFiles.length === 1
? "↓ Download"
: "↓ Download (" + downloadableFiles.length + ")";
var dlBtn = createElement("button", "sftp-ctx-btn is-accent", { var dlBtn = createElement("button", "sftp-ctx-btn is-accent", {
textContent: "↓ Download", textContent: dlLabel,
}); });
dlBtn.addEventListener("click", function () { dlBtn.addEventListener("click", function () {
self._download(sessionId, entry.path); self._downloadBatch(sessionId, downloadableFiles);
}); });
ctxBar.appendChild(dlBtn); ctxBar.appendChild(dlBtn);
} }
if (isSingle) {
var renameBtn = createElement("button", "sftp-ctx-btn", { var renameBtn = createElement("button", "sftp-ctx-btn", {
textContent: "✎ Rename", textContent: "✎ Rename",
}); });
@@ -521,12 +583,18 @@
self._promptRename(sessionId, entry); self._promptRename(sessionId, entry);
}); });
ctxBar.appendChild(renameBtn); ctxBar.appendChild(renameBtn);
}
var delLabel = isSingle ? "✕ Delete" : "✕ Delete (" + count + ")";
var delBtn = createElement("button", "sftp-ctx-btn is-danger", { var delBtn = createElement("button", "sftp-ctx-btn is-danger", {
textContent: "✕ Delete", textContent: delLabel,
}); });
delBtn.addEventListener("click", function () { delBtn.addEventListener("click", function () {
if (isSingle) {
self._confirmDelete(sessionId, entry); self._confirmDelete(sessionId, entry);
} else {
self._confirmDeleteBatch(sessionId, entries);
}
}); });
ctxBar.appendChild(delBtn); ctxBar.appendChild(delBtn);
} }
@@ -598,6 +666,59 @@
} }
} }
async _downloadBatch(sessionId, files) {
this.app.notifications.info("Downloading " + files.length + " files…");
var failed = 0;
for (var i = 0; i < files.length; i++) {
try {
await window.go.api.Service.SFTPDownload(sessionId, files[i].path);
} catch (err) {
failed++;
this.app.notifications.error(
"Download failed: " + files[i].name + " — " + (err.message || err),
);
}
}
if (failed === 0) {
this.app.notifications.success(
"All " + files.length + " files downloaded",
);
}
}
_confirmDeleteBatch(sessionId, entries) {
this._showConfirmModal({
title: "Delete " + entries.length + " Items",
message:
"Permanently delete <strong>" +
entries.length +
" selected items</strong>?",
confirmLabel: "Delete All",
isDanger: true,
onConfirm: function () {
this._deleteBatch(sessionId, entries);
}.bind(this),
});
}
async _deleteBatch(sessionId, entries) {
var failed = 0;
for (var i = 0; i < entries.length; i++) {
try {
await window.go.api.Service.SFTPRemove(sessionId, entries[i].path);
} catch (err) {
failed++;
this.app.notifications.error(
"Delete failed: " + entries[i].name + " — " + (err.message || err),
);
}
}
if (failed === 0) {
this.app.notifications.success("Deleted " + entries.length + " items");
}
await this._refresh(sessionId);
}
/* ── Modal dialogs (no prompt/confirm/alert) ──────────────────── */ /* ── Modal dialogs (no prompt/confirm/alert) ──────────────────── */
_promptMkdir(sessionId) { _promptMkdir(sessionId) {
@@ -784,6 +905,279 @@
}); });
} }
/* ── Go to path & Bookmarks ───────────────────────────────────── */
_promptGoToPath(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
this._showInputModal({
title: "Go to Path",
placeholder: "/path/to/directory",
initialValue: state.cwd,
confirmLabel: "Navigate",
onConfirm: function (value) {
var path = value.trim();
if (path) this._navigate(sessionId, path);
}.bind(this),
});
}
_showBookmarks(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
var self = this;
var bookmarks = this._getBookmarks(sessionId);
var backdrop = createElement("div", "modal-backdrop");
var modal = createElement("div", "modal modal-sm");
var listHtml = "";
if (bookmarks.length === 0) {
listHtml =
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);text-align:center;padding:var(--space-3);">No bookmarks yet. Navigate to a directory and click "Bookmark Current".</div>';
} else {
for (var i = 0; i < bookmarks.length; i++) {
listHtml +=
'<div class="sftp-bookmark-row" data-index="' +
i +
'" data-path="' +
escapeHtml(bookmarks[i]) +
'">';
listHtml +=
'<span style="flex:1;font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' +
escapeHtml(bookmarks[i]) +
"</span>";
listHtml +=
'<button class="btn btn-xs btn-ghost sftp-bookmark-del" data-index="' +
i +
'" title="Remove">✕</button>';
listHtml += "</div>";
}
}
modal.innerHTML =
'<div class="modal-header">' +
'<span class="modal-title">Directory Bookmarks</span>' +
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
"</div>" +
'<div class="modal-body">' +
'<div class="sftp-bookmark-list">' +
listHtml +
"</div>" +
"</div>" +
'<div class="modal-footer">' +
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Close</button>' +
'<button class="btn btn-sm btn-primary sftp-bookmark-add">★ Bookmark Current</button>' +
"</div>";
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(function () {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
var closed = false;
function close() {
if (closed) return;
closed = true;
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(function () {
backdrop.remove();
}, 200);
}
modal.querySelector(".modal-close").addEventListener("click", close);
modal
.querySelector(".sftp-modal-cancel")
.addEventListener("click", close);
backdrop.addEventListener("click", function (e) {
if (e.target === backdrop) close();
});
// Navigate to bookmark on click
modal.querySelectorAll(".sftp-bookmark-row").forEach(function (row) {
row.addEventListener("click", function (e) {
if (e.target.classList.contains("sftp-bookmark-del")) return;
var path = row.dataset.path;
close();
self._navigate(sessionId, path);
});
row.addEventListener("mousedown", function (e) {
e.preventDefault();
});
});
// Delete bookmark
modal.querySelectorAll(".sftp-bookmark-del").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.stopPropagation();
var idx = parseInt(btn.dataset.index, 10);
self._removeBookmark(sessionId, idx);
close();
self._showBookmarks(sessionId);
});
});
// Add current directory as bookmark
modal
.querySelector(".sftp-bookmark-add")
.addEventListener("click", function () {
self._addBookmark(sessionId, state.cwd);
close();
self._showBookmarks(sessionId);
});
}
_getBookmarks(sessionId) {
try {
var key = "sftp-bookmarks-" + sessionId;
var stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : [];
} catch (_) {
return [];
}
}
_addBookmark(sessionId, path) {
var bookmarks = this._getBookmarks(sessionId);
if (bookmarks.indexOf(path) === -1) {
bookmarks.push(path);
localStorage.setItem(
"sftp-bookmarks-" + sessionId,
JSON.stringify(bookmarks),
);
this.app.notifications.success("Bookmarked: " + path);
} else {
this.app.notifications.info("Already bookmarked");
}
}
_removeBookmark(sessionId, index) {
var bookmarks = this._getBookmarks(sessionId);
bookmarks.splice(index, 1);
localStorage.setItem(
"sftp-bookmarks-" + sessionId,
JSON.stringify(bookmarks),
);
}
/* ── Transfer progress queue ─────────────────────────────────── */
/**
* Handles a transfer progress event from the backend.
* @param {Object} payload - { sessionID, direction, transferred, total, path, done }
*/
handleTransferProgress(payload) {
var state = this.panels.get(payload.sessionID);
if (!state) {
// Panel not open — still track so we can show a notification on completion
if (payload.done) {
var dir =
payload.direction === "upload" ? "↑ Uploaded" : "↓ Downloaded";
var name = payload.path.split("/").pop() || payload.path;
this.app.notifications.success(dir + " " + name);
}
return;
}
// Initialise transfers map if needed
if (!state.transfers) state.transfers = new Map();
var key = payload.direction + ":" + payload.path;
if (payload.done) {
state.transfers.delete(key);
this._renderTransferQueue(payload.sessionID);
// Refresh file list after upload completes
if (payload.direction === "upload") {
this._refresh(payload.sessionID);
}
return;
}
state.transfers.set(key, {
direction: payload.direction,
path: payload.path,
name: payload.path.split("/").pop() || payload.path,
transferred: payload.transferred,
total: payload.total,
});
this._renderTransferQueue(payload.sessionID);
}
/**
* Renders the transfer queue panel showing active transfers.
*/
_renderTransferQueue(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
var queue = state.el.querySelector(".sftp-transfer-queue");
if (!state.transfers || state.transfers.size === 0) {
if (queue) queue.remove();
return;
}
if (!queue) {
queue = createElement("div", "sftp-transfer-queue");
// Insert between toolbar and file-list
var fileList = state.el.querySelector(".sftp-file-list");
state.el.insertBefore(queue, fileList);
}
queue.innerHTML = "";
var header = createElement("div", "sftp-transfer-header");
header.textContent = "Transfers (" + state.transfers.size + ")";
queue.appendChild(header);
var self = this;
state.transfers.forEach(function (transfer) {
var row = createElement("div", "sftp-transfer-row");
var icon = createElement("span", "sftp-transfer-icon");
icon.textContent = transfer.direction === "upload" ? "↑" : "↓";
row.appendChild(icon);
var info = createElement("div", "sftp-transfer-info");
var nameEl = createElement("span", "sftp-transfer-name", {
textContent: transfer.name,
title: transfer.path,
});
info.appendChild(nameEl);
var progress = createElement("div", "sftp-transfer-progress");
var bar = createElement("div", "sftp-transfer-bar");
var pct =
transfer.total > 0
? Math.min(
100,
Math.round((transfer.transferred / transfer.total) * 100),
)
: 0;
bar.style.width = pct + "%";
progress.appendChild(bar);
info.appendChild(progress);
var meta = createElement("span", "sftp-transfer-meta");
var transferredStr = formatBytes(transfer.transferred);
var totalStr = transfer.total > 0 ? formatBytes(transfer.total) : "?";
meta.textContent =
transferredStr + " / " + totalStr + " (" + pct + "%)";
info.appendChild(meta);
row.appendChild(info);
queue.appendChild(row);
});
}
/* ── Resize handle ────────────────────────────────────────────── */ /* ── Resize handle ────────────────────────────────────────────── */
_setupResize(sessionId, state) { _setupResize(sessionId, state) {
+33
View File
@@ -68,6 +68,9 @@
this.el.appendChild(this._renderBulkActions()); this.el.appendChild(this._renderBulkActions());
} }
// Local Machine quick-connect button
this.el.appendChild(this._renderLocalMachineItem());
// Favourites // Favourites
const favourites = profiles.filter((p) => p.favourite); const favourites = profiles.filter((p) => p.favourite);
if (favourites.length > 0) { if (favourites.length > 0) {
@@ -531,6 +534,36 @@
); );
} }
/** @private */
_renderLocalMachineItem() {
const item = createElement("div", "sidebar-item sidebar-item-local");
item.addEventListener("mousedown", (e) => e.preventDefault());
const indicator = createElement(
"span",
"protocol-indicator proto-local",
{
title: "Local",
},
);
item.appendChild(indicator);
const textWrapper = createElement("div", "sidebar-item-text");
const nameEl = createElement("span", "sidebar-item-name", {
textContent: "Local Machine",
});
textWrapper.appendChild(nameEl);
item.appendChild(textWrapper);
item.addEventListener("click", () => {
if (this.onConnectLocal) {
this.onConnectLocal();
}
});
return item;
}
/** @private */ /** @private */
_renderBulkActions() { _renderBulkActions() {
const bar = createElement("div", "sidebar-bulk-actions"); const bar = createElement("div", "sidebar-bulk-actions");
+221 -73
View File
@@ -4,7 +4,8 @@
* Provides a panel for managing and sending reusable commands to active sessions. * Provides a panel for managing and sending reusable commands to active sessions.
*/ */
const { createElement, escapeHtml, dismissContextMenus } = window.NexusTerminal; const { createElement, escapeHtml, dismissContextMenus } =
window.NexusTerminal;
class SnippetLibrary { class SnippetLibrary {
/** /**
@@ -14,7 +15,7 @@ class SnippetLibrary {
this.app = app; this.app = app;
/** @type {Array<Object>} */ /** @type {Array<Object>} */
this.snippets = []; this.snippets = [];
this.searchQuery = ''; this.searchQuery = "";
} }
// ── Public API ─────────────────────────────────────────────────── // ── Public API ───────────────────────────────────────────────────
@@ -29,26 +30,140 @@ class SnippetLibrary {
/** /**
* Sends a snippet command to the active session. * Sends a snippet command to the active session.
* Supports {{variable}} placeholders that prompt the user for values.
* Built-in variables: {{hostname}}, {{date}}, {{time}}, {{timestamp}}.
* @param {string} command * @param {string} command
*/ */
sendToActiveSession(command) { sendToActiveSession(command) {
const sessionId = this.app.state.activeSessionId; const sessionId = this.app.state.activeSessionId;
if (!sessionId) { if (!sessionId) {
this.app.notifications.error('No active session'); this.app.notifications.error("No active session");
return; return;
} }
const session = this.app.state.getSession(sessionId); const session = this.app.state.getSession(sessionId);
if (!session || session.state !== 'connected') { if (!session || session.state !== "connected") {
this.app.notifications.error('Session is not connected'); this.app.notifications.error("Session is not connected");
return; return;
} }
try {
const b64 = btoa(command + '\n'); // Check for template variables
window.go.api.Service.WriteSession(sessionId, b64); const varPattern = /\{\{([^}]+)\}\}/g;
this.app.notifications.info('Snippet sent'); const matches = [];
} catch (err) { let match;
this.app.notifications.error('Failed to send snippet: ' + err); while ((match = varPattern.exec(command)) !== null) {
matches.push(match[1].trim());
} }
// Resolve built-in variables first
const builtins = {
hostname: session.host || "unknown",
date: new Date().toISOString().split("T")[0],
time: new Date().toTimeString().split(" ")[0],
timestamp: Math.floor(Date.now() / 1000).toString(),
};
let resolved = command;
const userVars = [];
for (const varName of matches) {
const lower = varName.toLowerCase();
if (builtins[lower] !== undefined) {
resolved = resolved.replace("{{" + varName + "}}", builtins[lower]);
} else if (!userVars.includes(varName)) {
userVars.push(varName);
}
}
if (userVars.length === 0) {
this._sendCommand(sessionId, resolved);
return;
}
// Prompt for user-defined variables
this._promptVariables(userVars, (values) => {
let final = resolved;
for (let i = 0; i < userVars.length; i++) {
final = final.split("{{" + userVars[i] + "}}").join(values[i] || "");
}
this._sendCommand(sessionId, final);
});
}
/** @private */
_sendCommand(sessionId, command) {
try {
const b64 = btoa(command + "\n");
window.go.api.Service.WriteSession(sessionId, b64);
this.app.notifications.info("Snippet sent");
} catch (err) {
this.app.notifications.error("Failed to send snippet: " + err);
}
}
/** @private */
_promptVariables(varNames, onConfirm) {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
let inputsHtml = "";
for (const v of varNames) {
inputsHtml += `
<div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">${escapeHtml(v)}</label>
<input type="text" class="form-input snippet-var-input" data-var="${escapeHtml(v)}" placeholder="Enter value for ${escapeHtml(v)}" />
</div>
`;
}
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Snippet Variables</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">${inputsHtml}</div>
<div class="modal-footer">
<button class="btn btn-sm btn-ghost snippet-var-cancel">Cancel</button>
<button class="btn btn-sm btn-primary snippet-var-send">Send</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
const firstInput = modal.querySelector(".snippet-var-input");
if (firstInput) firstInput.focus();
});
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector(".modal-close").addEventListener("click", close);
modal
.querySelector(".snippet-var-cancel")
.addEventListener("click", close);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
modal.querySelector(".snippet-var-send").addEventListener("click", () => {
const inputs = modal.querySelectorAll(".snippet-var-input");
const values = Array.from(inputs).map((inp) => inp.value);
close();
onConfirm(values);
});
// Enter to send
modal.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
modal.querySelector(".snippet-var-send").click();
}
if (e.key === "Escape") close();
});
} }
// ── Data ───────────────────────────────────────────────────────── // ── Data ─────────────────────────────────────────────────────────
@@ -56,10 +171,10 @@ class SnippetLibrary {
/** @private */ /** @private */
async _loadSnippets() { async _loadSnippets() {
try { try {
this.snippets = await window.go.api.Service.ListSnippets() || []; this.snippets = (await window.go.api.Service.ListSnippets()) || [];
} catch (err) { } catch (err) {
this.snippets = []; this.snippets = [];
console.error('Failed to load snippets:', err); console.error("Failed to load snippets:", err);
} }
} }
@@ -67,8 +182,8 @@ class SnippetLibrary {
/** @private */ /** @private */
_showModal() { _showModal() {
const backdrop = createElement('div', 'modal-backdrop'); const backdrop = createElement("div", "modal-backdrop");
const modal = createElement('div', 'modal modal-lg'); const modal = createElement("div", "modal modal-lg");
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-header"> <div class="modal-header">
@@ -94,28 +209,30 @@ class SnippetLibrary {
backdrop.appendChild(modal); backdrop.appendChild(modal);
document.body.appendChild(backdrop); document.body.appendChild(backdrop);
requestAnimationFrame(() => { requestAnimationFrame(() => {
backdrop.classList.add('is-visible'); backdrop.classList.add("is-visible");
modal.classList.add('is-visible'); modal.classList.add("is-visible");
}); });
const close = () => { const close = () => {
backdrop.classList.remove('is-visible'); backdrop.classList.remove("is-visible");
modal.classList.remove('is-visible'); modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200); setTimeout(() => backdrop.remove(), 200);
}; };
modal.querySelector('.modal-close').addEventListener('click', close); modal.querySelector(".modal-close").addEventListener("click", close);
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); }); backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
// Search // Search
const searchInput = modal.querySelector('.snippet-search'); const searchInput = modal.querySelector(".snippet-search");
searchInput.addEventListener('input', () => { searchInput.addEventListener("input", () => {
this.searchQuery = searchInput.value.trim().toLowerCase(); this.searchQuery = searchInput.value.trim().toLowerCase();
this._renderList(modal, close); this._renderList(modal, close);
}); });
// New snippet button // New snippet button
modal.querySelector('.snippet-add-btn').addEventListener('click', () => { modal.querySelector(".snippet-add-btn").addEventListener("click", () => {
this._showEditor(modal, null, close); this._showEditor(modal, null, close);
}); });
@@ -124,87 +241,106 @@ class SnippetLibrary {
/** @private */ /** @private */
_renderList(modal, closeFn) { _renderList(modal, closeFn) {
const listEl = modal.querySelector('.snippet-list'); const listEl = modal.querySelector(".snippet-list");
if (!listEl) return; if (!listEl) return;
listEl.innerHTML = ''; listEl.innerHTML = "";
const filtered = this.snippets.filter((sn) => { const filtered = this.snippets.filter((sn) => {
if (!this.searchQuery) return true; if (!this.searchQuery) return true;
return sn.name.toLowerCase().includes(this.searchQuery) || return (
sn.name.toLowerCase().includes(this.searchQuery) ||
sn.command.toLowerCase().includes(this.searchQuery) || sn.command.toLowerCase().includes(this.searchQuery) ||
(sn.description || '').toLowerCase().includes(this.searchQuery); (sn.description || "").toLowerCase().includes(this.searchQuery)
);
}); });
if (filtered.length === 0) { if (filtered.length === 0) {
listEl.innerHTML = '<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>'; listEl.innerHTML =
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>';
return; return;
} }
for (const sn of filtered) { for (const sn of filtered) {
const item = createElement('div', 'snippet-item', { const item = createElement("div", "snippet-item", {
dataset: { id: sn.id }, dataset: { id: sn.id },
}); });
item.style.cssText = 'padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);'; item.style.cssText =
"padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);";
item.innerHTML = ` item.innerHTML = `
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);color:var(--text-primary);">${escapeHtml(sn.name)}</div> <div style="font-weight:var(--weight-medium);font-size:var(--text-sm);color:var(--text-primary);">${escapeHtml(sn.name)}</div>
<div style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-secondary);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(sn.command)}</div> <div style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-secondary);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(sn.command)}</div>
`; `;
item.addEventListener('click', () => this._showDetail(modal, sn, closeFn)); item.addEventListener("click", () =>
item.addEventListener('mousedown', (e) => e.preventDefault()); this._showDetail(modal, sn, closeFn),
item.addEventListener('mouseenter', () => { item.style.background = 'var(--bg-hover)'; }); );
item.addEventListener('mouseleave', () => { item.style.background = ''; }); item.addEventListener("mousedown", (e) => e.preventDefault());
item.addEventListener("mouseenter", () => {
item.style.background = "var(--bg-hover)";
});
item.addEventListener("mouseleave", () => {
item.style.background = "";
});
listEl.appendChild(item); listEl.appendChild(item);
} }
} }
/** @private */ /** @private */
_showDetail(modal, snippet, closeFn) { _showDetail(modal, snippet, closeFn) {
const detailPanel = modal.querySelector('.snippet-detail-panel'); const detailPanel = modal.querySelector(".snippet-detail-panel");
if (!detailPanel) return; if (!detailPanel) return;
detailPanel.innerHTML = ''; detailPanel.innerHTML = "";
const nameEl = createElement('div', '', { const nameEl = createElement("div", "", {
innerHTML: `<strong style="font-size:var(--text-base);">${escapeHtml(snippet.name)}</strong>`, innerHTML: `<strong style="font-size:var(--text-base);">${escapeHtml(snippet.name)}</strong>`,
}); });
detailPanel.appendChild(nameEl); detailPanel.appendChild(nameEl);
if (snippet.description) { if (snippet.description) {
const descEl = createElement('div', '', { const descEl = createElement("div", "", {
textContent: snippet.description, textContent: snippet.description,
}); });
descEl.style.cssText = 'font-size:var(--text-sm);color:var(--text-secondary);'; descEl.style.cssText =
"font-size:var(--text-sm);color:var(--text-secondary);";
detailPanel.appendChild(descEl); detailPanel.appendChild(descEl);
} }
const cmdEl = createElement('pre', '', { const cmdEl = createElement("pre", "", {
textContent: snippet.command, textContent: snippet.command,
}); });
cmdEl.style.cssText = 'flex:1;font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-base);padding:var(--space-3);border-radius:var(--radius-md);overflow:auto;color:var(--text-primary);margin:0;white-space:pre-wrap;word-break:break-all;'; cmdEl.style.cssText =
"flex:1;font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-base);padding:var(--space-3);border-radius:var(--radius-md);overflow:auto;color:var(--text-primary);margin:0;white-space:pre-wrap;word-break:break-all;";
detailPanel.appendChild(cmdEl); detailPanel.appendChild(cmdEl);
// Action buttons // Action buttons
const actions = createElement('div', '', {}); const actions = createElement("div", "", {});
actions.style.cssText = 'display:flex;gap:var(--space-2);'; actions.style.cssText = "display:flex;gap:var(--space-2);";
const sendBtn = createElement('button', 'btn btn-sm btn-primary', { textContent: 'Send to Session' }); const sendBtn = createElement("button", "btn btn-sm btn-primary", {
sendBtn.addEventListener('click', () => { textContent: "Send to Session",
});
sendBtn.addEventListener("click", () => {
this.sendToActiveSession(snippet.command); this.sendToActiveSession(snippet.command);
}); });
const editBtn = createElement('button', 'btn btn-sm btn-secondary', { textContent: 'Edit' }); const editBtn = createElement("button", "btn btn-sm btn-secondary", {
editBtn.addEventListener('click', () => { textContent: "Edit",
});
editBtn.addEventListener("click", () => {
this._showEditor(modal, snippet, closeFn); this._showEditor(modal, snippet, closeFn);
}); });
const deleteBtn = createElement('button', 'btn btn-sm btn-danger', { textContent: 'Delete' }); const deleteBtn = createElement("button", "btn btn-sm btn-danger", {
deleteBtn.addEventListener('click', async () => { textContent: "Delete",
});
deleteBtn.addEventListener("click", async () => {
try { try {
await window.go.api.Service.DeleteSnippet(snippet.id); await window.go.api.Service.DeleteSnippet(snippet.id);
this.snippets = this.snippets.filter((s) => s.id !== snippet.id); this.snippets = this.snippets.filter((s) => s.id !== snippet.id);
this._renderList(modal, closeFn); this._renderList(modal, closeFn);
detailPanel.innerHTML = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>'; detailPanel.innerHTML =
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>';
} catch (err) { } catch (err) {
this.app.notifications.error('Failed to delete: ' + err); this.app.notifications.error("Failed to delete: " + err);
} }
}); });
@@ -216,30 +352,30 @@ class SnippetLibrary {
/** @private */ /** @private */
_showEditor(modal, snippet, closeFn) { _showEditor(modal, snippet, closeFn) {
const detailPanel = modal.querySelector('.snippet-detail-panel'); const detailPanel = modal.querySelector(".snippet-detail-panel");
if (!detailPanel) return; if (!detailPanel) return;
detailPanel.innerHTML = ''; detailPanel.innerHTML = "";
const isNew = !snippet; const isNew = !snippet;
const title = isNew ? 'New Snippet' : 'Edit Snippet'; const title = isNew ? "New Snippet" : "Edit Snippet";
detailPanel.innerHTML = ` detailPanel.innerHTML = `
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);">${title}</div> <div style="font-weight:var(--weight-medium);font-size:var(--text-sm);">${title}</div>
<div class="form-group" style="margin-bottom:var(--space-2);"> <div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Name</label> <label class="form-label">Name</label>
<input type="text" class="form-input snippet-ed-name" value="${isNew ? '' : escapeHtml(snippet.name)}" /> <input type="text" class="form-input snippet-ed-name" value="${isNew ? "" : escapeHtml(snippet.name)}" />
</div> </div>
<div class="form-group" style="margin-bottom:var(--space-2);"> <div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<input type="text" class="form-input snippet-ed-desc" value="${isNew ? '' : escapeHtml(snippet.description || '')}" /> <input type="text" class="form-input snippet-ed-desc" value="${isNew ? "" : escapeHtml(snippet.description || "")}" />
</div> </div>
<div class="form-group" style="flex:1;display:flex;flex-direction:column;"> <div class="form-group" style="flex:1;display:flex;flex-direction:column;">
<label class="form-label">Command</label> <label class="form-label">Command</label>
<textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? '' : escapeHtml(snippet.command)}</textarea> <textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? "" : escapeHtml(snippet.command)}</textarea>
</div> </div>
<div class="form-group" style="margin-bottom:var(--space-2);"> <div class="form-group" style="margin-bottom:var(--space-2);">
<label class="form-label">Folder</label> <label class="form-label">Folder</label>
<input type="text" class="form-input snippet-ed-folder" value="${isNew ? '' : escapeHtml(snippet.folder || '')}" placeholder="e.g. Networking, Debugging" /> <input type="text" class="form-input snippet-ed-folder" value="${isNew ? "" : escapeHtml(snippet.folder || "")}" placeholder="e.g. Networking, Debugging" />
</div> </div>
<div style="display:flex;gap:var(--space-2);"> <div style="display:flex;gap:var(--space-2);">
<button class="btn btn-sm btn-primary snippet-save-btn">Save</button> <button class="btn btn-sm btn-primary snippet-save-btn">Save</button>
@@ -247,29 +383,37 @@ class SnippetLibrary {
</div> </div>
`; `;
detailPanel.querySelector('.snippet-save-btn').addEventListener('click', async () => { detailPanel
const name = detailPanel.querySelector('.snippet-ed-name').value.trim(); .querySelector(".snippet-save-btn")
const command = detailPanel.querySelector('.snippet-ed-cmd').value; .addEventListener("click", async () => {
const description = detailPanel.querySelector('.snippet-ed-desc').value.trim(); const name = detailPanel
const folder = detailPanel.querySelector('.snippet-ed-folder').value.trim(); .querySelector(".snippet-ed-name")
.value.trim();
const command = detailPanel.querySelector(".snippet-ed-cmd").value;
const description = detailPanel
.querySelector(".snippet-ed-desc")
.value.trim();
const folder = detailPanel
.querySelector(".snippet-ed-folder")
.value.trim();
if (!name) { if (!name) {
this.app.notifications.error('Name is required'); this.app.notifications.error("Name is required");
return; return;
} }
if (!command) { if (!command) {
this.app.notifications.error('Command is required'); this.app.notifications.error("Command is required");
return; return;
} }
try { try {
const data = { const data = {
id: isNew ? '' : snippet.id, id: isNew ? "" : snippet.id,
name, name,
command, command,
description, description,
folder, folder,
tags: isNew ? [] : (snippet.tags || []), tags: isNew ? [] : snippet.tags || [],
}; };
let saved; let saved;
@@ -284,17 +428,22 @@ class SnippetLibrary {
this._renderList(modal, closeFn); this._renderList(modal, closeFn);
this._showDetail(modal, saved, closeFn); this._showDetail(modal, saved, closeFn);
this.app.notifications.info(isNew ? 'Snippet created' : 'Snippet updated'); this.app.notifications.info(
isNew ? "Snippet created" : "Snippet updated",
);
} catch (err) { } catch (err) {
this.app.notifications.error('Failed to save snippet: ' + err); this.app.notifications.error("Failed to save snippet: " + err);
} }
}); });
detailPanel.querySelector('.snippet-cancel-btn').addEventListener('click', () => { detailPanel
.querySelector(".snippet-cancel-btn")
.addEventListener("click", () => {
if (snippet) { if (snippet) {
this._showDetail(modal, snippet, closeFn); this._showDetail(modal, snippet, closeFn);
} else { } else {
detailPanel.innerHTML = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>'; detailPanel.innerHTML =
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>';
} }
}); });
} }
@@ -302,5 +451,4 @@ class SnippetLibrary {
window.NexusTerminal = window.NexusTerminal || {}; window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SnippetLibrary = SnippetLibrary; window.NexusTerminal.SnippetLibrary = SnippetLibrary;
})(); })();
+310
View File
@@ -0,0 +1,310 @@
(function () {
/**
* Nexus Terminal — SSH Key Manager.
* Panel for listing, generating, copying, and deleting SSH key pairs.
*/
const { createElement, dismissContextMenus } = window.NexusTerminal;
class SSHKeyManager {
constructor(app) {
this.app = app;
this._backdrop = null;
}
// ── Public API ───────────────────────────────────────────────────
open() {
if (this._backdrop) return;
this._render();
this._loadKeys();
}
close() {
if (this._backdrop) {
this._backdrop.classList.remove("is-visible");
const modal = this._backdrop.querySelector(".modal");
if (modal) modal.classList.remove("is-visible");
setTimeout(() => {
if (this._backdrop) {
this._backdrop.remove();
this._backdrop = null;
}
}, 200);
}
}
// ── Rendering ────────────────────────────────────────────────────
_render() {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">SSH Key Manager</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="sshkey-toolbar">
<button class="btn btn-sm btn-accent sshkey-generate-btn">Generate New Key</button>
</div>
<div class="sshkey-list"></div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
this._backdrop = backdrop;
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
// Events
modal
.querySelector(".modal-close")
.addEventListener("click", () => this.close());
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) this.close();
});
modal
.querySelector(".sshkey-generate-btn")
.addEventListener("click", () => this._showGenerateForm());
}
async _loadKeys() {
const list = this._backdrop.querySelector(".sshkey-list");
if (!list) return;
list.innerHTML = '<div class="sshkey-loading">Loading keys…</div>';
try {
const keys = await window.go.api.Service.ListSSHKeyPairs();
if (keys.length === 0) {
list.innerHTML =
'<div class="sshkey-empty">No SSH keys found in ~/.ssh/</div>';
return;
}
list.innerHTML = "";
for (const key of keys) {
list.appendChild(this._renderKeyRow(key));
}
} catch (err) {
list.innerHTML = `<div class="sshkey-error">Failed to load keys: ${err}</div>`;
}
}
_renderKeyRow(key) {
const row = createElement("div", "sshkey-row");
const info = createElement("div", "sshkey-info");
const name = createElement("div", "sshkey-name", {
textContent: key.name,
});
const meta = createElement("div", "sshkey-meta", {
textContent: key.type.toUpperCase() + (key.bits ? ` ${key.bits}` : ""),
});
info.appendChild(name);
info.appendChild(meta);
const actions = createElement("div", "sshkey-actions");
const copyBtn = createElement("button", "btn btn-xs", {
textContent: "Copy Public Key",
});
copyBtn.addEventListener("mousedown", (e) => e.preventDefault());
copyBtn.addEventListener("click", () => this._copyPublicKey(key));
const deleteBtn = createElement("button", "btn btn-xs btn-danger", {
textContent: "Delete",
});
deleteBtn.addEventListener("mousedown", (e) => e.preventDefault());
deleteBtn.addEventListener("click", () => this._confirmDelete(key));
actions.appendChild(copyBtn);
actions.appendChild(deleteBtn);
row.appendChild(info);
row.appendChild(actions);
return row;
}
async _copyPublicKey(key) {
try {
const pubKey = await window.go.api.Service.GetSSHPublicKey(key.name);
if (window.runtime && window.runtime.ClipboardSetText) {
window.runtime.ClipboardSetText(pubKey);
} else {
await navigator.clipboard.writeText(pubKey);
}
this.app.notifications.success("Public key copied to clipboard");
} catch (err) {
this.app.notifications.error("Failed to copy: " + err);
}
}
_confirmDelete(key) {
const confirmed = this._showConfirm(
"Delete Key",
`Delete key pair "${key.name}"? This cannot be undone.`,
"Delete",
);
confirmed.then((ok) => {
if (ok) this._deleteKey(key);
});
}
async _deleteKey(key) {
try {
await window.go.api.Service.DeleteSSHKey(key.name);
this.app.notifications.success(`Key "${key.name}" deleted`);
this._loadKeys();
} catch (err) {
this.app.notifications.error("Delete failed: " + err);
}
}
// ── Generate form ────────────────────────────────────────────────
_showGenerateForm() {
const list = this._backdrop.querySelector(".sshkey-list");
if (!list) return;
const form = createElement("div", "sshkey-generate-form");
form.innerHTML = `
<div class="form-group">
<label class="form-label">Key Name</label>
<input type="text" class="form-input sshkey-name-input" placeholder="id_ed25519_nexus" autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label class="form-label">Type</label>
<select class="form-input sshkey-type-select">
<option value="ed25519" selected>Ed25519 (recommended)</option>
<option value="rsa">RSA</option>
<option value="ecdsa">ECDSA</option>
</select>
</div>
<div class="form-group sshkey-bits-group" style="display:none">
<label class="form-label">Bits</label>
<select class="form-input sshkey-bits-select">
<option value="4096" selected>4096</option>
<option value="2048">2048</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Passphrase (optional)</label>
<input type="password" class="form-input sshkey-pass-input" placeholder="Leave blank for no passphrase" autocomplete="off" />
</div>
<div class="sshkey-form-actions">
<button class="btn btn-sm sshkey-cancel-btn">Cancel</button>
<button class="btn btn-sm btn-accent sshkey-create-btn">Generate</button>
</div>
`;
// Insert form above the key list
list.parentNode.insertBefore(form, list);
list.style.display = "none";
// Show bits field for RSA
const typeSelect = form.querySelector(".sshkey-type-select");
const bitsGroup = form.querySelector(".sshkey-bits-group");
typeSelect.addEventListener("change", () => {
bitsGroup.style.display = typeSelect.value === "rsa" ? "" : "none";
});
form.querySelector(".sshkey-cancel-btn").addEventListener("click", () => {
form.remove();
list.style.display = "";
});
form
.querySelector(".sshkey-create-btn")
.addEventListener("click", async () => {
const nameInput = form.querySelector(".sshkey-name-input");
const name = nameInput.value.trim();
const keyType = typeSelect.value;
const bits = parseInt(
form.querySelector(".sshkey-bits-select").value,
10,
);
const passphrase = form.querySelector(".sshkey-pass-input").value;
if (!name) {
this.app.notifications.error("Key name is required");
return;
}
try {
await window.go.api.Service.GenerateSSHKey(
name,
keyType,
bits,
passphrase,
);
this.app.notifications.success(
`Key "${name}" generated successfully`,
);
form.remove();
list.style.display = "";
this._loadKeys();
} catch (err) {
this.app.notifications.error("Generation failed: " + err);
}
});
form.querySelector(".sshkey-name-input").focus();
}
// ── Confirmation helper ──────────────────────────────────────────
_showConfirm(title, message, confirmLabel) {
return new Promise((resolve) => {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">${title}</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-sm);color:var(--text-secondary)">${message}</p>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-danger confirm-ok">${confirmLabel}</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const close = (val) => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
resolve(val);
};
modal
.querySelector(".modal-close")
.addEventListener("click", () => close(false));
modal
.querySelector(".confirm-cancel")
.addEventListener("click", () => close(false));
modal
.querySelector(".confirm-ok")
.addEventListener("click", () => close(true));
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close(false);
});
});
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SSHKeyManager = SSHKeyManager;
})();
+22 -19
View File
@@ -28,6 +28,9 @@ class State {
/** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */ /** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */
this.broadcastMode = false; this.broadcastMode = false;
/** @type {Set<string>|null} Session IDs to broadcast to. Null means all. */
this.broadcastTargets = null;
/** @type {Map<string, Set<Function>>} event name → callbacks */ /** @type {Map<string, Set<Function>>} event name → callbacks */
this._listeners = new Map(); this._listeners = new Map();
} }
@@ -84,7 +87,7 @@ class State {
*/ */
setProfiles(profiles) { setProfiles(profiles) {
this.profiles = profiles; this.profiles = profiles;
this.emit('profiles:changed', this.profiles); this.emit("profiles:changed", this.profiles);
} }
/** /**
@@ -93,8 +96,8 @@ class State {
*/ */
addProfile(profile) { addProfile(profile) {
this.profiles.push(profile); this.profiles.push(profile);
this.emit('profiles:changed', this.profiles); this.emit("profiles:changed", this.profiles);
this.emit('profile:added', profile); this.emit("profile:added", profile);
} }
/** /**
@@ -106,8 +109,8 @@ class State {
if (idx !== -1) { if (idx !== -1) {
this.profiles[idx] = profile; this.profiles[idx] = profile;
} }
this.emit('profiles:changed', this.profiles); this.emit("profiles:changed", this.profiles);
this.emit('profile:updated', profile); this.emit("profile:updated", profile);
} }
/** /**
@@ -116,8 +119,8 @@ class State {
*/ */
removeProfile(id) { removeProfile(id) {
this.profiles = this.profiles.filter((p) => p.id !== id); this.profiles = this.profiles.filter((p) => p.id !== id);
this.emit('profiles:changed', this.profiles); this.emit("profiles:changed", this.profiles);
this.emit('profile:removed', id); this.emit("profile:removed", id);
} }
/** /**
@@ -137,8 +140,8 @@ class State {
*/ */
addSession(sessionInfo) { addSession(sessionInfo) {
this.sessions.set(sessionInfo.id, sessionInfo); this.sessions.set(sessionInfo.id, sessionInfo);
this.emit('sessions:changed', this.sessions); this.emit("sessions:changed", this.sessions);
this.emit('session:added', sessionInfo); this.emit("session:added", sessionInfo);
} }
/** /**
@@ -150,8 +153,8 @@ class State {
const existing = this.sessions.get(id); const existing = this.sessions.get(id);
if (existing) { if (existing) {
Object.assign(existing, updates); Object.assign(existing, updates);
this.emit('sessions:changed', this.sessions); this.emit("sessions:changed", this.sessions);
this.emit('session:updated', existing); this.emit("session:updated", existing);
} }
} }
@@ -164,10 +167,11 @@ class State {
if (this.activeSessionId === id) { if (this.activeSessionId === id) {
// Activate the most recent remaining session, or null // Activate the most recent remaining session, or null
const remaining = [...this.sessions.keys()]; const remaining = [...this.sessions.keys()];
this.activeSessionId = remaining.length > 0 ? remaining[remaining.length - 1] : null; this.activeSessionId =
remaining.length > 0 ? remaining[remaining.length - 1] : null;
} }
this.emit('sessions:changed', this.sessions); this.emit("sessions:changed", this.sessions);
this.emit('session:removed', id); this.emit("session:removed", id);
} }
/** /**
@@ -185,7 +189,7 @@ class State {
*/ */
setActiveSession(id) { setActiveSession(id) {
this.activeSessionId = id; this.activeSessionId = id;
this.emit('session:activated', id); this.emit("session:activated", id);
} }
/** /**
@@ -204,7 +208,7 @@ class State {
*/ */
setPreferences(prefs) { setPreferences(prefs) {
this.preferences = prefs; this.preferences = prefs;
this.emit('preferences:changed', this.preferences); this.emit("preferences:changed", this.preferences);
} }
// ── Secret store ───────────────────────────────────────────────── // ── Secret store ─────────────────────────────────────────────────
@@ -215,7 +219,7 @@ class State {
*/ */
setSecretStoreState(unlocked) { setSecretStoreState(unlocked) {
this.secretStoreUnlocked = unlocked; this.secretStoreUnlocked = unlocked;
this.emit('secretStore:changed', unlocked); this.emit("secretStore:changed", unlocked);
} }
/** /**
@@ -224,11 +228,10 @@ class State {
*/ */
setSecretStoreInitialised(initialised) { setSecretStoreInitialised(initialised) {
this.secretStoreInitialised = initialised; this.secretStoreInitialised = initialised;
this.emit('secretStore:init', initialised); this.emit("secretStore:init", initialised);
} }
} }
window.NexusTerminal = window.NexusTerminal || {}; window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.State = State; window.NexusTerminal.State = State;
})(); })();
+34 -3
View File
@@ -100,9 +100,12 @@
setActive(sessionId) { setActive(sessionId) {
this.app.state.setActiveSession(sessionId); this.app.state.setActiveSession(sessionId);
// Highlight active tab // Highlight active tab and clear activity indicator
this.tabList.querySelectorAll(".tab").forEach((t) => { this.tabList.querySelectorAll(".tab").forEach((t) => {
t.classList.toggle("tab-active", t.dataset.sessionId === sessionId); t.classList.toggle("tab-active", t.dataset.sessionId === sessionId);
if (t.dataset.sessionId === sessionId) {
t.classList.remove("tab-has-activity");
}
}); });
// Show corresponding panel, hide others // Show corresponding panel, hide others
@@ -116,11 +119,18 @@
this.app.sidebar.highlightActive(session.profileId); this.app.sidebar.highlightActive(session.profileId);
} }
// Fit the terminal for the newly active session // Fit and refresh the terminal for the newly active session.
this.app.terminals.fitActive(); // Double-RAF ensures the panel has layout dimensions before fitting.
requestAnimationFrame(() => {
this.app.terminals.fitSession(sessionId);
const entry = this.app.terminals.terminals.get(sessionId);
if (entry) {
entry.term.refresh(0, entry.term.rows - 1);
}
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.app.terminals.focusTerminal(sessionId); this.app.terminals.focusTerminal(sessionId);
}); });
});
} }
/** /**
@@ -418,6 +428,21 @@
return [...this.order]; return [...this.order];
} }
/**
* Marks a background tab as having new activity.
* Does nothing if the session is currently active.
* @param {string} sessionId
*/
markActivity(sessionId) {
if (sessionId === this.app.state.activeSessionId) return;
const tabEl = this.tabList.querySelector(
`[data-session-id="${sessionId}"]`,
);
if (tabEl && !tabEl.classList.contains("tab-has-activity")) {
tabEl.classList.add("tab-has-activity");
}
}
/** /**
* Returns the session ID of the next tab (wrapping round). * Returns the session ID of the next tab (wrapping round).
* @returns {string|null} * @returns {string|null}
@@ -455,6 +480,8 @@
const protoLabel = const protoLabel =
sessionInfo.protocol === "github" sessionInfo.protocol === "github"
? "GitHub" ? "GitHub"
: sessionInfo.protocol === "local"
? "Local"
: sessionInfo.protocol.toUpperCase(); : sessionInfo.protocol.toUpperCase();
const badge = createElement( const badge = createElement(
"span", "span",
@@ -481,6 +508,10 @@
); );
tab.appendChild(status); tab.appendChild(status);
// Activity indicator (hidden by default, shown when background tab has output)
const activity = createElement("span", "tab-activity");
tab.appendChild(activity);
// Pin indicator // Pin indicator
if (sessionInfo.pinned) { if (sessionInfo.pinned) {
tab.classList.add("tab-pinned"); tab.classList.add("tab-pinned");
+249 -2
View File
@@ -47,6 +47,8 @@
allowProposedApi: true, allowProposedApi: true,
convertEol: false, convertEol: false,
windowsMode: false, windowsMode: false,
scrollOnUserInput: true,
altClickMovesCursor: true,
}); });
// Load addons // Load addons
@@ -150,10 +152,13 @@
const b64 = btoa(data); const b64 = btoa(data);
window.go.api.Service.WriteSession(sessionId, b64); window.go.api.Service.WriteSession(sessionId, b64);
// Broadcast to all other connected sessions if enabled // Broadcast to other connected sessions if enabled
if (this.app.state.broadcastMode) { if (this.app.state.broadcastMode) {
const targets = this.app.state.broadcastTargets;
for (const [otherId] of this.terminals) { for (const [otherId] of this.terminals) {
if (otherId === sessionId) continue; if (otherId === sessionId) continue;
// If targets are set, only broadcast to selected sessions
if (targets && !targets.has(otherId)) continue;
const otherSession = this.app.state.getSession(otherId); const otherSession = this.app.state.getSession(otherId);
if (otherSession && otherSession.state === "connected") { if (otherSession && otherSession.state === "connected") {
try { try {
@@ -504,6 +509,8 @@
const { createElement, dismissContextMenus } = window.NexusTerminal; const { createElement, dismissContextMenus } = window.NexusTerminal;
dismissContextMenus(); dismissContextMenus();
const isMac = navigator.platform.indexOf("Mac") !== -1;
const modKey = isMac ? "⌘" : "Ctrl+";
const hasSelection = term.hasSelection(); const hasSelection = term.hasSelection();
const menu = createElement("div", "context-menu"); const menu = createElement("div", "context-menu");
menu.style.left = `${x}px`; menu.style.left = `${x}px`;
@@ -512,6 +519,7 @@
const items = [ const items = [
{ {
label: "Copy", label: "Copy",
shortcut: modKey + "C",
disabled: !hasSelection, disabled: !hasSelection,
action: () => { action: () => {
const text = term.getSelection(); const text = term.getSelection();
@@ -527,6 +535,7 @@
}, },
{ {
label: "Paste", label: "Paste",
shortcut: modKey + "V",
action: () => { action: () => {
if (window.runtime && window.runtime.ClipboardGetText) { if (window.runtime && window.runtime.ClipboardGetText) {
window.runtime window.runtime
@@ -548,19 +557,41 @@
{ divider: true }, { divider: true },
{ {
label: "Select All", label: "Select All",
shortcut: modKey + "A",
action: () => term.selectAll(), action: () => term.selectAll(),
}, },
{ {
label: "Find…", label: "Find…",
shortcut: modKey + "F",
action: () => this.app._openTerminalSearch(), action: () => this.app._openTerminalSearch(),
}, },
{ {
label: "Send Snippet…", label: "Send Snippet…",
action: () => this.app.snippets.open(), action: () => this.app.snippets.open(),
}, },
{
label: "Port Forwards…",
action: () => this._showPortForwardPanel(sessionId),
},
{ divider: true }, { divider: true },
{
label: "Split Right",
shortcut: modKey + "⇧D",
action: () => this.app.splitPane("vertical"),
},
{
label: "Split Down",
shortcut: modKey + "⇧E",
action: () => this.app.splitPane("horizontal"),
},
{ divider: true },
{
label: "Export Output",
action: () => this._exportTerminalOutput(sessionId, term),
},
{ {
label: "Clear Terminal", label: "Clear Terminal",
shortcut: modKey + "K",
action: () => term.clear(), action: () => term.clear(),
}, },
{ {
@@ -574,9 +605,17 @@
menu.appendChild(createElement("div", "context-menu-divider")); menu.appendChild(createElement("div", "context-menu-divider"));
continue; continue;
} }
const btn = createElement("button", "context-menu-item", { const btn = createElement("button", "context-menu-item");
const labelSpan = createElement("span", "context-menu-label", {
textContent: entry.label, textContent: entry.label,
}); });
btn.appendChild(labelSpan);
if (entry.shortcut) {
const shortcutSpan = createElement("span", "context-menu-shortcut", {
textContent: entry.shortcut,
});
btn.appendChild(shortcutSpan);
}
if (entry.disabled) { if (entry.disabled) {
btn.disabled = true; btn.disabled = true;
btn.classList.add("context-menu-disabled"); btn.classList.add("context-menu-disabled");
@@ -1169,6 +1208,214 @@
return btoa(binary); return btoa(binary);
} }
/**
* Exports the terminal scrollback buffer as plain text.
* @param {string} sessionId
* @param {Terminal} term
* @private
*/
_exportTerminalOutput(sessionId, term) {
const { createElement } = window.NexusTerminal;
const buffer = term.buffer.active;
const lines = [];
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (line) lines.push(line.translateToString(true));
}
const text = lines.join("\n").trimEnd();
// Use Wails save dialog if available
if (window.runtime && window.runtime.SaveFileDialog) {
// Copy to clipboard as a fallback
}
// Copy to clipboard
if (window.runtime && window.runtime.ClipboardSetText) {
window.runtime.ClipboardSetText(text);
} else {
navigator.clipboard.writeText(text).catch(function () {});
}
// Also offer download via blob
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "terminal-output-" + sessionId.substring(0, 8) + ".txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.app.notifications.success(
"Terminal output exported & copied to clipboard",
);
}
/**
* Shows the port forwarding management panel for an SSH session.
* @param {string} sessionId
* @private
*/
_showPortForwardPanel(sessionId) {
const { createElement, escapeHtml } = window.NexusTerminal;
const session = this.app.state.getSession(sessionId);
if (!session || session.protocol !== "ssh") {
this.app.notifications.error(
"Port forwarding is only available for SSH sessions",
);
return;
}
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Port Forwards</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body" style="min-height:200px;">
<div class="portfwd-list" style="margin-bottom:var(--space-3);"></div>
<div class="portfwd-add" style="border-top:1px solid var(--border-subtle);padding-top:var(--space-3);">
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);margin-bottom:var(--space-2);">Add New Forward</div>
<div style="display:grid;grid-template-columns:auto 1fr 60px 20px 1fr 60px;gap:var(--space-2);align-items:center;">
<select class="form-input pf-direction" style="height:28px;font-size:var(--text-xs);">
<option value="local">Local →</option>
<option value="remote">Remote ←</option>
</select>
<input class="form-input pf-bind-host" type="text" placeholder="127.0.0.1" value="127.0.0.1" style="height:28px;font-size:var(--text-xs);" />
<input class="form-input pf-bind-port" type="number" placeholder="Port" style="height:28px;font-size:var(--text-xs);" />
<span style="text-align:center;color:var(--text-tertiary);">→</span>
<input class="form-input pf-dest-host" type="text" placeholder="localhost" value="localhost" style="height:28px;font-size:var(--text-xs);" />
<input class="form-input pf-dest-port" type="number" placeholder="Port" style="height:28px;font-size:var(--text-xs);" />
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);">
<input class="form-input pf-label" type="text" placeholder="Label (optional)" style="flex:1;height:28px;font-size:var(--text-xs);" />
<button class="btn btn-sm btn-primary pf-add-btn">Start Forward</button>
</div>
</div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector(".modal-close").addEventListener("click", close);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
const listEl = modal.querySelector(".portfwd-list");
const self = this;
const renderForwards = async () => {
try {
const forwards =
(await window.go.api.Service.ListPortForwards(sessionId)) || [];
listEl.innerHTML = "";
if (forwards.length === 0) {
listEl.innerHTML =
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-2);">No active port forwards</div>';
return;
}
for (const fwd of forwards) {
const row = createElement("div", "", {});
row.style.cssText =
"display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) 0;border-bottom:1px solid var(--border-subtle);";
const dirLabel = fwd.direction === "local" ? "L" : "R";
const dirColor =
fwd.direction === "local" ? "var(--accent)" : "var(--warning)";
row.innerHTML = `
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:${dirColor};width:16px;">${dirLabel}</span>
<span style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-primary);flex:1;">
${escapeHtml(fwd.bindHost || "127.0.0.1")}:${fwd.bindPort} → ${escapeHtml(fwd.destHost || "localhost")}:${fwd.destPort}
</span>
${fwd.label ? `<span style="font-size:var(--text-xs);color:var(--text-tertiary);">${escapeHtml(fwd.label)}</span>` : ""}
`;
const stopBtn = createElement("button", "btn btn-xs btn-danger", {
textContent: "Stop",
});
stopBtn.addEventListener("click", async () => {
try {
await window.go.api.Service.StopPortForward(sessionId, fwd.id);
self.app.notifications.info("Forward stopped");
renderForwards();
} catch (err) {
self.app.notifications.error(
"Failed to stop: " + (err.message || err),
);
}
});
row.appendChild(stopBtn);
listEl.appendChild(row);
}
} catch (err) {
listEl.innerHTML =
'<div style="color:var(--error);font-size:var(--text-sm);">Failed to load forwards</div>';
}
};
renderForwards();
// Add forward button
modal.querySelector(".pf-add-btn").addEventListener("click", async () => {
const direction = modal.querySelector(".pf-direction").value;
const bindHost =
modal.querySelector(".pf-bind-host").value.trim() || "127.0.0.1";
const bindPort = parseInt(
modal.querySelector(".pf-bind-port").value,
10,
);
const destHost =
modal.querySelector(".pf-dest-host").value.trim() || "localhost";
const destPort = parseInt(
modal.querySelector(".pf-dest-port").value,
10,
);
const label = modal.querySelector(".pf-label").value.trim();
if (!bindPort || !destPort) {
self.app.notifications.error(
"Bind port and destination port are required",
);
return;
}
try {
await window.go.api.Service.StartPortForward(sessionId, {
id: "",
direction: direction,
bindHost: bindHost,
bindPort: bindPort,
destHost: destHost,
destPort: destPort,
enabled: true,
label: label,
});
self.app.notifications.success("Forward started");
modal.querySelector(".pf-bind-port").value = "";
modal.querySelector(".pf-dest-port").value = "";
modal.querySelector(".pf-label").value = "";
renderForwards();
} catch (err) {
self.app.notifications.error(
"Failed to start forward: " + (err.message || err),
);
}
});
}
/** /**
* Builds the xterm.js colour theme. * Builds the xterm.js colour theme.
* @returns {Object} * @returns {Object}
+273 -190
View File
@@ -11,226 +11,310 @@
*/ */
const THEMES = { const THEMES = {
charcoal: { charcoal: {
label: 'Charcoal (Default)', label: "Charcoal (Default)",
theme: { theme: {
background: '#1c1c1c', background: "#1c1c1c",
foreground: '#e4e4e4', foreground: "#e4e4e4",
cursor: '#4ecdc4', cursor: "#4ecdc4",
cursorAccent: '#1c1c1c', cursorAccent: "#1c1c1c",
selectionBackground: 'rgba(78, 205, 196, 0.3)', selectionBackground: "rgba(78, 205, 196, 0.3)",
selectionForeground: '#ffffff', selectionForeground: "#ffffff",
black: '#1c1c1c', black: "#1c1c1c",
red: '#ef5350', red: "#ef5350",
green: '#66bb6a', green: "#66bb6a",
yellow: '#ffa726', yellow: "#ffa726",
blue: '#42a5f5', blue: "#42a5f5",
magenta: '#ab47bc', magenta: "#ab47bc",
cyan: '#4ecdc4', cyan: "#4ecdc4",
white: '#e4e4e4', white: "#e4e4e4",
brightBlack: '#555555', brightBlack: "#555555",
brightRed: '#ff7043', brightRed: "#ff7043",
brightGreen: '#81c784', brightGreen: "#81c784",
brightYellow: '#ffcc02', brightYellow: "#ffcc02",
brightBlue: '#64b5f6', brightBlue: "#64b5f6",
brightMagenta: '#ce93d8', brightMagenta: "#ce93d8",
brightCyan: '#80deea', brightCyan: "#80deea",
brightWhite: '#ffffff', brightWhite: "#ffffff",
}, },
}, },
'solarized-dark': { "solarized-dark": {
label: 'Solarized Dark', label: "Solarized Dark",
theme: { theme: {
background: '#002b36', background: "#002b36",
foreground: '#839496', foreground: "#839496",
cursor: '#93a1a1', cursor: "#93a1a1",
cursorAccent: '#002b36', cursorAccent: "#002b36",
selectionBackground: 'rgba(147, 161, 161, 0.3)', selectionBackground: "rgba(147, 161, 161, 0.3)",
selectionForeground: '#fdf6e3', selectionForeground: "#fdf6e3",
black: '#073642', black: "#073642",
red: '#dc322f', red: "#dc322f",
green: '#859900', green: "#859900",
yellow: '#b58900', yellow: "#b58900",
blue: '#268bd2', blue: "#268bd2",
magenta: '#d33682', magenta: "#d33682",
cyan: '#2aa198', cyan: "#2aa198",
white: '#eee8d5', white: "#eee8d5",
brightBlack: '#586e75', brightBlack: "#586e75",
brightRed: '#cb4b16', brightRed: "#cb4b16",
brightGreen: '#586e75', brightGreen: "#586e75",
brightYellow: '#657b83', brightYellow: "#657b83",
brightBlue: '#839496', brightBlue: "#839496",
brightMagenta: '#6c71c4', brightMagenta: "#6c71c4",
brightCyan: '#93a1a1', brightCyan: "#93a1a1",
brightWhite: '#fdf6e3', brightWhite: "#fdf6e3",
}, },
}, },
monokai: { monokai: {
label: 'Monokai', label: "Monokai",
theme: { theme: {
background: '#272822', background: "#272822",
foreground: '#f8f8f2', foreground: "#f8f8f2",
cursor: '#f8f8f0', cursor: "#f8f8f0",
cursorAccent: '#272822', cursorAccent: "#272822",
selectionBackground: 'rgba(73, 72, 62, 0.6)', selectionBackground: "rgba(73, 72, 62, 0.6)",
selectionForeground: '#f8f8f2', selectionForeground: "#f8f8f2",
black: '#272822', black: "#272822",
red: '#f92672', red: "#f92672",
green: '#a6e22e', green: "#a6e22e",
yellow: '#f4bf75', yellow: "#f4bf75",
blue: '#66d9ef', blue: "#66d9ef",
magenta: '#ae81ff', magenta: "#ae81ff",
cyan: '#a1efe4', cyan: "#a1efe4",
white: '#f8f8f2', white: "#f8f8f2",
brightBlack: '#75715e', brightBlack: "#75715e",
brightRed: '#f92672', brightRed: "#f92672",
brightGreen: '#a6e22e', brightGreen: "#a6e22e",
brightYellow: '#f4bf75', brightYellow: "#f4bf75",
brightBlue: '#66d9ef', brightBlue: "#66d9ef",
brightMagenta: '#ae81ff', brightMagenta: "#ae81ff",
brightCyan: '#a1efe4', brightCyan: "#a1efe4",
brightWhite: '#f9f8f5', brightWhite: "#f9f8f5",
}, },
}, },
nord: { nord: {
label: 'Nord', label: "Nord",
theme: { theme: {
background: '#2e3440', background: "#2e3440",
foreground: '#d8dee9', foreground: "#d8dee9",
cursor: '#d8dee9', cursor: "#d8dee9",
cursorAccent: '#2e3440', cursorAccent: "#2e3440",
selectionBackground: 'rgba(136, 192, 208, 0.3)', selectionBackground: "rgba(136, 192, 208, 0.3)",
selectionForeground: '#eceff4', selectionForeground: "#eceff4",
black: '#3b4252', black: "#3b4252",
red: '#bf616a', red: "#bf616a",
green: '#a3be8c', green: "#a3be8c",
yellow: '#ebcb8b', yellow: "#ebcb8b",
blue: '#81a1c1', blue: "#81a1c1",
magenta: '#b48ead', magenta: "#b48ead",
cyan: '#88c0d0', cyan: "#88c0d0",
white: '#e5e9f0', white: "#e5e9f0",
brightBlack: '#4c566a', brightBlack: "#4c566a",
brightRed: '#bf616a', brightRed: "#bf616a",
brightGreen: '#a3be8c', brightGreen: "#a3be8c",
brightYellow: '#ebcb8b', brightYellow: "#ebcb8b",
brightBlue: '#81a1c1', brightBlue: "#81a1c1",
brightMagenta: '#b48ead', brightMagenta: "#b48ead",
brightCyan: '#8fbcbb', brightCyan: "#8fbcbb",
brightWhite: '#eceff4', brightWhite: "#eceff4",
}, },
}, },
dracula: { dracula: {
label: 'Dracula', label: "Dracula",
theme: { theme: {
background: '#282a36', background: "#282a36",
foreground: '#f8f8f2', foreground: "#f8f8f2",
cursor: '#f8f8f2', cursor: "#f8f8f2",
cursorAccent: '#282a36', cursorAccent: "#282a36",
selectionBackground: 'rgba(68, 71, 90, 0.6)', selectionBackground: "rgba(68, 71, 90, 0.6)",
selectionForeground: '#f8f8f2', selectionForeground: "#f8f8f2",
black: '#21222c', black: "#21222c",
red: '#ff5555', red: "#ff5555",
green: '#50fa7b', green: "#50fa7b",
yellow: '#f1fa8c', yellow: "#f1fa8c",
blue: '#bd93f9', blue: "#bd93f9",
magenta: '#ff79c6', magenta: "#ff79c6",
cyan: '#8be9fd', cyan: "#8be9fd",
white: '#f8f8f2', white: "#f8f8f2",
brightBlack: '#6272a4', brightBlack: "#6272a4",
brightRed: '#ff6e6e', brightRed: "#ff6e6e",
brightGreen: '#69ff94', brightGreen: "#69ff94",
brightYellow: '#ffffa5', brightYellow: "#ffffa5",
brightBlue: '#d6acff', brightBlue: "#d6acff",
brightMagenta: '#ff92df', brightMagenta: "#ff92df",
brightCyan: '#a4ffff', brightCyan: "#a4ffff",
brightWhite: '#ffffff', brightWhite: "#ffffff",
}, },
}, },
'one-dark': { "one-dark": {
label: 'One Dark', label: "One Dark",
theme: { theme: {
background: '#282c34', background: "#282c34",
foreground: '#abb2bf', foreground: "#abb2bf",
cursor: '#528bff', cursor: "#528bff",
cursorAccent: '#282c34', cursorAccent: "#282c34",
selectionBackground: 'rgba(82, 139, 255, 0.3)', selectionBackground: "rgba(82, 139, 255, 0.3)",
selectionForeground: '#abb2bf', selectionForeground: "#abb2bf",
black: '#282c34', black: "#282c34",
red: '#e06c75', red: "#e06c75",
green: '#98c379', green: "#98c379",
yellow: '#e5c07b', yellow: "#e5c07b",
blue: '#61afef', blue: "#61afef",
magenta: '#c678dd', magenta: "#c678dd",
cyan: '#56b6c2', cyan: "#56b6c2",
white: '#abb2bf', white: "#abb2bf",
brightBlack: '#5c6370', brightBlack: "#5c6370",
brightRed: '#e06c75', brightRed: "#e06c75",
brightGreen: '#98c379', brightGreen: "#98c379",
brightYellow: '#e5c07b', brightYellow: "#e5c07b",
brightBlue: '#61afef', brightBlue: "#61afef",
brightMagenta: '#c678dd', brightMagenta: "#c678dd",
brightCyan: '#56b6c2', brightCyan: "#56b6c2",
brightWhite: '#ffffff', brightWhite: "#ffffff",
}, },
}, },
'gruvbox-dark': { "gruvbox-dark": {
label: 'Gruvbox Dark', label: "Gruvbox Dark",
theme: { theme: {
background: '#282828', background: "#282828",
foreground: '#ebdbb2', foreground: "#ebdbb2",
cursor: '#ebdbb2', cursor: "#ebdbb2",
cursorAccent: '#282828', cursorAccent: "#282828",
selectionBackground: 'rgba(214, 93, 14, 0.3)', selectionBackground: "rgba(214, 93, 14, 0.3)",
selectionForeground: '#ebdbb2', selectionForeground: "#ebdbb2",
black: '#282828', black: "#282828",
red: '#cc241d', red: "#cc241d",
green: '#98971a', green: "#98971a",
yellow: '#d79921', yellow: "#d79921",
blue: '#458588', blue: "#458588",
magenta: '#b16286', magenta: "#b16286",
cyan: '#689d6a', cyan: "#689d6a",
white: '#a89984', white: "#a89984",
brightBlack: '#928374', brightBlack: "#928374",
brightRed: '#fb4934', brightRed: "#fb4934",
brightGreen: '#b8bb26', brightGreen: "#b8bb26",
brightYellow: '#fabd2f', brightYellow: "#fabd2f",
brightBlue: '#83a598', brightBlue: "#83a598",
brightMagenta: '#d3869b', brightMagenta: "#d3869b",
brightCyan: '#8ec07c', brightCyan: "#8ec07c",
brightWhite: '#ebdbb2', brightWhite: "#ebdbb2",
}, },
}, },
'tokyo-night': { "tokyo-night": {
label: 'Tokyo Night', label: "Tokyo Night",
theme: { theme: {
background: '#1a1b26', background: "#1a1b26",
foreground: '#a9b1d6', foreground: "#a9b1d6",
cursor: '#c0caf5', cursor: "#c0caf5",
cursorAccent: '#1a1b26', cursorAccent: "#1a1b26",
selectionBackground: 'rgba(41, 46, 66, 0.9)', selectionBackground: "rgba(41, 46, 66, 0.9)",
selectionForeground: '#c0caf5', selectionForeground: "#c0caf5",
black: '#15161e', black: "#15161e",
red: '#f7768e', red: "#f7768e",
green: '#9ece6a', green: "#9ece6a",
yellow: '#e0af68', yellow: "#e0af68",
blue: '#7aa2f7', blue: "#7aa2f7",
magenta: '#bb9af7', magenta: "#bb9af7",
cyan: '#7dcfff', cyan: "#7dcfff",
white: '#a9b1d6', white: "#a9b1d6",
brightBlack: '#414868', brightBlack: "#414868",
brightRed: '#f7768e', brightRed: "#f7768e",
brightGreen: '#9ece6a', brightGreen: "#9ece6a",
brightYellow: '#e0af68', brightYellow: "#e0af68",
brightBlue: '#7aa2f7', brightBlue: "#7aa2f7",
brightMagenta: '#bb9af7', brightMagenta: "#bb9af7",
brightCyan: '#7dcfff', brightCyan: "#7dcfff",
brightWhite: '#c0caf5', brightWhite: "#c0caf5",
},
},
"catppuccin-mocha": {
label: "Catppuccin Mocha",
theme: {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
cursorAccent: "#1e1e2e",
selectionBackground: "rgba(88, 91, 112, 0.5)",
selectionForeground: "#cdd6f4",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f38ba8",
brightGreen: "#a6e3a1",
brightYellow: "#f9e2af",
brightBlue: "#89b4fa",
brightMagenta: "#f5c2e7",
brightCyan: "#94e2d5",
brightWhite: "#a6adc8",
},
},
"rose-pine": {
label: "Rosé Pine",
theme: {
background: "#191724",
foreground: "#e0def4",
cursor: "#524f67",
cursorAccent: "#e0def4",
selectionBackground: "rgba(110, 106, 134, 0.4)",
selectionForeground: "#e0def4",
black: "#26233a",
red: "#eb6f92",
green: "#31748f",
yellow: "#f6c177",
blue: "#9ccfd8",
magenta: "#c4a7e7",
cyan: "#ebbcba",
white: "#e0def4",
brightBlack: "#6e6a86",
brightRed: "#eb6f92",
brightGreen: "#31748f",
brightYellow: "#f6c177",
brightBlue: "#9ccfd8",
brightMagenta: "#c4a7e7",
brightCyan: "#ebbcba",
brightWhite: "#e0def4",
},
},
"solarized-light": {
label: "Solarized Light",
theme: {
background: "#fdf6e3",
foreground: "#657b83",
cursor: "#586e75",
cursorAccent: "#fdf6e3",
selectionBackground: "rgba(7, 54, 66, 0.2)",
selectionForeground: "#073642",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#002b36",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
}, },
}, },
}; };
@@ -242,7 +326,7 @@ const THEMES = {
* @returns {Object} xterm.js theme object * @returns {Object} xterm.js theme object
*/ */
function getTerminalTheme(name) { function getTerminalTheme(name) {
const entry = THEMES[name || 'charcoal']; const entry = THEMES[name || "charcoal"];
return entry ? entry.theme : THEMES.charcoal.theme; return entry ? entry.theme : THEMES.charcoal.theme;
} }
@@ -260,5 +344,4 @@ function listTerminalThemes() {
window.NexusTerminal = window.NexusTerminal || {}; window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.getTerminalTheme = getTerminalTheme; window.NexusTerminal.getTerminalTheme = getTerminalTheme;
window.NexusTerminal.listTerminalThemes = listTerminalThemes; window.NexusTerminal.listTerminalThemes = listTerminalThemes;
})(); })();
+381
View File
@@ -0,0 +1,381 @@
(function () {
/**
* Nexus Terminal — Trigger Rules Engine.
* Monitors terminal output and fires actions (highlight, notify, sound)
* when patterns match. Rules are stored in localStorage.
*/
const { createElement } = window.NexusTerminal;
const STORAGE_KEY = "nexus:trigger-rules";
class TriggerEngine {
constructor(app) {
this.app = app;
this._rules = this._load();
this._backdrop = null;
// Buffer per session to accumulate partial lines
this._buffers = new Map();
}
// ── Public API ───────────────────────────────────────────────────
/**
* Checks incoming terminal data against all enabled rules.
* Called from the session:data event handler.
* @param {string} sessionId
* @param {string} text - decoded terminal text
*/
check(sessionId, text) {
if (this._rules.length === 0) return;
// Accumulate text and check line by line
const buf = (this._buffers.get(sessionId) || "") + text;
const lines = buf.split("\n");
// Keep the last incomplete line in the buffer
this._buffers.set(sessionId, lines.pop());
for (const line of lines) {
this._checkLine(sessionId, line);
}
}
/**
* Opens the trigger rules management panel.
*/
open() {
if (this._backdrop) return;
this._render();
}
/**
* Closes the trigger rules panel.
*/
close() {
if (this._backdrop) {
this._backdrop.classList.remove("is-visible");
const modal = this._backdrop.querySelector(".modal");
if (modal) modal.classList.remove("is-visible");
setTimeout(() => {
if (this._backdrop) {
this._backdrop.remove();
this._backdrop = null;
}
}, 200);
}
}
/**
* Cleans up session buffer on disconnect.
* @param {string} sessionId
*/
cleanup(sessionId) {
this._buffers.delete(sessionId);
}
// ── Rule matching ────────────────────────────────────────────────
_checkLine(sessionId, line) {
// Strip ANSI escape sequences for matching
const clean = line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
for (const rule of this._rules) {
if (!rule.enabled) continue;
let matched = false;
if (rule.isRegex) {
try {
const re = new RegExp(rule.pattern, rule.caseSensitive ? "" : "i");
matched = re.test(clean);
} catch (_) {
// Invalid regex — skip
}
} else {
const haystack = rule.caseSensitive ? clean : clean.toLowerCase();
const needle = rule.caseSensitive
? rule.pattern
: rule.pattern.toLowerCase();
matched = haystack.includes(needle);
}
if (!matched) continue;
// Fire actions
if (rule.action === "notify" || rule.action === "both") {
const session = this.app.state.getSession(sessionId);
const label = session
? session.tabName || session.profileName
: sessionId;
this.app.notifications.info(
`[${label}] Trigger: ${rule.name || rule.pattern}`,
);
}
if (rule.action === "highlight" || rule.action === "both") {
// Flash the tab briefly
const tabEl = document.querySelector(
`.tab[data-session-id="${sessionId}"]`,
);
if (tabEl) {
tabEl.classList.add("tab-trigger-flash");
setTimeout(() => tabEl.classList.remove("tab-trigger-flash"), 2000);
}
}
if (rule.action === "sound") {
this._playBeep();
}
}
}
_playBeep() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
gain.gain.value = 0.1;
osc.start();
osc.stop(ctx.currentTime + 0.15);
} catch (_) {
// Audio not available — silent
}
}
// ── Storage ──────────────────────────────────────────────────────
_load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (_) {
return [];
}
}
_save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._rules));
} catch (_) {
// localStorage full or unavailable
}
}
// ── UI ───────────────────────────────────────────────────────────
_render() {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Trigger Rules</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-xs);color:var(--text-tertiary);margin-bottom:var(--space-3)">
Rules match against terminal output and fire actions when a pattern is detected.
</p>
<div class="trigger-toolbar">
<button class="btn btn-sm btn-accent trigger-add-btn">Add Rule</button>
</div>
<div class="trigger-list"></div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
this._backdrop = backdrop;
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
modal
.querySelector(".modal-close")
.addEventListener("click", () => this.close());
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) this.close();
});
modal
.querySelector(".trigger-add-btn")
.addEventListener("click", () => this._addRule());
this._renderList();
}
_renderList() {
const list = this._backdrop.querySelector(".trigger-list");
if (!list) return;
list.innerHTML = "";
if (this._rules.length === 0) {
list.innerHTML =
'<div class="trigger-empty">No trigger rules defined</div>';
return;
}
for (let i = 0; i < this._rules.length; i++) {
list.appendChild(this._renderRuleRow(this._rules[i], i));
}
}
_renderRuleRow(rule, index) {
const row = createElement("div", "trigger-row");
const toggle = createElement("input", "trigger-toggle");
toggle.type = "checkbox";
toggle.checked = rule.enabled;
toggle.addEventListener("change", () => {
this._rules[index].enabled = toggle.checked;
this._save();
});
const info = createElement("div", "trigger-info");
const name = createElement("div", "trigger-name", {
textContent: rule.name || rule.pattern,
});
const meta = createElement("div", "trigger-meta", {
textContent: `${rule.isRegex ? "Regex" : "Text"} · ${rule.action} · ${rule.caseSensitive ? "Case-sensitive" : "Case-insensitive"}`,
});
info.appendChild(name);
info.appendChild(meta);
const pattern = createElement("code", "trigger-pattern", {
textContent: rule.pattern,
});
const actions = createElement("div", "trigger-actions");
const editBtn = createElement("button", "btn btn-xs", {
textContent: "Edit",
});
editBtn.addEventListener("mousedown", (e) => e.preventDefault());
editBtn.addEventListener("click", () => this._editRule(index));
const delBtn = createElement("button", "btn btn-xs btn-danger", {
textContent: "✕",
});
delBtn.addEventListener("mousedown", (e) => e.preventDefault());
delBtn.addEventListener("click", () => {
this._rules.splice(index, 1);
this._save();
this._renderList();
});
actions.appendChild(editBtn);
actions.appendChild(delBtn);
row.appendChild(toggle);
row.appendChild(info);
row.appendChild(pattern);
row.appendChild(actions);
return row;
}
_addRule() {
this._showRuleForm(null, (rule) => {
this._rules.push(rule);
this._save();
this._renderList();
});
}
_editRule(index) {
this._showRuleForm(this._rules[index], (rule) => {
this._rules[index] = rule;
this._save();
this._renderList();
});
}
_showRuleForm(existing, onSave) {
const list = this._backdrop.querySelector(".trigger-list");
const toolbar = this._backdrop.querySelector(".trigger-toolbar");
if (!list) return;
// Hide list and toolbar
list.style.display = "none";
if (toolbar) toolbar.style.display = "none";
const form = createElement("div", "trigger-form");
form.innerHTML = `
<div class="form-group">
<label class="form-label">Name (optional)</label>
<input type="text" class="form-input trigger-name-input" placeholder="e.g. Error alert" value="${existing ? existing.name || "" : ""}" />
</div>
<div class="form-group">
<label class="form-label">Pattern</label>
<input type="text" class="form-input trigger-pattern-input" placeholder="e.g. ERROR|FATAL|panic" value="${existing ? existing.pattern : ""}" />
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Match Type</label>
<select class="form-input trigger-type-select">
<option value="text"${existing && !existing.isRegex ? " selected" : ""}>Plain text</option>
<option value="regex"${existing && existing.isRegex ? " selected" : ""}>Regex</option>
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Action</label>
<select class="form-input trigger-action-select">
<option value="notify"${existing && existing.action === "notify" ? " selected" : ""}>Notify</option>
<option value="highlight"${existing && existing.action === "highlight" ? " selected" : ""}>Highlight tab</option>
<option value="both"${existing && existing.action === "both" ? " selected" : ""}>Notify + Highlight</option>
<option value="sound"${existing && existing.action === "sound" ? " selected" : ""}>Sound</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" class="trigger-case-check"${existing && existing.caseSensitive ? " checked" : ""} />
Case-sensitive
</label>
</div>
<div class="trigger-form-actions">
<button class="btn btn-sm trigger-cancel-btn">Cancel</button>
<button class="btn btn-sm btn-accent trigger-save-btn">${existing ? "Save" : "Add"}</button>
</div>
`;
list.parentNode.insertBefore(form, list);
form
.querySelector(".trigger-cancel-btn")
.addEventListener("click", () => {
form.remove();
list.style.display = "";
if (toolbar) toolbar.style.display = "";
});
form.querySelector(".trigger-save-btn").addEventListener("click", () => {
const pattern = form
.querySelector(".trigger-pattern-input")
.value.trim();
if (!pattern) {
this.app.notifications.error("Pattern is required");
return;
}
const rule = {
name: form.querySelector(".trigger-name-input").value.trim(),
pattern: pattern,
isRegex: form.querySelector(".trigger-type-select").value === "regex",
action: form.querySelector(".trigger-action-select").value,
caseSensitive: form.querySelector(".trigger-case-check").checked,
enabled: existing ? existing.enabled : true,
};
form.remove();
list.style.display = "";
if (toolbar) toolbar.style.display = "";
onSave(rule);
});
form.querySelector(".trigger-pattern-input").focus();
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.TriggerEngine = TriggerEngine;
})();
+95 -49
View File
@@ -35,15 +35,15 @@ function createElement(tag, className, attrs = {}) {
el.className = className; el.className = className;
} }
for (const [key, value] of Object.entries(attrs)) { for (const [key, value] of Object.entries(attrs)) {
if (key === 'textContent') { if (key === "textContent") {
el.textContent = value; el.textContent = value;
} else if (key === 'innerHTML') { } else if (key === "innerHTML") {
el.innerHTML = value; el.innerHTML = value;
} else if (key === 'dataset') { } else if (key === "dataset") {
for (const [dKey, dVal] of Object.entries(value)) { for (const [dKey, dVal] of Object.entries(value)) {
el.dataset[dKey] = dVal; el.dataset[dKey] = dVal;
} }
} else if (key.startsWith('on') && typeof value === 'function') { } else if (key.startsWith("on") && typeof value === "function") {
el.addEventListener(key.slice(2).toLowerCase(), value); el.addEventListener(key.slice(2).toLowerCase(), value);
} else { } else {
el.setAttribute(key, value); el.setAttribute(key, value);
@@ -58,13 +58,13 @@ function createElement(tag, className, attrs = {}) {
* @returns {string} Escaped string safe for innerHTML. * @returns {string} Escaped string safe for innerHTML.
*/ */
function escapeHtml(str) { function escapeHtml(str) {
if (!str) return ''; if (!str) return "";
const map = { const map = {
'&': '&amp;', "&": "&amp;",
'<': '&lt;', "<": "&lt;",
'>': '&gt;', ">": "&gt;",
'"': '&quot;', '"': "&quot;",
"'": '&#039;', "'": "&#039;",
}; };
return str.replace(/[&<>"']/g, (ch) => map[ch]); return str.replace(/[&<>"']/g, (ch) => map[ch]);
} }
@@ -75,21 +75,33 @@ function escapeHtml(str) {
* @returns {string} e.g. "14:32:07" or "2 Jan 14:32". * @returns {string} e.g. "14:32:07" or "2 Jan 14:32".
*/ */
function formatTimestamp(date) { function formatTimestamp(date) {
if (!date) return ''; if (!date) return "";
const d = date instanceof Date ? date : new Date(date); const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return ''; if (isNaN(d.getTime())) return "";
const now = new Date(); const now = new Date();
const isToday = d.toDateString() === now.toDateString(); const isToday = d.toDateString() === now.toDateString();
const hours = String(d.getHours()).padStart(2, '0'); const hours = String(d.getHours()).padStart(2, "0");
const mins = String(d.getMinutes()).padStart(2, '0'); const mins = String(d.getMinutes()).padStart(2, "0");
const secs = String(d.getSeconds()).padStart(2, '0'); const secs = String(d.getSeconds()).padStart(2, "0");
if (isToday) { if (isToday) {
return `${hours}:${mins}:${secs}`; return `${hours}:${mins}:${secs}`;
} }
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', const months = [
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; "Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
return `${d.getDate()} ${months[d.getMonth()]} ${hours}:${mins}`; return `${d.getDate()} ${months[d.getMonth()]} ${hours}:${mins}`;
} }
@@ -174,8 +186,8 @@ function generateId() {
* @returns {string} e.g. "1.23 KB" * @returns {string} e.g. "1.23 KB"
*/ */
function formatBytes(bytes) { function formatBytes(bytes) {
if (bytes === 0) return '0 B'; if (bytes === 0) return "0 B";
const units = ['B', 'KB', 'MB', 'GB', 'TB']; const units = ["B", "KB", "MB", "GB", "TB"];
const k = 1024; const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, i); const value = bytes / Math.pow(k, i);
@@ -189,15 +201,15 @@ function formatBytes(bytes) {
* @returns {string} * @returns {string}
*/ */
function truncate(str, len) { function truncate(str, len) {
if (!str || str.length <= len) return str || ''; if (!str || str.length <= len) return str || "";
return str.slice(0, len) + '…'; return str.slice(0, len) + "…";
} }
/** /**
* Removes any open context menus from the DOM. * Removes any open context menus from the DOM.
*/ */
function dismissContextMenus() { function dismissContextMenus() {
document.querySelectorAll('.context-menu').forEach((m) => m.remove()); document.querySelectorAll(".context-menu").forEach((m) => m.remove());
} }
/** /**
@@ -207,11 +219,20 @@ function dismissContextMenus() {
*/ */
function protocolColour(protocol) { function protocolColour(protocol) {
switch (protocol) { switch (protocol) {
case 'ssh': return 'proto-ssh'; case "ssh":
case 'telnet': return 'proto-telnet'; return "proto-ssh";
case 'raw': return 'proto-raw'; case "telnet":
case 'github': return 'proto-github'; return "proto-telnet";
default: return ''; case "raw":
return "proto-raw";
case "github":
return "proto-github";
case "vnc":
return "proto-vnc";
case "local":
return "proto-local";
default:
return "";
} }
} }
@@ -221,24 +242,25 @@ function protocolColour(protocol) {
* @returns {string} e.g. "3 hours ago", "2 days ago". * @returns {string} e.g. "3 hours ago", "2 days ago".
*/ */
function relativeTime(isoDate) { function relativeTime(isoDate) {
if (!isoDate) return ''; if (!isoDate) return "";
const d = new Date(isoDate); const d = new Date(isoDate);
if (isNaN(d.getTime())) return ''; if (isNaN(d.getTime())) return "";
const now = Date.now(); const now = Date.now();
const diff = now - d.getTime(); const diff = now - d.getTime();
const secs = Math.floor(diff / 1000); const secs = Math.floor(diff / 1000);
if (secs < 60) return 'just now'; if (secs < 60) return "just now";
const mins = Math.floor(secs / 60); const mins = Math.floor(secs / 60);
if (mins < 60) return mins === 1 ? '1 minute ago' : `${mins} minutes ago`; if (mins < 60) return mins === 1 ? "1 minute ago" : `${mins} minutes ago`;
const hrs = Math.floor(mins / 60); const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`; if (hrs < 24) return hrs === 1 ? "1 hour ago" : `${hrs} hours ago`;
const days = Math.floor(hrs / 24); const days = Math.floor(hrs / 24);
if (days < 30) return days === 1 ? '1 day ago' : `${days} days ago`; if (days < 30) return days === 1 ? "1 day ago" : `${days} days ago`;
const months = Math.floor(days / 30); const months = Math.floor(days / 30);
if (months < 12) return months === 1 ? '1 month ago' : `${months} months ago`; if (months < 12)
return months === 1 ? "1 month ago" : `${months} months ago`;
const years = Math.floor(months / 12); const years = Math.floor(months / 12);
return years === 1 ? '1 year ago' : `${years} years ago`; return years === 1 ? "1 year ago" : `${years} years ago`;
} }
/** /**
@@ -248,27 +270,50 @@ function relativeTime(isoDate) {
*/ */
function stateLabel(state) { function stateLabel(state) {
switch (state) { switch (state) {
case 'connecting': return 'Connecting'; case "connecting":
case 'connected': return 'Connected'; return "Connecting";
case 'disconnected': return 'Disconnected'; case "connected":
case 'reconnecting': return 'Reconnecting'; return "Connected";
case 'error': return 'Error'; case "disconnected":
default: return state || 'Unknown'; return "Disconnected";
case "reconnecting":
return "Reconnecting";
case "error":
return "Error";
default:
return state || "Unknown";
} }
} }
window.NexusTerminal = window.NexusTerminal || {}; window.NexusTerminal = window.NexusTerminal || {};
Object.assign(window.NexusTerminal, { $, $$, createElement, escapeHtml, formatTimestamp, debounce, fuzzyMatch, base64Encode, base64Decode, generateId, formatBytes, truncate, dismissContextMenus, protocolColour, stateLabel, relativeTime }); Object.assign(window.NexusTerminal, {
$,
$$,
createElement,
escapeHtml,
formatTimestamp,
debounce,
fuzzyMatch,
base64Encode,
base64Decode,
generateId,
formatBytes,
truncate,
dismissContextMenus,
protocolColour,
stateLabel,
relativeTime,
});
/* ── Disable macOS/WebKit auto-capitalisation on all text inputs ───────── */ /* ── Disable macOS/WebKit auto-capitalisation on all text inputs ───────── */
function disableAutoCapitalise(el) { function disableAutoCapitalise(el) {
el.setAttribute('autocapitalize', 'none'); el.setAttribute("autocapitalize", "none");
el.setAttribute('autocorrect', 'off'); el.setAttribute("autocorrect", "off");
el.setAttribute('spellcheck', 'false'); el.setAttribute("spellcheck", "false");
} }
// Apply to existing inputs // Apply to existing inputs
document.querySelectorAll('input, textarea').forEach(disableAutoCapitalise); document.querySelectorAll("input, textarea").forEach(disableAutoCapitalise);
// Observe for dynamically added inputs // Observe for dynamically added inputs
var inputObserver = new MutationObserver(function (mutations) { var inputObserver = new MutationObserver(function (mutations) {
@@ -277,15 +322,16 @@ var inputObserver = new MutationObserver(function(mutations) {
for (var j = 0; j < nodes.length; j++) { for (var j = 0; j < nodes.length; j++) {
var node = nodes[j]; var node = nodes[j];
if (node.nodeType !== 1) continue; if (node.nodeType !== 1) continue;
if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA') { if (node.tagName === "INPUT" || node.tagName === "TEXTAREA") {
disableAutoCapitalise(node); disableAutoCapitalise(node);
} }
if (node.querySelectorAll) { if (node.querySelectorAll) {
node.querySelectorAll('input, textarea').forEach(disableAutoCapitalise); node
.querySelectorAll("input, textarea")
.forEach(disableAutoCapitalise);
} }
} }
} }
}); });
inputObserver.observe(document.body, { childList: true, subtree: true }); inputObserver.observe(document.body, { childList: true, subtree: true });
})(); })();
+295
View File
@@ -0,0 +1,295 @@
(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();
})();
+19
View File
@@ -1,8 +1,11 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
import {models} from '../models'; import {models} from '../models';
import {api} from '../models';
import {context} from '../models'; import {context} from '../models';
export function BrowseForShell():Promise<string>;
export function CancelReconnect(arg1:string):Promise<void>; export function CancelReconnect(arg1:string):Promise<void>;
export function CheckForUpdates():Promise<Record<string, any>>; export function CheckForUpdates():Promise<Record<string, any>>;
@@ -11,6 +14,8 @@ export function ClearSessionHistory(arg1:string):Promise<void>;
export function CloseAllSessions():Promise<void>; export function CloseAllSessions():Promise<void>;
export function ConnectLocal():Promise<models.SessionInfo>;
export function ConnectQuick(arg1:models.Profile):Promise<models.SessionInfo>; export function ConnectQuick(arg1:models.Profile):Promise<models.SessionInfo>;
export function ConnectSession(arg1:string):Promise<models.SessionInfo>; export function ConnectSession(arg1:string):Promise<models.SessionInfo>;
@@ -27,6 +32,8 @@ export function DeleteProfileSecrets(arg1:string):Promise<void>;
export function DeleteRecording(arg1:string):Promise<void>; export function DeleteRecording(arg1:string):Promise<void>;
export function DeleteSSHKey(arg1:string):Promise<void>;
export function DeleteSnippet(arg1:string):Promise<void>; export function DeleteSnippet(arg1:string):Promise<void>;
export function DeleteTOTPSecret(arg1:string):Promise<void>; export function DeleteTOTPSecret(arg1:string):Promise<void>;
@@ -45,20 +52,28 @@ export function FileExists(arg1:string):Promise<boolean>;
export function FileIsPrivateKey(arg1:string):Promise<boolean>; export function FileIsPrivateKey(arg1:string):Promise<boolean>;
export function GenerateSSHKey(arg1:string,arg2:string,arg3:number,arg4:string):Promise<void>;
export function GetAppInfo():Promise<Record<string, string>>; export function GetAppInfo():Promise<Record<string, string>>;
export function GetDefaultShell():Promise<string>;
export function GetPreferences():Promise<models.AppPreferences>; export function GetPreferences():Promise<models.AppPreferences>;
export function GetProfile(arg1:string):Promise<models.Profile>; export function GetProfile(arg1:string):Promise<models.Profile>;
export function GetRecording(arg1:string):Promise<Record<string, any>>; export function GetRecording(arg1:string):Promise<Record<string, any>>;
export function GetSSHPublicKey(arg1:string):Promise<string>;
export function GetSessionCwd(arg1:string):Promise<string>; export function GetSessionCwd(arg1:string):Promise<string>;
export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>; export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>;
export function GetTOTPCode(arg1:string):Promise<string>; export function GetTOTPCode(arg1:string):Promise<string>;
export function GetVNCWebSocketURL(arg1:string):Promise<string>;
export function HasTOTPSecret(arg1:string):Promise<boolean>; export function HasTOTPSecret(arg1:string):Promise<boolean>;
export function ImportProfiles(arg1:string,arg2:string):Promise<number>; export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
@@ -93,6 +108,8 @@ export function ListProfilesByTag(arg1:string):Promise<Array<models.Profile>>;
export function ListRecordings():Promise<Array<Record<string, any>>>; export function ListRecordings():Promise<Array<Record<string, any>>>;
export function ListSSHKeyPairs():Promise<Array<api.SSHKeyInfo>>;
export function ListSSHKeys():Promise<Array<string>>; export function ListSSHKeys():Promise<Array<string>>;
export function ListSessions():Promise<Array<models.SessionInfo>>; export function ListSessions():Promise<Array<models.SessionInfo>>;
@@ -105,6 +122,8 @@ export function NewProfile(arg1:string):Promise<models.Profile>;
export function PinSession(arg1:string,arg2:boolean):Promise<void>; export function PinSession(arg1:string,arg2:boolean):Promise<void>;
export function PingSession(arg1:string):Promise<number>;
export function ReconnectSession(arg1:string):Promise<void>; export function ReconnectSession(arg1:string):Promise<void>;
export function RefreshGitHubRepos(arg1:string):Promise<Array<models.GitHubRepo>>; export function RefreshGitHubRepos(arg1:string):Promise<Array<models.GitHubRepo>>;
+36
View File
@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
export function BrowseForShell() {
return window['go']['api']['Service']['BrowseForShell']();
}
export function CancelReconnect(arg1) { export function CancelReconnect(arg1) {
return window['go']['api']['Service']['CancelReconnect'](arg1); return window['go']['api']['Service']['CancelReconnect'](arg1);
} }
@@ -18,6 +22,10 @@ export function CloseAllSessions() {
return window['go']['api']['Service']['CloseAllSessions'](); return window['go']['api']['Service']['CloseAllSessions']();
} }
export function ConnectLocal() {
return window['go']['api']['Service']['ConnectLocal']();
}
export function ConnectQuick(arg1) { export function ConnectQuick(arg1) {
return window['go']['api']['Service']['ConnectQuick'](arg1); return window['go']['api']['Service']['ConnectQuick'](arg1);
} }
@@ -50,6 +58,10 @@ export function DeleteRecording(arg1) {
return window['go']['api']['Service']['DeleteRecording'](arg1); return window['go']['api']['Service']['DeleteRecording'](arg1);
} }
export function DeleteSSHKey(arg1) {
return window['go']['api']['Service']['DeleteSSHKey'](arg1);
}
export function DeleteSnippet(arg1) { export function DeleteSnippet(arg1) {
return window['go']['api']['Service']['DeleteSnippet'](arg1); return window['go']['api']['Service']['DeleteSnippet'](arg1);
} }
@@ -86,10 +98,18 @@ export function FileIsPrivateKey(arg1) {
return window['go']['api']['Service']['FileIsPrivateKey'](arg1); return window['go']['api']['Service']['FileIsPrivateKey'](arg1);
} }
export function GenerateSSHKey(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['GenerateSSHKey'](arg1, arg2, arg3, arg4);
}
export function GetAppInfo() { export function GetAppInfo() {
return window['go']['api']['Service']['GetAppInfo'](); return window['go']['api']['Service']['GetAppInfo']();
} }
export function GetDefaultShell() {
return window['go']['api']['Service']['GetDefaultShell']();
}
export function GetPreferences() { export function GetPreferences() {
return window['go']['api']['Service']['GetPreferences'](); return window['go']['api']['Service']['GetPreferences']();
} }
@@ -102,6 +122,10 @@ export function GetRecording(arg1) {
return window['go']['api']['Service']['GetRecording'](arg1); return window['go']['api']['Service']['GetRecording'](arg1);
} }
export function GetSSHPublicKey(arg1) {
return window['go']['api']['Service']['GetSSHPublicKey'](arg1);
}
export function GetSessionCwd(arg1) { export function GetSessionCwd(arg1) {
return window['go']['api']['Service']['GetSessionCwd'](arg1); return window['go']['api']['Service']['GetSessionCwd'](arg1);
} }
@@ -114,6 +138,10 @@ export function GetTOTPCode(arg1) {
return window['go']['api']['Service']['GetTOTPCode'](arg1); return window['go']['api']['Service']['GetTOTPCode'](arg1);
} }
export function GetVNCWebSocketURL(arg1) {
return window['go']['api']['Service']['GetVNCWebSocketURL'](arg1);
}
export function HasTOTPSecret(arg1) { export function HasTOTPSecret(arg1) {
return window['go']['api']['Service']['HasTOTPSecret'](arg1); return window['go']['api']['Service']['HasTOTPSecret'](arg1);
} }
@@ -182,6 +210,10 @@ export function ListRecordings() {
return window['go']['api']['Service']['ListRecordings'](); return window['go']['api']['Service']['ListRecordings']();
} }
export function ListSSHKeyPairs() {
return window['go']['api']['Service']['ListSSHKeyPairs']();
}
export function ListSSHKeys() { export function ListSSHKeys() {
return window['go']['api']['Service']['ListSSHKeys'](); return window['go']['api']['Service']['ListSSHKeys']();
} }
@@ -206,6 +238,10 @@ export function PinSession(arg1, arg2) {
return window['go']['api']['Service']['PinSession'](arg1, arg2); return window['go']['api']['Service']['PinSession'](arg1, arg2);
} }
export function PingSession(arg1) {
return window['go']['api']['Service']['PingSession'](arg1);
}
export function ReconnectSession(arg1) { export function ReconnectSession(arg1) {
return window['go']['api']['Service']['ReconnectSession'](arg1); return window['go']['api']['Service']['ReconnectSession'](arg1);
} }
+70
View File
@@ -1,3 +1,28 @@
export namespace api {
export class SSHKeyInfo {
name: string;
type: string;
bits: string;
publicKey: string;
privateKeyPath: string;
static createFrom(source: any = {}) {
return new SSHKeyInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.type = source["type"];
this.bits = source["bits"];
this.publicKey = source["publicKey"];
this.privateKeyPath = source["privateKeyPath"];
}
}
}
export namespace models { export namespace models {
export class AppPreferences { export class AppPreferences {
@@ -13,6 +38,7 @@ export namespace models {
defaultScrollback: number; defaultScrollback: number;
defaultCursorStyle: string; defaultCursorStyle: string;
useNativePaste: boolean; useNativePaste: boolean;
localShell: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new AppPreferences(source); return new AppPreferences(source);
@@ -32,6 +58,7 @@ export namespace models {
this.defaultScrollback = source["defaultScrollback"]; this.defaultScrollback = source["defaultScrollback"];
this.defaultCursorStyle = source["defaultCursorStyle"]; this.defaultCursorStyle = source["defaultCursorStyle"];
this.useNativePaste = source["useNativePaste"]; this.useNativePaste = source["useNativePaste"];
this.localShell = source["localShell"];
} }
} }
export class AuditEntry { export class AuditEntry {
@@ -222,6 +249,46 @@ export namespace models {
this.label = source["label"]; this.label = source["label"];
} }
} }
export class VNCConfig {
vncHost: string;
vncPort: number;
secretMode: string;
viewOnly: boolean;
scaleToFit: boolean;
quality: number;
sharedSession: boolean;
sshTunnel: boolean;
sshHost: string;
sshPort: number;
sshUsername: string;
sshAuthMethod: string;
sshSecretMode: string;
sshPrivateKeyPath: string;
sshAgentAuth: boolean;
static createFrom(source: any = {}) {
return new VNCConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.vncHost = source["vncHost"];
this.vncPort = source["vncPort"];
this.secretMode = source["secretMode"];
this.viewOnly = source["viewOnly"];
this.scaleToFit = source["scaleToFit"];
this.quality = source["quality"];
this.sharedSession = source["sharedSession"];
this.sshTunnel = source["sshTunnel"];
this.sshHost = source["sshHost"];
this.sshPort = source["sshPort"];
this.sshUsername = source["sshUsername"];
this.sshAuthMethod = source["sshAuthMethod"];
this.sshSecretMode = source["sshSecretMode"];
this.sshPrivateKeyPath = source["sshPrivateKeyPath"];
this.sshAgentAuth = source["sshAgentAuth"];
}
}
export class RAWConfig { export class RAWConfig {
autoReconnect: boolean; autoReconnect: boolean;
lineEnding: string; lineEnding: string;
@@ -410,6 +477,7 @@ export namespace models {
telnet?: TelnetConfig; telnet?: TelnetConfig;
raw?: RAWConfig; raw?: RAWConfig;
github?: GitHubConfig; github?: GitHubConfig;
vnc?: VNCConfig;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Profile(source); return new Profile(source);
@@ -442,6 +510,7 @@ export namespace models {
this.telnet = this.convertValues(source["telnet"], TelnetConfig); this.telnet = this.convertValues(source["telnet"], TelnetConfig);
this.raw = this.convertValues(source["raw"], RAWConfig); this.raw = this.convertValues(source["raw"], RAWConfig);
this.github = this.convertValues(source["github"], GitHubConfig); this.github = this.convertValues(source["github"], GitHubConfig);
this.vnc = this.convertValues(source["vnc"], VNCConfig);
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -523,5 +592,6 @@ export namespace models {
} }
} }
} }
+18 -18
View File
@@ -3,44 +3,44 @@ module github.com/andydixon/nexus
go 1.25.0 go 1.25.0
require ( require (
github.com/creack/pty v1.1.24
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.22 github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.44
github.com/pkg/sftp v1.13.10 github.com/pkg/sftp v1.13.10
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/wailsapp/wails/v2 v2.12.0 github.com/wailsapp/wails/v2 v2.12.0
github.com/zalando/go-keyring v0.2.5 github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.51.0
golang.org/x/net v0.53.0 golang.org/x/net v0.54.0
golang.org/x/text v0.36.0 golang.org/x/text v0.37.0
) )
require ( require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/alessio/shellescape v1.4.1 // indirect
github.com/bep/debounce v1.2.1 // indirect github.com/bep/debounce v1.2.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/boombuler/barcode v1.1.0 // indirect
github.com/danieljoos/wincred v1.2.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/kr/fs v0.1.0 // indirect github.com/kr/fs v0.1.0 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/echo/v4 v4.15.2 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.5.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect github.com/samber/lo v1.53.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.44.0 // indirect
) )
+40 -42
View File
@@ -1,32 +1,33 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
@@ -40,13 +41,12 @@ github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQc
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -60,47 +60,45 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+251
View File
@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"net" "net"
"os" "os"
"os/exec"
"path" "path"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -23,6 +24,7 @@ import (
"github.com/andydixon/nexus/internal/audit" "github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/exportimport" "github.com/andydixon/nexus/internal/exportimport"
"github.com/andydixon/nexus/internal/localshell"
"github.com/andydixon/nexus/internal/models" "github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/preferences" "github.com/andydixon/nexus/internal/preferences"
"github.com/andydixon/nexus/internal/secrets" "github.com/andydixon/nexus/internal/secrets"
@@ -368,6 +370,24 @@ func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, erro
return *info, nil return *info, nil
} }
// ConnectLocal opens a local shell session using the configured (or default) shell.
func (s *Service) ConnectLocal() (models.SessionInfo, error) {
shell := ""
if s.prefs != nil {
prefs := s.prefs.Get()
shell = prefs.LocalShell
}
sessionID, err := s.sessions.ConnectLocal(shell)
if err != nil {
return models.SessionInfo{}, err
}
info := s.sessions.GetSessionInfo(sessionID)
if info == nil {
return models.SessionInfo{}, fmt.Errorf("session created but info not found")
}
return *info, nil
}
// DisconnectSession closes a session by ID. // DisconnectSession closes a session by ID.
func (s *Service) DisconnectSession(sessionID string) error { func (s *Service) DisconnectSession(sessionID string) error {
return s.sessions.Disconnect(sessionID) return s.sessions.Disconnect(sessionID)
@@ -912,6 +932,39 @@ func (s *Service) DownloadGitHubRepoArchive(sessionID string, fullName string, r
return path, nil return path, nil
} }
// ---------------------------------------------------------------------------
// VNC
// ---------------------------------------------------------------------------
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
// The frontend connects noVNC to this URL for display.
func (s *Service) GetVNCWebSocketURL(sessionID string) (string, error) {
return s.sessions.GetVNCWebSocketURL(sessionID)
}
// ---------------------------------------------------------------------------
// Local shell
// ---------------------------------------------------------------------------
// GetDefaultShell returns the system's default shell path.
func (s *Service) GetDefaultShell() string {
return localshell.DefaultShell()
}
// BrowseForShell opens a file dialog to select a shell binary.
func (s *Service) BrowseForShell() (string, error) {
if s.ctx == nil {
return "", fmt.Errorf("application context not set")
}
path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
Title: "Select Shell Binary",
})
if err != nil {
return "", fmt.Errorf("file dialog failed: %w", err)
}
return path, nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Port forwarding // Port forwarding
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -931,6 +984,11 @@ func (s *Service) StopPortForward(sessionID string, forwardID string) error {
return s.sessions.StopPortForward(sessionID, forwardID) return s.sessions.StopPortForward(sessionID, forwardID)
} }
// PingSession sends a keepalive request and returns the round-trip time in milliseconds.
func (s *Service) PingSession(sessionID string) (int64, error) {
return s.sessions.PingSession(sessionID)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Session recording // Session recording
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1452,3 +1510,196 @@ func derefProfiles(ptrs []*models.Profile) []models.Profile {
} }
return result return result
} }
// ---------------------------------------------------------------------------
// SSH Key Management
// ---------------------------------------------------------------------------
// SSHKeyInfo describes a key found in ~/.ssh/.
type SSHKeyInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Bits string `json:"bits"`
PublicKey string `json:"publicKey"`
PrivateKey string `json:"privateKeyPath"`
}
// ListSSHKeyPairs returns detailed info about SSH key pairs in ~/.ssh/.
func (s *Service) ListSSHKeyPairs() ([]SSHKeyInfo, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
entries, err := os.ReadDir(sshDir)
if err != nil {
if os.IsNotExist(err) {
return []SSHKeyInfo{}, nil
}
return nil, fmt.Errorf("unable to read .ssh directory: %w", err)
}
var keys []SSHKeyInfo
seen := make(map[string]bool)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Look for .pub files and match them with private keys
if !strings.HasSuffix(name, ".pub") {
continue
}
baseName := strings.TrimSuffix(name, ".pub")
privPath := filepath.Join(sshDir, baseName)
// Verify private key exists
if _, err := os.Stat(privPath); err != nil {
continue
}
if seen[baseName] {
continue
}
seen[baseName] = true
// Read public key content
pubBytes, err := os.ReadFile(filepath.Join(sshDir, name))
if err != nil {
continue
}
pubContent := strings.TrimSpace(string(pubBytes))
// Determine key type from public key content
keyType := "unknown"
parts := strings.Fields(pubContent)
if len(parts) >= 1 {
switch {
case strings.Contains(parts[0], "ed25519"):
keyType = "ed25519"
case strings.Contains(parts[0], "ecdsa"):
keyType = "ecdsa"
case strings.Contains(parts[0], "rsa"):
keyType = "rsa"
case strings.Contains(parts[0], "dsa"):
keyType = "dsa"
}
}
keys = append(keys, SSHKeyInfo{
Name: baseName,
Type: keyType,
PublicKey: pubContent,
PrivateKey: privPath,
})
}
if keys == nil {
keys = []SSHKeyInfo{}
}
return keys, nil
}
// GenerateSSHKey generates a new SSH key pair.
func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphrase string) error {
if name == "" {
return fmt.Errorf("key name is required")
}
// Sanitise name — only allow alphanumeric, hyphens, underscores
for _, ch := range name {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
return fmt.Errorf("key name contains invalid characters (use alphanumeric, hyphens, underscores)")
}
}
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
// Ensure .ssh directory exists
if err := os.MkdirAll(sshDir, 0700); err != nil {
return fmt.Errorf("unable to create .ssh directory: %w", err)
}
keyPath := filepath.Join(sshDir, name)
// Check if key already exists
if _, err := os.Stat(keyPath); err == nil {
return fmt.Errorf("key %q already exists", name)
}
if keyType == "" {
keyType = "ed25519"
}
// Build ssh-keygen arguments
args := []string{"-t", keyType, "-f", keyPath, "-C", name + "@nexus-terminal"}
if passphrase != "" {
args = append(args, "-N", passphrase)
} else {
args = append(args, "-N", "")
}
if keyType == "rsa" && bits > 0 {
args = append(args, "-b", fmt.Sprintf("%d", bits))
}
// #nosec G204 — arguments are sanitised above
cmd := exec.Command("ssh-keygen", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("ssh-keygen failed: %s", stderr.String())
}
return nil
}
// GetSSHPublicKey returns the content of a public key file.
func (s *Service) GetSSHPublicKey(name string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("unable to determine home directory: %w", err)
}
pubPath := filepath.Join(home, ".ssh", name+".pub")
data, err := os.ReadFile(pubPath)
if err != nil {
return "", fmt.Errorf("unable to read public key: %w", err)
}
return strings.TrimSpace(string(data)), nil
}
// DeleteSSHKey removes an SSH key pair.
func (s *Service) DeleteSSHKey(name string) error {
if name == "" {
return fmt.Errorf("key name is required")
}
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err)
}
sshDir := filepath.Join(home, ".ssh")
privPath := filepath.Join(sshDir, name)
pubPath := privPath + ".pub"
// Verify paths are within .ssh directory (prevent directory traversal)
absPriv, _ := filepath.Abs(privPath)
absSsh, _ := filepath.Abs(sshDir)
if !strings.HasPrefix(absPriv, absSsh+string(os.PathSeparator)) {
return fmt.Errorf("invalid key name")
}
var errs []string
if err := os.Remove(privPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, "private key: "+err.Error())
}
if err := os.Remove(pubPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, "public key: "+err.Error())
}
if len(errs) > 0 {
return fmt.Errorf("failed to delete: %s", strings.Join(errs, "; "))
}
return nil
}
+190
View File
@@ -0,0 +1,190 @@
// Package localshell provides a local PTY-based terminal session.
// It spawns a shell process and bridges I/O for the frontend terminal.
package localshell
import (
"fmt"
"os"
"os/exec"
"runtime"
"sync"
"github.com/creack/pty"
)
// Client manages a local shell PTY session.
type Client struct {
mu sync.Mutex
cmd *exec.Cmd
ptmx *os.File
shell string
onData func([]byte)
onError func(error)
onClose func()
closed bool
cols uint16
rows uint16
}
// Config holds configuration for a local shell session.
type Config struct {
Shell string // Path to the shell binary (e.g. /bin/zsh)
Cols uint16
Rows uint16
OnData func([]byte)
OnError func(error)
OnClose func()
}
// DefaultShell returns the system default shell.
func DefaultShell() string {
if runtime.GOOS == "windows" {
// Prefer PowerShell if available, else cmd.exe.
if ps, err := exec.LookPath("powershell.exe"); err == nil {
return ps
}
return "cmd.exe"
}
// Unix: use SHELL env var, fallback to /bin/sh.
if sh := os.Getenv("SHELL"); sh != "" {
return sh
}
return "/bin/sh"
}
// NewClient creates a new local shell client.
func NewClient(cfg Config) *Client {
shell := cfg.Shell
if shell == "" {
shell = DefaultShell()
}
cols := cfg.Cols
if cols == 0 {
cols = 80
}
rows := cfg.Rows
if rows == 0 {
rows = 24
}
return &Client{
shell: shell,
cols: cols,
rows: rows,
onData: cfg.OnData,
onError: cfg.OnError,
onClose: cfg.OnClose,
}
}
// Start spawns the shell process and begins reading output.
func (c *Client) Start() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.ptmx != nil {
return fmt.Errorf("shell already running")
}
cmd := exec.Command(c.shell)
cmd.Env = os.Environ()
// Start in the user's home directory.
if home, err := os.UserHomeDir(); err == nil {
cmd.Dir = home
}
// Set TERM for proper terminal behaviour.
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
Cols: c.cols,
Rows: c.rows,
})
if err != nil {
return fmt.Errorf("failed to start shell %q: %w", c.shell, err)
}
c.cmd = cmd
c.ptmx = ptmx
// Read loop — forward PTY output to the frontend.
go c.readLoop()
// Wait for process exit.
go func() {
_ = cmd.Wait()
c.mu.Lock()
wasClosed := c.closed
c.closed = true
c.mu.Unlock()
if !wasClosed && c.onClose != nil {
c.onClose()
}
}()
return nil
}
// Write sends data to the shell's stdin (PTY).
func (c *Client) Write(data []byte) error {
c.mu.Lock()
ptmx := c.ptmx
c.mu.Unlock()
if ptmx == nil {
return fmt.Errorf("shell not running")
}
_, err := ptmx.Write(data)
return err
}
// Resize updates the PTY window size.
func (c *Client) Resize(cols, rows uint16) error {
c.mu.Lock()
ptmx := c.ptmx
c.mu.Unlock()
if ptmx == nil {
return nil
}
return pty.Setsize(ptmx, &pty.Winsize{Cols: cols, Rows: rows})
}
// Close terminates the shell process and PTY.
func (c *Client) Close() {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return
}
c.closed = true
if c.ptmx != nil {
c.ptmx.Close()
c.ptmx = nil
}
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
}
}
func (c *Client) readLoop() {
buf := make([]byte, 32*1024)
for {
n, err := c.ptmx.Read(buf)
if n > 0 && c.onData != nil {
c.onData(buf[:n])
}
if err != nil {
c.mu.Lock()
wasClosed := c.closed
c.mu.Unlock()
if !wasClosed && c.onError != nil {
c.onError(err)
}
return
}
}
}
+14
View File
@@ -12,6 +12,7 @@ const (
DefaultTelnetPort = 23 DefaultTelnetPort = 23
DefaultRAWPort = 23 DefaultRAWPort = 23
DefaultGitHubPort = 443 DefaultGitHubPort = 443
DefaultVNCPort = 5900
) )
// Default timeout values (in seconds). // Default timeout values (in seconds).
@@ -50,6 +51,7 @@ func DefaultAppPreferences() AppPreferences {
DefaultScrollback: DefaultScrollback, DefaultScrollback: DefaultScrollback,
DefaultCursorStyle: DefaultCursorStyle, DefaultCursorStyle: DefaultCursorStyle,
UseNativePaste: false, UseNativePaste: false,
LocalShell: "", // empty means use system default
} }
} }
@@ -108,6 +110,18 @@ func NewProfile(protocol Protocol) Profile {
SecretMode: SecretModeSaved, SecretMode: SecretModeSaved,
RefreshIntervalSecs: 300, RefreshIntervalSecs: 300,
} }
case ProtocolVNC:
p.Port = DefaultVNCPort
p.VNC = &VNCConfig{
VNCPort: DefaultVNCPort,
SecretMode: SecretModeSaved,
ScaleToFit: true,
SharedSession: true,
Quality: 6,
SSHPort: DefaultSSHPort,
SSHAuthMethod: "publickey",
SSHSecretMode: SecretModeSaved,
}
} }
return p return p
+40
View File
@@ -12,6 +12,8 @@ const (
ProtocolTelnet Protocol = "telnet" ProtocolTelnet Protocol = "telnet"
ProtocolRAW Protocol = "raw" ProtocolRAW Protocol = "raw"
ProtocolGitHub Protocol = "github" ProtocolGitHub Protocol = "github"
ProtocolVNC Protocol = "vnc"
ProtocolLocal Protocol = "local"
) )
// HostKeyPolicy determines how unknown or changed SSH host keys are handled. // HostKeyPolicy determines how unknown or changed SSH host keys are handled.
@@ -80,6 +82,9 @@ type Profile struct {
// GitHub-specific configuration // GitHub-specific configuration
GitHub *GitHubConfig `json:"github,omitempty"` GitHub *GitHubConfig `json:"github,omitempty"`
// VNC-specific configuration
VNC *VNCConfig `json:"vnc,omitempty"`
} }
// SSHConfig holds SSH-specific settings for a connection profile. // SSHConfig holds SSH-specific settings for a connection profile.
@@ -192,6 +197,40 @@ type GitHubConfig struct {
RefreshIntervalSecs int `json:"refreshIntervalSecs"` RefreshIntervalSecs int `json:"refreshIntervalSecs"`
} }
// VNCConfig holds VNC-specific settings for a connection profile.
type VNCConfig struct {
// VNCHost is the VNC server address (defaults to profile.Host if empty).
VNCHost string `json:"vncHost"`
// VNCPort is the VNC server port (5900 + display number, default 5900).
VNCPort int `json:"vncPort"`
// SecretMode controls how the VNC password is stored.
SecretMode SecretMode `json:"secretMode"`
// ViewOnly disables sending keyboard and mouse events to the server.
ViewOnly bool `json:"viewOnly"`
// ScaleToFit scales the remote framebuffer to fit the panel.
ScaleToFit bool `json:"scaleToFit"`
// Quality controls JPEG compression quality (1-9, 0 = default).
Quality int `json:"quality"`
// SharedSession allows other VNC viewers to remain connected.
SharedSession bool `json:"sharedSession"`
// SSH tunnel settings — if enabled, VNC traffic is tunnelled through an SSH connection.
SSHTunnel bool `json:"sshTunnel"`
SSHHost string `json:"sshHost"`
SSHPort int `json:"sshPort"`
SSHUsername string `json:"sshUsername"`
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
SSHSecretMode SecretMode `json:"sshSecretMode"`
SSHPrivateKey string `json:"sshPrivateKeyPath"`
SSHAgentAuth bool `json:"sshAgentAuth"`
}
// GitHubRepo is a single repository returned to the frontend for display. // GitHubRepo is a single repository returned to the frontend for display.
type GitHubRepo struct { type GitHubRepo struct {
FullName string `json:"fullName"` FullName string `json:"fullName"`
@@ -264,6 +303,7 @@ type AppPreferences struct {
DefaultScrollback int `json:"defaultScrollback"` DefaultScrollback int `json:"defaultScrollback"`
DefaultCursorStyle string `json:"defaultCursorStyle"` DefaultCursorStyle string `json:"defaultCursorStyle"`
UseNativePaste bool `json:"useNativePaste"` UseNativePaste bool `json:"useNativePaste"`
LocalShell string `json:"localShell"` // path to shell binary for local terminal
} }
// AuditEntry records an auditable action within the application. // AuditEntry records an auditable action within the application.
+247
View File
@@ -7,6 +7,8 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"net"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -16,6 +18,7 @@ import (
"github.com/andydixon/nexus/internal/audit" "github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/githubclient" "github.com/andydixon/nexus/internal/githubclient"
"github.com/andydixon/nexus/internal/localshell"
"github.com/andydixon/nexus/internal/models" "github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/rawclient" "github.com/andydixon/nexus/internal/rawclient"
"github.com/andydixon/nexus/internal/recording" "github.com/andydixon/nexus/internal/recording"
@@ -24,6 +27,7 @@ import (
"github.com/andydixon/nexus/internal/sshclient" "github.com/andydixon/nexus/internal/sshclient"
"github.com/andydixon/nexus/internal/storage" "github.com/andydixon/nexus/internal/storage"
"github.com/andydixon/nexus/internal/telnetclient" "github.com/andydixon/nexus/internal/telnetclient"
"github.com/andydixon/nexus/internal/vncclient"
) )
// Session wraps a protocol client with metadata and state. // Session wraps a protocol client with metadata and state.
@@ -34,6 +38,8 @@ type Session struct {
rawClient *rawclient.Client rawClient *rawclient.Client
githubClient *githubclient.Client githubClient *githubclient.Client
githubCancel context.CancelFunc // stops the auto-refresh goroutine githubCancel context.CancelFunc // stops the auto-refresh goroutine
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
localClient *localshell.Client // local shell PTY, nil for non-local sessions
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
profile *models.Profile profile *models.Profile
recorder *recording.Recorder // active recording, nil if not recording recorder *recording.Recorder // active recording, nil if not recording
@@ -123,6 +129,8 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
err = m.connectRAW(sess) err = m.connectRAW(sess)
case models.ProtocolGitHub: case models.ProtocolGitHub:
err = m.connectGitHub(sess, getSecret) err = m.connectGitHub(sess, getSecret)
case models.ProtocolVNC:
err = m.connectVNC(sess, getSecret)
default: default:
err = fmt.Errorf("unsupported protocol %q", profile.Protocol) err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
} }
@@ -157,6 +165,104 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
return sessionID, nil return sessionID, nil
} }
// ConnectLocal creates a local shell session without a saved profile.
// The shell path is resolved from the given parameter or falls back to the system default.
func (m *Manager) ConnectLocal(shell string) (string, error) {
sessionID := uuid.New().String()
now := time.Now()
info := models.SessionInfo{
ID: sessionID,
ProfileID: "",
ProfileName: "Local Machine",
Protocol: models.ProtocolLocal,
Host: "localhost",
Port: 0,
State: models.SessionConnecting,
TabName: "Local",
ConnectedAt: now,
}
sess := &Session{
Info: info,
}
m.mu.Lock()
m.sessions[sessionID] = sess
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionConnecting),
})
go func() {
client := localshell.NewClient(localshell.Config{
Shell: shell,
Cols: 80,
Rows: 24,
OnData: func(data []byte) {
m.emitEvent("session:data", map[string]interface{}{
"sessionID": sessionID,
"data": base64.StdEncoding.EncodeToString(data),
})
// Write to active recording if present.
m.mu.RLock()
rec := sess.recorder
m.mu.RUnlock()
if rec != nil {
rec.WriteOutput(data)
}
},
OnError: func(err error) {
m.mu.Lock()
sess.Info.State = models.SessionError
sess.Info.Error = err.Error()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionError),
"error": err.Error(),
})
},
OnClose: func() {
m.handleSessionClosed(sessionID)
},
})
if err := client.Start(); err != nil {
m.mu.Lock()
sess.Info.State = models.SessionError
sess.Info.Error = err.Error()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionError),
"error": err.Error(),
})
return
}
sess.localClient = client
m.mu.Lock()
sess.Info.State = models.SessionConnected
sess.Info.ConnectedAt = time.Now()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionConnected),
})
m.auditLog.LogConnect("", sessionID, "local", "localhost")
}()
return sessionID, nil
}
// Disconnect closes a specific session. // Disconnect closes a specific session.
func (m *Manager) Disconnect(sessionID string) error { func (m *Manager) Disconnect(sessionID string) error {
m.mu.Lock() m.mu.Lock()
@@ -234,6 +340,8 @@ func (m *Manager) WriteToSession(sessionID string, data []byte) error {
return sess.telnetClient.Write(data) return sess.telnetClient.Write(data)
case sess.rawClient != nil: case sess.rawClient != nil:
return sess.rawClient.Write(data) return sess.rawClient.Write(data)
case sess.localClient != nil:
return sess.localClient.Write(data)
} }
return fmt.Errorf("sessions: no active client for session %q", sessionID) return fmt.Errorf("sessions: no active client for session %q", sessionID)
} }
@@ -253,6 +361,8 @@ func (m *Manager) ResizeSession(sessionID string, cols, rows int) error {
return sess.sshClient.Resize(cols, rows) return sess.sshClient.Resize(cols, rows)
case sess.telnetClient != nil: case sess.telnetClient != nil:
return sess.telnetClient.Resize(cols, rows) return sess.telnetClient.Resize(cols, rows)
case sess.localClient != nil:
return sess.localClient.Resize(uint16(cols), uint16(rows))
} }
// RAW sessions have no concept of terminal size. // RAW sessions have no concept of terminal size.
return nil return nil
@@ -867,6 +977,116 @@ func (m *Manager) DownloadGitHubArchive(sessionID string, fullName string, ref s
return nil return nil
} }
// connectVNC establishes a VNC connection (with optional SSH tunnel) and
// emits the local WebSocket proxy URL to the frontend.
func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (string, error)) error {
profile := sess.profile
vncConf := profile.VNC
if vncConf == nil {
return fmt.Errorf("VNC configuration missing")
}
// Fall back to the profile-level host if VNC-specific host is empty.
vncHost := vncConf.VNCHost
if vncHost == "" {
vncHost = profile.Host
}
cfg := vncclient.Config{
VNCHost: vncHost,
VNCPort: vncConf.VNCPort,
OnError: func(err error) {
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sess.Info.ID,
"state": string(models.SessionError),
"error": err.Error(),
})
},
}
// SSH tunnel configuration
if vncConf.SSHTunnel {
cfg.SSHTunnel = true
cfg.SSHHost = vncConf.SSHHost
cfg.SSHPort = vncConf.SSHPort
cfg.SSHUsername = vncConf.SSHUsername
cfg.SSHAuthMethod = vncConf.SSHAuthMethod
cfg.SSHAgentAuth = vncConf.SSHAgentAuth
// Resolve SSH credentials
switch vncConf.SSHAuthMethod {
case "password":
getPass := m.buildSecretCallback(profile.ID, "vnc-ssh-password", vncConf.SSHSecretMode, getSecret)
password, err := getPass()
if err != nil {
return fmt.Errorf("SSH password: %w", err)
}
cfg.SSHPassword = password
case "publickey":
if vncConf.SSHPrivateKey != "" {
keyData, err := os.ReadFile(vncConf.SSHPrivateKey)
if err != nil {
return fmt.Errorf("reading SSH key: %w", err)
}
cfg.SSHPrivateKey = keyData
// Get passphrase if needed
getPassphrase := m.buildSecretCallback(profile.ID, "vnc-ssh-passphrase", vncConf.SSHSecretMode, getSecret)
passphrase, _ := getPassphrase()
cfg.SSHPassphrase = passphrase
}
}
// SSH agent support
if vncConf.SSHAgentAuth {
socketPath := os.Getenv("SSH_AUTH_SOCK")
if socketPath != "" {
agentConn, err := net.Dial("unix", socketPath)
if err == nil {
cfg.SSHAgentConn = agentConn
}
}
}
} else {
// Direct VNC — may need a VNC password
getVNCPass := m.buildSecretCallback(profile.ID, "vnc-password", vncConf.SecretMode, getSecret)
password, _ := getVNCPass()
// VNC auth is handled by the RFB protocol in the frontend (noVNC),
// but we store it in case we need server-side auth negotiation later.
_ = password
}
client := vncclient.NewClient(cfg)
wsURL, err := client.Connect(cfg)
if err != nil {
return err
}
sess.vncClient = client
// Emit the WebSocket URL so the frontend can connect noVNC.
m.emitEvent("session:vnc:ready", map[string]interface{}{
"sessionID": sess.Info.ID,
"wsURL": wsURL,
})
return nil
}
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) {
m.mu.RLock()
sess, ok := m.sessions[sessionID]
m.mu.RUnlock()
if !ok {
return "", fmt.Errorf("sessions: session %q not found", sessionID)
}
if sess.vncClient == nil {
return "", fmt.Errorf("sessions: session %q is not a VNC connection", sessionID)
}
return sess.vncClient.WSURL(), nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Session recording // Session recording
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1069,6 +1289,7 @@ func (m *Manager) SFTPDownload(sessionID, remotePath, localPath string) error {
return sc.Download(remotePath, localPath, func(p sftpclient.TransferProgress) { return sc.Download(remotePath, localPath, func(p sftpclient.TransferProgress) {
m.emitEvent("session:sftp:progress", map[string]interface{}{ m.emitEvent("session:sftp:progress", map[string]interface{}{
"sessionID": sessionID, "sessionID": sessionID,
"direction": "download",
"transferred": p.Transferred, "transferred": p.Transferred,
"total": p.Total, "total": p.Total,
"path": p.Path, "path": p.Path,
@@ -1086,6 +1307,7 @@ func (m *Manager) SFTPUpload(sessionID, localPath, remotePath string) error {
return sc.Upload(localPath, remotePath, func(p sftpclient.TransferProgress) { return sc.Upload(localPath, remotePath, func(p sftpclient.TransferProgress) {
m.emitEvent("session:sftp:progress", map[string]interface{}{ m.emitEvent("session:sftp:progress", map[string]interface{}{
"sessionID": sessionID, "sessionID": sessionID,
"direction": "upload",
"transferred": p.Transferred, "transferred": p.Transferred,
"total": p.Total, "total": p.Total,
"path": p.Path, "path": p.Path,
@@ -1103,6 +1325,7 @@ func (m *Manager) SFTPUploadBytes(sessionID, remotePath string, data []byte) err
return sc.UploadBytes(remotePath, data, func(p sftpclient.TransferProgress) { return sc.UploadBytes(remotePath, data, func(p sftpclient.TransferProgress) {
m.emitEvent("session:sftp:progress", map[string]interface{}{ m.emitEvent("session:sftp:progress", map[string]interface{}{
"sessionID": sessionID, "sessionID": sessionID,
"direction": "upload",
"transferred": p.Transferred, "transferred": p.Transferred,
"total": p.Total, "total": p.Total,
"path": p.Path, "path": p.Path,
@@ -1202,6 +1425,22 @@ func (m *Manager) StopPortForward(sessionID string, forwardID string) error {
return nil return nil
} }
// PingSession sends a keepalive request to the server and returns the RTT in milliseconds.
func (m *Manager) PingSession(sessionID string) (int64, error) {
m.mu.RLock()
sess, ok := m.sessions[sessionID]
m.mu.RUnlock()
if !ok {
return 0, fmt.Errorf("sessions: session %q not found", sessionID)
}
if sess.sshClient == nil {
return 0, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID)
}
return sess.sshClient.Ping()
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Internal: helpers // Internal: helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1351,6 +1590,14 @@ func (m *Manager) closeSession(sess *Session) {
if sess.githubCancel != nil { if sess.githubCancel != nil {
sess.githubCancel() sess.githubCancel()
} }
if sess.vncClient != nil {
sess.vncClient.Close()
sess.vncClient = nil
}
if sess.localClient != nil {
sess.localClient.Close()
sess.localClient = nil
}
m.mu.Lock() m.mu.Lock()
sess.Info.State = models.SessionDisconnected sess.Info.State = models.SessionDisconnected
+21 -2
View File
@@ -159,8 +159,9 @@ func (c *Client) connectInternal(
rows := 24 rows := 24
modes := ssh.TerminalModes{ modes := ssh.TerminalModes{
ssh.ECHO: 1, ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_ISPEED: 115200,
ssh.TTY_OP_OSPEED: 14400, ssh.TTY_OP_OSPEED: 115200,
ssh.IUTF8: 1,
} }
if err := session.RequestPty(termType, rows, cols, modes); err != nil { if err := session.RequestPty(termType, rows, cols, modes); err != nil {
@@ -261,6 +262,24 @@ func (c *Client) Resize(cols, rows int) error {
return session.WindowChange(rows, cols) return session.WindowChange(rows, cols)
} }
// Ping sends a keepalive request and returns the round-trip time in milliseconds.
func (c *Client) Ping() (int64, error) {
c.mu.Lock()
if !c.connected || c.sshClient == nil {
c.mu.Unlock()
return 0, fmt.Errorf("not connected")
}
client := c.sshClient
c.mu.Unlock()
start := time.Now()
_, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
if err != nil {
return 0, err
}
return time.Since(start).Milliseconds(), nil
}
// RunCommand executes a single command using a new SSH session. // RunCommand executes a single command using a new SSH session.
func (c *Client) RunCommand(command string) (string, error) { func (c *Client) RunCommand(command string) (string, error) {
c.mu.Lock() c.mu.Lock()
@@ -0,0 +1,105 @@
-- Migration 004: Add VNC protocol support
-- Adds the 'vnc' value to the protocol CHECK constraint and a vnc_config column.
-- Step 1: Create the new table with the updated constraint.
CREATE TABLE IF NOT EXISTS profiles_new (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT DEFAULT '',
protocol TEXT NOT NULL CHECK(
protocol IN ('ssh', 'telnet', 'raw', 'github', 'vnc')
),
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
folder TEXT DEFAULT '',
environment TEXT DEFAULT '',
notes TEXT DEFAULT '',
favourite INTEGER DEFAULT 0,
last_connected TEXT,
connect_timeout INTEGER DEFAULT 30,
read_timeout INTEGER DEFAULT 0,
terminal_font_family TEXT DEFAULT '',
terminal_font_size INTEGER DEFAULT 14,
terminal_scrollback INTEGER DEFAULT 10000,
terminal_cursor_style TEXT DEFAULT 'block',
ssh_config TEXT,
telnet_config TEXT,
raw_config TEXT,
github_config TEXT,
vnc_config TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Step 2: Copy existing data (vnc_config will be NULL for legacy rows).
INSERT
OR IGNORE INTO profiles_new (
id,
display_name,
description,
protocol,
host,
port,
username,
tags,
folder,
environment,
notes,
favourite,
last_connected,
connect_timeout,
read_timeout,
terminal_font_family,
terminal_font_size,
terminal_scrollback,
terminal_cursor_style,
ssh_config,
telnet_config,
raw_config,
github_config,
vnc_config,
created_at,
updated_at
)
SELECT
id,
display_name,
description,
protocol,
host,
port,
username,
tags,
folder,
environment,
notes,
favourite,
last_connected,
connect_timeout,
read_timeout,
terminal_font_family,
terminal_font_size,
terminal_scrollback,
terminal_cursor_style,
ssh_config,
telnet_config,
raw_config,
github_config,
NULL,
created_at,
updated_at
FROM
profiles;
-- Step 3: Drop old table and rename.
DROP TABLE profiles;
ALTER TABLE
profiles_new RENAME TO profiles;
-- Step 4: Record the migration version.
INSERT INTO
schema_version (version, applied_at)
VALUES
(4, datetime('now'));
+26 -16
View File
@@ -110,15 +110,16 @@ func (s *Store) CreateProfile(p *models.Profile) error {
telnetJSON, _ := json.Marshal(p.Telnet) telnetJSON, _ := json.Marshal(p.Telnet)
rawJSON, _ := json.Marshal(p.RAW) rawJSON, _ := json.Marshal(p.RAW)
githubJSON, _ := json.Marshal(p.GitHub) githubJSON, _ := json.Marshal(p.GitHub)
vncJSON, _ := json.Marshal(p.VNC)
_, err := s.db.Exec(`INSERT INTO profiles ( _, err := s.db.Exec(`INSERT INTO profiles (
id, display_name, description, protocol, host, port, username, id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.ID, p.DisplayName, p.Description, string(p.Protocol), p.ID, p.DisplayName, p.Description, string(p.Protocol),
p.Host, p.Port, p.Username, p.Host, p.Port, p.Username,
string(tagsJSON), p.Folder, p.Environment, p.Notes, string(tagsJSON), p.Folder, p.Environment, p.Notes,
@@ -126,7 +127,7 @@ func (s *Store) CreateProfile(p *models.Profile) error {
p.ConnectTimeout, p.ReadTimeout, p.ConnectTimeout, p.ReadTimeout,
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle, p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON), nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339), p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
) )
if err != nil { if err != nil {
@@ -142,7 +143,7 @@ func (s *Store) GetProfile(id string) (*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE id = ?`, id) FROM profiles WHERE id = ?`, id)
@@ -163,13 +164,14 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
telnetJSON, _ := json.Marshal(p.Telnet) telnetJSON, _ := json.Marshal(p.Telnet)
rawJSON, _ := json.Marshal(p.RAW) rawJSON, _ := json.Marshal(p.RAW)
githubJSON, _ := json.Marshal(p.GitHub) githubJSON, _ := json.Marshal(p.GitHub)
vncJSON, _ := json.Marshal(p.VNC)
res, err := s.db.Exec(`UPDATE profiles SET res, err := s.db.Exec(`UPDATE profiles SET
display_name=?, description=?, protocol=?, host=?, port=?, username=?, display_name=?, description=?, protocol=?, host=?, port=?, username=?,
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?, tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
connect_timeout=?, read_timeout=?, connect_timeout=?, read_timeout=?,
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?, terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?,
updated_at=? updated_at=?
WHERE id=?`, WHERE id=?`,
p.DisplayName, p.Description, string(p.Protocol), p.DisplayName, p.Description, string(p.Protocol),
@@ -179,7 +181,7 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
p.ConnectTimeout, p.ReadTimeout, p.ConnectTimeout, p.ReadTimeout,
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle, p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON), nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
p.UpdatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
p.ID, p.ID,
) )
@@ -213,7 +215,7 @@ func (s *Store) ListProfiles() ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles ORDER BY display_name`) FROM profiles ORDER BY display_name`)
if err != nil { if err != nil {
@@ -232,7 +234,7 @@ func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles FROM profiles
WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ? WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ?
@@ -251,7 +253,7 @@ func (s *Store) ListProfilesByProtocol(protocol string) ([]*models.Profile, erro
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol) FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
if err != nil { if err != nil {
@@ -269,7 +271,7 @@ func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q) FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
if err != nil { if err != nil {
@@ -286,7 +288,7 @@ func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE folder = ? ORDER BY display_name`, folder) FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
if err != nil { if err != nil {
@@ -303,7 +305,7 @@ func (s *Store) ListFavourites() ([]*models.Profile, error) {
tags, folder, environment, notes, favourite, last_connected, tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout, connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config, github_config, ssh_config, telnet_config, raw_config, github_config, vnc_config,
created_at, updated_at created_at, updated_at
FROM profiles WHERE favourite = 1 ORDER BY display_name`) FROM profiles WHERE favourite = 1 ORDER BY display_name`)
if err != nil { if err != nil {
@@ -566,7 +568,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
p := &models.Profile{} p := &models.Profile{}
var ( var (
protocol, tagsJSON string protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON sql.NullString sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
lastConn sql.NullString lastConn sql.NullString
favourite int favourite int
createdAt, updatedAt string createdAt, updatedAt string
@@ -579,7 +581,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
&favourite, &lastConn, &favourite, &lastConn,
&p.ConnectTimeout, &p.ReadTimeout, &p.ConnectTimeout, &p.ReadTimeout,
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle, &p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
&createdAt, &updatedAt, &createdAt, &updatedAt,
) )
if err != nil { if err != nil {
@@ -618,6 +620,10 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
p.GitHub = &models.GitHubConfig{} p.GitHub = &models.GitHubConfig{}
_ = json.Unmarshal([]byte(githubJSON.String), p.GitHub) _ = json.Unmarshal([]byte(githubJSON.String), p.GitHub)
} }
if vncJSON.Valid && vncJSON.String != "" {
p.VNC = &models.VNCConfig{}
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
}
return p, nil return p, nil
} }
@@ -629,7 +635,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
p := &models.Profile{} p := &models.Profile{}
var ( var (
protocol, tagsJSON string protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON, githubJSON sql.NullString sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
lastConn sql.NullString lastConn sql.NullString
favourite int favourite int
createdAt, updatedAt string createdAt, updatedAt string
@@ -642,7 +648,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
&favourite, &lastConn, &favourite, &lastConn,
&p.ConnectTimeout, &p.ReadTimeout, &p.ConnectTimeout, &p.ReadTimeout,
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle, &p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
&createdAt, &updatedAt, &createdAt, &updatedAt,
) )
if err != nil { if err != nil {
@@ -681,6 +687,10 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
p.GitHub = &models.GitHubConfig{} p.GitHub = &models.GitHubConfig{}
_ = json.Unmarshal([]byte(githubJSON.String), p.GitHub) _ = json.Unmarshal([]byte(githubJSON.String), p.GitHub)
} }
if vncJSON.Valid && vncJSON.String != "" {
p.VNC = &models.VNCConfig{}
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
}
result = append(result, p) result = append(result, p)
} }
+2
View File
@@ -95,6 +95,8 @@ func ValidateProfile(p *models.Profile) error {
} }
case models.ProtocolGitHub: case models.ProtocolGitHub:
// GitHub profiles require no host-level validation beyond the common checks. // GitHub profiles require no host-level validation beyond the common checks.
case models.ProtocolVNC:
// VNC profiles use vnc.vncHost or the profile-level host; validated at connect time.
default: default:
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol)) errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
} }
+286
View File
@@ -0,0 +1,286 @@
// Package vncclient provides a WebSocket-to-TCP proxy for VNC connections,
// with optional SSH tunnel support.
package vncclient
import (
"context"
"fmt"
"net"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)
// Client manages a VNC session, optionally tunnelled through SSH.
type Client struct {
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
listener net.Listener
sshClient *ssh.Client
wsURL string
connected bool
onError func(error)
}
// Config holds the configuration for a VNC connection.
type Config struct {
// VNC server address
VNCHost string
VNCPort int
// SSH tunnel settings
SSHTunnel bool
SSHHost string
SSHPort int
SSHUsername string
SSHAuthMethod string // "password", "publickey"
SSHPassword string
SSHPrivateKey []byte
SSHPassphrase string
SSHAgentAuth bool
SSHAgentConn net.Conn
// Callbacks
OnError func(error)
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 65536,
WriteBufferSize: 65536,
}
// NewClient creates a new VNC proxy client.
func NewClient(cfg Config) *Client {
ctx, cancel := context.WithCancel(context.Background())
return &Client{
ctx: ctx,
cancel: cancel,
onError: cfg.OnError,
}
}
// Connect establishes the VNC connection (with optional SSH tunnel) and starts
// a local WebSocket proxy. Returns the WebSocket URL for the frontend.
func (c *Client) Connect(cfg Config) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.connected {
return c.wsURL, nil
}
var vncConn net.Conn
var err error
vncAddr := fmt.Sprintf("%s:%d", cfg.VNCHost, cfg.VNCPort)
if cfg.SSHTunnel {
// Establish SSH connection first
sshAddr := fmt.Sprintf("%s:%d", cfg.SSHHost, cfg.SSHPort)
sshConfig := &ssh.ClientConfig{
User: cfg.SSHUsername,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: use known hosts
Timeout: 15 * time.Second,
}
// Configure auth methods
var authMethods []ssh.AuthMethod
if cfg.SSHAgentAuth && cfg.SSHAgentConn != nil {
agent := newSSHAgent(cfg.SSHAgentConn)
if agent != nil {
authMethods = append(authMethods, agent)
}
}
if len(cfg.SSHPrivateKey) > 0 {
var signer ssh.Signer
if cfg.SSHPassphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(cfg.SSHPrivateKey, []byte(cfg.SSHPassphrase))
} else {
signer, err = ssh.ParsePrivateKey(cfg.SSHPrivateKey)
}
if err == nil {
authMethods = append(authMethods, ssh.PublicKeys(signer))
}
}
if cfg.SSHPassword != "" {
authMethods = append(authMethods, ssh.Password(cfg.SSHPassword))
}
sshConfig.Auth = authMethods
c.sshClient, err = ssh.Dial("tcp", sshAddr, sshConfig)
if err != nil {
return "", fmt.Errorf("SSH connection failed: %w", err)
}
// Dial VNC through the SSH tunnel
// When tunnelling, VNC address is relative to the SSH server
tunnelAddr := vncAddr
if cfg.VNCHost == "" || cfg.VNCHost == cfg.SSHHost {
tunnelAddr = fmt.Sprintf("127.0.0.1:%d", cfg.VNCPort)
}
vncConn, err = c.sshClient.Dial("tcp", tunnelAddr)
if err != nil {
c.sshClient.Close()
c.sshClient = nil
return "", fmt.Errorf("VNC tunnel dial failed: %w", err)
}
} else {
// Direct TCP connection to VNC server
vncConn, err = net.DialTimeout("tcp", vncAddr, 15*time.Second)
if err != nil {
return "", fmt.Errorf("VNC connection failed: %w", err)
}
}
// Start local WebSocket proxy
wsURL, err := c.startProxy(vncConn)
if err != nil {
vncConn.Close()
if c.sshClient != nil {
c.sshClient.Close()
c.sshClient = nil
}
return "", fmt.Errorf("failed to start WebSocket proxy: %w", err)
}
c.wsURL = wsURL
c.connected = true
return wsURL, nil
}
// startProxy creates a local HTTP server that upgrades to WebSocket and bridges
// traffic to the VNC TCP connection.
func (c *Client) startProxy(vncConn net.Conn) (string, error) {
// Listen on a random available port on localhost
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
}
c.listener = listener
port := listener.Addr().(*net.TCPAddr).Port
wsURL := fmt.Sprintf("ws://127.0.0.1:%d", port)
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer ws.Close()
// Bridge WebSocket ↔ VNC TCP in both directions
done := make(chan struct{}, 2)
// VNC → WebSocket
go func() {
defer func() { done <- struct{}{} }()
buf := make([]byte, 65536)
for {
n, err := vncConn.Read(buf)
if n > 0 {
if writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); writeErr != nil {
return
}
}
if err != nil {
return
}
}
}()
// WebSocket → VNC
go func() {
defer func() { done <- struct{}{} }()
for {
_, msg, err := ws.ReadMessage()
if err != nil {
return
}
if _, err := vncConn.Write(msg); err != nil {
return
}
}
}()
// Wait for either direction to close
select {
case <-done:
case <-c.ctx.Done():
}
})
server := &http.Server{
Handler: mux,
ReadTimeout: 0, // VNC is long-lived
WriteTimeout: 0,
}
go func() {
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
if c.onError != nil {
c.onError(fmt.Errorf("VNC proxy error: %w", err))
}
}
}()
// Close server when context is cancelled
go func() {
<-c.ctx.Done()
server.Close()
vncConn.Close()
}()
return wsURL, nil
}
// Close shuts down the VNC proxy and any SSH tunnel.
func (c *Client) Close() {
c.mu.Lock()
defer c.mu.Unlock()
c.cancel()
c.connected = false
if c.listener != nil {
c.listener.Close()
c.listener = nil
}
if c.sshClient != nil {
c.sshClient.Close()
c.sshClient = nil
}
}
// IsConnected returns whether the VNC session is active.
func (c *Client) IsConnected() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.connected
}
// WSURL returns the WebSocket URL for the frontend to connect to.
func (c *Client) WSURL() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.wsURL
}
// newSSHAgent creates an SSH agent auth method from the given connection.
func newSSHAgent(conn net.Conn) ssh.AuthMethod {
if conn == nil {
return nil
}
ag := agent.NewClient(conn)
signers, err := ag.Signers()
if err != nil || len(signers) == 0 {
return nil
}
return ssh.PublicKeys(signers...)
}
+81 -8
View File
@@ -18,6 +18,53 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; } warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
err() { echo -e "${RED}[nexus]${NC} $1" >&2; } err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
host_os() {
case "$(uname -s)" in
Darwin) echo "darwin" ;;
Linux) echo "linux" ;;
*) echo "unknown" ;;
esac
}
host_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "amd64" ;;
arm64|aarch64) echo "arm64" ;;
*) echo "unknown" ;;
esac
}
build_tags() {
if command -v pkg-config >/dev/null 2>&1 \
&& pkg-config --exists webkit2gtk-4.1 \
&& ! pkg-config --exists webkit2gtk-4.0; then
echo "webkit2_41"
fi
}
default_targets() {
local os
local arch
os="$(host_os)"
arch="$(host_arch)"
if [ "$os" = "unknown" ] || [ "$arch" = "unknown" ]; then
err "Unsupported host platform: $(uname -s)/$(uname -m)"
exit 1
fi
echo "${os}/${arch}"
}
release_targets() {
if [ -n "${NEXUS_RELEASE_TARGETS:-}" ]; then
echo "$NEXUS_RELEASE_TARGETS" | tr ',' '\n' | sed '/^[[:space:]]*$/d'
return
fi
default_targets
}
check_deps() { check_deps() {
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; } command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; } command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; }
@@ -132,6 +179,8 @@ build_target() {
log "Building ${platform}..." log "Building ${platform}..."
local ldflags local ldflags
ldflags="$(build_ldflags "$version")" ldflags="$(build_ldflags "$version")"
local tags
tags="$(build_tags)"
if [ -n "$cc" ]; then if [ -n "$cc" ]; then
local cc_cmd="$cc" local cc_cmd="$cc"
if [ -n "$zig_target" ]; then if [ -n "$zig_target" ]; then
@@ -141,12 +190,22 @@ build_target() {
cc_cmd="zig cc -target ${zig_target}-windows-gnu" cc_cmd="zig cc -target ${zig_target}-windows-gnu"
fi fi
fi fi
if ! CC="$cc_cmd" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"; then if [ -n "$tags" ]; then
if ! CC="$cc_cmd" CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags" -o "$output_path"; then
warn "Build failed for ${platform}"
return 1
fi
elif ! CC="$cc_cmd" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"; then
warn "Build failed for ${platform}" warn "Build failed for ${platform}"
return 1 return 1
fi fi
else else
if ! CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"; then if [ -n "$tags" ]; then
if ! CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags" -o "$output_path"; then
warn "Build failed for ${platform}"
return 1
fi
elif ! CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"; then
warn "Build failed for ${platform}" warn "Build failed for ${platform}"
return 1 return 1
fi fi
@@ -273,13 +332,27 @@ main() {
log "Building release ${version}..." log "Building release ${version}..."
: > "${out_dir}/.release-files" : > "${out_dir}/.release-files"
local targets
mapfile -t targets < <(release_targets)
if [ "${#targets[@]}" -eq 0 ]; then
err "No release targets selected."
exit 1
fi
local errors=0 local errors=0
if ! build_target "darwin" "amd64" "$version" "$out_dir"; then errors=1; fi local target
if ! build_target "darwin" "arm64" "$version" "$out_dir"; then errors=1; fi for target in "${targets[@]}"; do
if ! build_target "linux" "amd64" "$version" "$out_dir"; then errors=1; fi local target_os="${target%%/*}"
if ! build_target "linux" "arm64" "$version" "$out_dir"; then errors=1; fi local target_arch="${target##*/}"
if ! build_target "windows" "amd64" "$version" "$out_dir"; then errors=1; fi if [ "$target_os" = "$target_arch" ] || [ -z "$target_os" ] || [ -z "$target_arch" ]; then
if ! build_target "windows" "arm64" "$version" "$out_dir"; then errors=1; fi err "Invalid release target: ${target}"
errors=1
continue
fi
if ! build_target "$target_os" "$target_arch" "$version" "$out_dir"; then
errors=1
fi
done
write_manifest "$version" "$out_dir" "$release_root" write_manifest "$version" "$out_dir" "$release_root"