NexusTerm
English · 中文
A desktop SSH session manager for Windows and Linux — connection tree, tabbed terminals, tunnels, local shell and serial.
Think Xshell's session management with tmux's multiplexing, local-first: your connection inventory never leaves the machine, and credentials are held by the OS keyring rather than a vendor's cloud.
Status
Early. Everything below reflects what actually works today.
| Milestone | Scope | State |
|---|---|---|
| M0 | Scaffold — Electron + React + TS three-process pipeline | done |
| M1 | Persistence + key vault (SQLite, safeStorage, degradation handling) |
done |
| M2 | Prove the native module chain (better-sqlite3, later node-pty / serialport) |
folded into M1/M8 |
| M3 | Connection tree UI | done |
| M4 | SSH connect + terminal | done |
| M5 | Tabs and same-host grouping | done |
| M6 | Flow control and reconnect resilience | done |
| M7 | Port forwarding (-L / -R / -D) |
|
| M8 | Local terminal + serial | local terminal done, serial not |
| M9 | Packaging | done |
Prerequisites
Windows
Node.js 22.12 or newer is required. The dependencies ship Windows x64/ARM64
prebuilds for better-sqlite3 and node-pty, so Visual Studio is normally not
required. If npm install still falls back to node-gyp, use the verified
no-compile installation path:
npm ci --ignore-scripts
node node_modules/electron/install.js
With this path, ssh2 uses its JavaScript crypto implementation. Functionality
is unchanged, but the optional native acceleration is absent. To compile every
optional native module, install Python 3 and Visual Studio Build Tools with the
Desktop development with C++ workload.
Linux build toolchain (for native modules)
sudo apt install build-essential python3 pkg-config libudev-dev
libudev-dev is required by @serialport/bindings-cpp. It is not needed until
M8, but installing it up front avoids a confusing build failure later.
Runtime keyring
Windows uses DPAPI and needs no additional keyring service. On Linux, credential storage depends on an OS keyring daemon. Verify one is running:
ps -e -o comm= | grep -E 'gnome-keyring|kwalletd'
echo "$DBUS_SESSION_BUS_ADDRESS"
If neither is present, Electron silently falls back to a basic_text backend
that "encrypts" with a hardcoded key — equivalent to plaintext, but the database
still looks like ciphertext. The app detects this and warns rather than
pretending to be secure.
Serial port access
Membership in the dialout group is required:
sudo usermod -aG dialout "$USER"
A full logout and login is required for this to take effect — a new shell is not
enough. On Arch the group is called uucp.
Two daemons are known to hijack USB serial adapters before the app can open them:
- ModemManager probes newly attached devices with AT commands. Add a udev
rule with
ID_MM_DEVICE_IGNORE=1. - brltty (braille display daemon) claims CH340/CP210x adapters on Ubuntu, presenting as a device that mysteriously vanishes.
Development
npm install
npm run dev
Other scripts:
npm test # vitest — see "Testing" below
npm run typecheck # main/preload and renderer are checked separately
npm run build # production bundles into out/
npm run dist # package for the current platform into release/
npm run dist:win # explicitly build the Windows x64 NSIS installer
npm run rebuild:native # rebuild native modules against Electron's ABI
Testing
npm test
The suite runs under plain Node — no Electron process required. That is possible
because Vault takes a Keyring interface rather than importing
safeStorage directly, so the security-critical paths (master key lifecycle,
refusing to run on an untrusted keyring, locking) are exercised for real instead
of against a mock so loose it would prove nothing.
Tests use a real file-backed SQLite database in a temp directory, not
:memory:. Two reasons: the assertion that a secret never reaches disk needs an
actual file to inspect, and WAL mode — where a secret could plausibly end up —
does not apply to in-memory databases.
The assertion worth knowing about is in tests/credentials.test.ts: after
storing a password it checkpoints the WAL and reads the raw database file,
asserting the plaintext appears nowhere in it. Checking the column instead would
only prove the column holds bytes.
Integration smoke test
Unit tests cover the repositories and the pure tree logic, but nothing in them exercises the wire between the renderer and the main process. For that:
npm run build
./node_modules/electron/dist/electron --no-sandbox --remote-debugging-port=9222 .
npm run smoke
On Windows PowerShell, replace the second line with:
.\node_modules\electron\dist\electron.exe --remote-debugging-port=9222 .
scripts/ipc-smoke.mjs attaches to the running app over the Chrome DevTools
Protocol and calls window.nexusterm — the same object the UI calls. That makes
it a real end-to-end check of preload bridge → ipcRenderer.invoke → handler →
zod validation → repository → SQLite → reply, without needing a way to drive the
UI by hand.
It currently verifies folder and host creation, nesting, rename, moving to the root layer, refusal of a folder move into its own descendant, rejection of an invalid payload, opening and closing a local PTY, and that it cleans up after itself.
SSH, against a real server
npm run build
./node_modules/electron/dist/electron --no-sandbox --remote-debugging-port=9222 .
npm run e2e:ssh
scripts/ssh-e2e.mjs goes further: it double-clicks a host in the tree, answers
the host-key prompt, and types into the resulting terminal with synthetic
keystrokes. Requires the test sshd described in tests/dial.test.ts.
Because the WebGL renderer leaves no text in the DOM, it does not assert on terminal output. Instead the typed command makes the remote shell create a file, which is then checked from outside the browser:
keystroke → xterm → MessagePort → main → SSH channel → remote shell → /tmp/…
That covers the whole outbound path, and the result cannot be faked by a
rendering bug. The final report carries remoteUser, read back from a file the
remote wrote — which is the return path proven separately.
Packaging
npm run dist # package for the current platform into release/
npm run dist:win # Windows x64 NSIS installer into release/
npm run verify:package # check the result can actually run
The Windows installer is written to release/NexusTerm-<version>-x64.exe. The
uninstalled layout is kept in release/win-unpacked/ for quick testing before
running the installer.
scripts/verify-package.mjs exists because a package that is broken in the
ways that matter still builds, still installs, and still starts. Native modules
cannot be loaded from inside an asar archive, and when they are left there the
failure appears on a user's machine the first time they open a terminal — not
in CI, and not in npm test. The script checks a Linux AppImage or the Windows
win-unpacked directory and asserts that each current-platform native binary
really is in app.asar.unpacked.
Two things it checks that are easy to get wrong:
ssh2carries an optional native module.sshcrypto.nodeis easy to miss because the library reads as pure JavaScript. A no-compile Windows install warns when it is absent and uses the JS implementation.node-ptymust be unpacked whole, not just its.nodefiles. Windows also needswinpty-agent.exe,OpenConsole.exe, and their DLLs; unpacking only*.nodeproduces a terminal that fails after installation.
npmRebuild is off: every native module here is built on node-addon-api, so
the binaries from npm install load unchanged in Electron's Node. Rebuilding
would cost time and risk replacing working binaries for no gain.
Both .deb metadata and desktopName are set, and the AppImage's
.desktop entry is generated from linux.desktop.entry. Windows uses an NSIS
installer with a selectable install directory plus Start menu and desktop
shortcuts.
Packaging notes for whoever picks this up
- The
author,homepageandmaintainerfields are placeholders (ylhan@example.com,example.com). They are required by the deb format and must be replaced before distributing. - The Windows installer is currently not digitally signed, so SmartScreen may report an unknown publisher. Configure a code-signing certificate before public distribution.
asarUnpacklistsnode_modules/@serialport/**for a backend that does not exist yet. Harmless now; load-bearing once the serial transport lands.
Toolchain constraints
Why Electron is pinned exactly
Native modules are compiled against a specific Electron ABI. A caret range would let a silent upgrade invalidate the rebuilt binaries, and the resulting failure appears at runtime rather than at install time. Keep the version exact.
Electron 44.4.1 bundles Node 24.21.0 and Chrome 152 — confirmed at runtime, not inferred from the dependency graph.
Three version constraints that are load-bearing
Established empirically. Each one fails in a way that does not point at its own cause.
Vite is held at 7.x, and @vitejs/plugin-react at 5.x.
electron-vite@5 declares peer vite@"^5 || ^6 || ^7", while
@vitejs/plugin-react@6 requires vite@"^8". The two cannot coexist. The 5.x
line of the React plugin is the newest one that accepts Vite 7. Upgrading Vite
means waiting for electron-vite to widen its peer range.
TypeScript 7 removed baseUrl.
paths mappings must now be relative, with a leading ./. The error is
TS5102: Option 'baseUrl' has been removed. It also enables
noUncheckedSideEffectImports, so bare imports like import './styles.css'
need a module declaration — "types": ["vite/client"] supplies it.
better-sqlite3 ships N-API prebuilds.
node_modules/better-sqlite3/prebuilds/ contains linux-x64.node,
win32-x64.node, and friends,
built with node-addon-api. Because N-API is ABI-stable across Node and Electron
versions, this normally removes the need for an electron-rebuild step. If a
native load ever fails at runtime, check here first before reaching for a
rebuild.
The Electron binary may need a manual download.
If npm install completes but node_modules/electron/dist/electron (or
electron.exe on Windows) is missing,
the package's postinstall was skipped. Run:
node node_modules/electron/install.js
Architecture
main process Node-native work only: ssh2 clients, PTYs, serial ports,
SQLite (sole writer), safeStorage (sole decryptor)
│
│ control plane ipcMain.handle / invoke — request/response, low volume
│ data plane one MessagePort per session — terminal bytes, high volume
▼
preload contextBridge, narrow allowlist — this file IS the
security boundary
▼
renderer React + xterm.js. sandbox: true, contextIsolation: true,
nodeIntegration: false. No Node access.
Two rules the design depends on:
-
Terminal bytes cross the boundary as raw
Uint8Array, never as a string. UTF-8 multibyte characters get split across TCP segments, so converting to a string in the main process corrupts text at chunk boundaries. The bug only shows up with CJK or emoji output, which makes it easy to miss. -
No IPC call returns a stored secret. The renderer asks for
connect(hostId), not for a password. AgetSecret()-style API would let any renderer-side compromise drain every saved credential at once.
SSH sessions
Host key verification is two-phase, and the first phase always refuses. A
host whose key is not in known_hosts returns unknown-host-key with the
fingerprint rather than connecting; the UI shows it, and only a retry carrying
that exact fingerprint gets through and records it. The cost is dialling twice
on a first connection. The alternative — a prompt round-tripped through the
renderer while a socket sits half-open — buys nothing but latency, and a client
that connects first and asks later is a client that has already lost.
A key that changed is never a prompt. host-key-mismatch is reported as an
error with both fingerprints, because a dialog with a continue button is
precisely what an attacker would want the user to see.
Fingerprints are rendered in OpenSSH's SHA256:<base64> form so they can be
checked against ssh-keygen -lf. A fingerprint the user cannot compare to
anything is not worth showing.
The session data path
Terminal bytes travel on a MessagePort dedicated to one session, not the
shared IPC channel. On a shared channel every session's output is serialised
through one queue, so a single cat of a large file delays input and output for
every other session.
The port cannot be handed over through contextBridge — a MessagePort passed
through it arrives unusable (electron/electron#27024). The preload receives it
and reposts with window.postMessage(..., ports), which does transfer
properly.
Flow control is credit-based: the main process stops reading from the SSH
channel once a megabyte is unacknowledged and resumes once the renderer has
drawn all but a quarter of it. Without it, cat of a large file queues the
whole output in the renderer and the UI dies — a certainty, not a risk.
Bytes cross as Uint8Array, never as a string. TCP splits UTF-8 characters
across chunks routinely, and decoding either half alone corrupts them; the bug
appears only with CJK or emoji output, which makes it easy to miss.
Reconnecting
When a transport drops, the Session survives and only the backend beneath it
is replaced — the reason those two are separate objects. The renderer keeps its
scrollback and its tab because the terminal was never torn down.
What does not survive is the shell. A reconnect gets a new process on the far end with a fresh working directory, and the terminal says so in as many words rather than letting someone believe they are still where they were before the blip. The notice is written into the scrollback rather than shown as a banner, because scrolling up later needs to show where the old shell ended and the new one began.
Two rules the retry loop follows:
- Only transport failures are retried. A changed host key or credentials the server rejected will fail identically every time; retrying would hammer the server for nothing and delay telling the user something is actually wrong.
- The retry is jittered. This is precisely the case where many sessions drop at once — a network goes down — and identical delays would have them all hit the server on the same tick.
Backoff starts at a second and caps at thirty, giving up after ten attempts: long enough to ride out a reboot or a flaky link, short enough that a session pointed at a decommissioned host stops rather than living forever in a loop.
The password is held in memory for the session's lifetime so a reconnect can redial without prompting. Reading it back from the vault does not work for a password the user chose not to save, and interrupting a blip with an authentication prompt makes a two-second outage into an interrogation.
One backend, two transports
An SSH channel and a local PTY are the same thing from the terminal's point of
view, so both sit behind SessionBackend and everything above it — flow
control, the port, the tab, the WebGL lifecycle — is written once. The serial
backend, when it lands, is a third implementation of the same interface.
The two differ in one place worth knowing: ssh2 hands back Buffer, node-pty
hands back string because it decodes the PTY stream itself, holding partial
multi-byte sequences across reads rather than mangling them. Re-encoding with
Buffer.from(chunk, 'utf8') is therefore lossless, and it keeps the boundary
uniform — everything above speaks bytes.
node-pty needs no rebuild against Electron. It is built on node-addon-api,
so the binary selected or compiled during dependency installation loads
unchanged in Electron's Node — verified by spawning a real PTY from inside
Electron 44. This retired what the plan had ranked as the project's highest
risk.
Linux has no additional terminal helper executable. The Windows prebuilds carry
winpty-agent.exe and OpenConsole.exe; the asarUnpack rule covers the whole
package so both survive the asar boundary.
The sidebar, and where sessions live
The sidebar merges two sources that have nothing to do with each other: the connection tree, which is persisted, and the open sessions, which exist only in the renderer and vanish when the app closes.
A host is therefore an interior node for exactly as long as it has sessions under it, and a leaf again once they are all closed. Its expandability is derived from the session list rather than stored, so there is no flag to fall out of step with reality.
The pinned local entry is a virtual row — not a database row. It cannot be dragged, renamed or deleted, which is the point: it is the one terminal that does not depend on the network being up, and it should not be possible to lose it. Double-clicking opens another shell on this machine; each call makes a new one.
Session ordering is seq — a monotonic counter from open time — rather than
array position, so pinning can reorder the display without rewriting the list
and closing a session does not disturb the others' places.
Session numbering
Second and later sessions for a host carry a number: n5095, n5095 2,
n5095 3. The first keeps the bare name, so the common case stays uncluttered.
Local shells are numbered as their own group.
The index is assigned when the session opens and stored, not derived from how many sessions happen to be open. Derived numbering renames the survivors: open three, close the middle one, and the third would silently become the second. It also collides — count-plus-one hands out a number that is still in use. Tracking the highest index instead means closing every session restarts the group at 1, which is what a fresh start should look like.
The base name is stored separately from the index because the base for a local shell is a translated string, and the module that composes labels has no business knowing a language.
Pinning is scoped to one host's group. A session that jumped to the top of the whole list would have left the group, and keeping a machine's sessions together is the reason the grouping exists.
The tab bar
Shows the same order as the sidebar, so a session is in the same place in both views. Tabs can be dragged to reorder, but only within their own host's group — a tab that left its block would have no place in a sidebar that groups by host, and the two views would stop corresponding.
Tabs and sessions carry the same five actions, so neither view is a partial vocabulary of the other:
| 重命名 | Names this session only. The host is untouched, and the name goes when the session closes — it is not a rename of the connection. |
| 置顶 | Moves it to the front of its host's group. |
| 复制 | Another session to the same host, placed next to the original. |
| 打开新标签 | Another session to the same host, appended to the group. |
| 关闭 | Ends the session. |
Duplicating copies the original's pin state as well as its position. Without that the copy would land in the other bucket — at the far end of the group — and "next to the original" would not be expressible at all.
Position is a fractional seq rather than an array index, so a drag or a
duplicate writes the midpoint between two neighbours instead of renumbering the
group. Same trade as the connection tree's sort_order, and the same reason:
the cost of a reorder stays constant no matter how many tabs are open.
Known gap: the terminal is invisible to screen readers
The WebGL renderer draws into canvases, so there is no text in the DOM for
assistive technology to read. xterm's screenReaderMode fixes this at some
throughput cost, which makes it a decision rather than a default — it has not
been made yet.
This also means end-to-end tests cannot assert on terminal text; see below for what they assert on instead.
The connection tree
Folders and hosts are rows in one nodes table with a kind discriminator,
sharing one id space and one ordering per layer. That is what lets them
interleave: a host can be dropped between two folders, and a folder between two
hosts.
This was not the original design. Folders and hosts began in separate tables
with independent sort_order sequences, which meant their positions were not
comparable — every folder necessarily rendered before every host, and a host
between two folders was not expressible at all. Migration 002_interleave.sql
rebuilt the tree and carried existing rows across.
Their attributes live in folders and hosts, keyed by node id, because a
kind discriminator alone cannot stop a foreign key from pointing at the wrong
kind. credentials and port_forwards reference hosts(id), and that
reference is genuinely unsatisfiable by a folder.
Migrations that rebuild tables
A restructure is SQLite's only way to change a table's shape, and it needs two pragmas set outside the transaction — SQLite ignores both inside one:
foreign_keys = OFF, because intermediate rebuild states legitimately violate foreign keyslegacy_alter_table = ON, soALTER TABLE ... RENAMEdoes not rewrite references in other tables — which would silently repointcredentialsat a table about to be dropped
Mark such a migration rebuildsTables: true and the runner handles both, then
runs PRAGMA foreign_key_check inside the transaction. A rebuild that leaves
dangling references throws and rolls back, so a bad migration leaves the
previous schema intact rather than a half-converted database.
tests/migration002.test.ts exercises this against a database built in the
old shape and filled with old-format rows. Testing a migration against a fresh
database would exercise none of its code paths.