NexusTerm: an SSH session manager for Linux
Connection tree with interleaved folders and hosts, tabbed terminals grouped by host, a pinned local shell, TOFU host key verification, and credentials held by the OS keyring. Terminal bytes travel on a per-session MessagePort with credit-based flow control, so one session running `cat` on a large file cannot stall the others. Transports reconnect on their own with jittered backoff, keeping the scrollback and the tab while replacing only the connection beneath. 156 tests, including integration tests against a real sshd and a migration exercised against a database built in the old shape.
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cd *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
release/
|
||||
*.log
|
||||
.DS_Store
|
||||
.vite/
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -0,0 +1,438 @@
|
||||
# NexusTerm
|
||||
|
||||
A desktop SSH session manager for 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. See the milestone table in the implementation plan.
|
||||
|
||||
| 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 | partly — tabs exist, grouping does not |
|
||||
| M6 | Flow control and reconnect resilience | done |
|
||||
| M7 | Port forwarding (`-L` / `-R` / `-D`) | |
|
||||
| M8 | Local terminal + serial | local terminal done, serial not |
|
||||
| M9 | Packaging | |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Build toolchain (for native modules)
|
||||
|
||||
```sh
|
||||
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
|
||||
|
||||
Credential storage depends on an OS keyring daemon. Verify one is running:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```sh
|
||||
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
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Other scripts:
|
||||
|
||||
```sh
|
||||
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 # AppImage + deb into release/
|
||||
npm run rebuild:native # rebuild native modules against Electron's ABI
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
./node_modules/electron/dist/electron --no-sandbox --remote-debugging-port=9222 .
|
||||
npm run smoke
|
||||
```
|
||||
|
||||
`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, and that it cleans up after itself.
|
||||
|
||||
### SSH, against a real server
|
||||
|
||||
```sh
|
||||
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.
|
||||
|
||||
## 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` 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` is missing,
|
||||
the package's postinstall was skipped. Run:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
1. **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.
|
||||
|
||||
2. **No IPC call returns a stored secret.** The renderer asks for
|
||||
`connect(hostId)`, not for a password. A `getSecret()`-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 compiled during `npm install` 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.
|
||||
|
||||
On Linux there is also no `spawn-helper`: that exists only for macOS and
|
||||
Windows. The `asarUnpack` rule covers the whole package anyway, so the
|
||||
executable surviving the asar boundary is handled without a special case.
|
||||
|
||||
### 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 keys
|
||||
- `legacy_alter_table = ON`, so `ALTER TABLE ... RENAME` does not rewrite
|
||||
references in *other* tables — which would silently repoint `credentials` at
|
||||
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.
|
||||
|
||||
See the implementation plan for the full design, including the data model,
|
||||
flow-control protocol, and the packaging notes.
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,83 @@
|
||||
appId: dev.ylhan.nexusterm
|
||||
productName: NexusTerm
|
||||
copyright: Copyright © 2026 ylhan
|
||||
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
|
||||
files:
|
||||
- out/**/*
|
||||
- package.json
|
||||
|
||||
# Ship only this platform's native prebuilds.
|
||||
#
|
||||
# better-sqlite3 alone carries eight, and node-pty another eleven — for other
|
||||
# operating systems, other architectures, and musl. None of them can be
|
||||
# loaded here and together they are about 10MB of the package.
|
||||
# better-sqlite3 ships these as flat FILES (`win32-arm64.node`), not as
|
||||
# directories, so the pattern must not require a trailing `/**`.
|
||||
- '!node_modules/better-sqlite3/prebuilds/win32-*'
|
||||
- '!node_modules/better-sqlite3/prebuilds/darwin-*'
|
||||
- '!node_modules/better-sqlite3/prebuilds/linuxmusl-*'
|
||||
- '!node_modules/better-sqlite3/prebuilds/linux-arm64*'
|
||||
- '!node_modules/node-pty/prebuilds/win32-*/**'
|
||||
- '!node_modules/node-pty/prebuilds/darwin-*/**'
|
||||
- '!node_modules/ssh2/lib/protocol/crypto/build/Release/obj/**'
|
||||
|
||||
# Native modules cannot be loaded from inside an asar archive.
|
||||
#
|
||||
# Unpack the WHOLE package, not just `**/*.node`. node-pty ships `spawn-helper`
|
||||
# on macOS and Windows, a real executable loaded from disk rather than dlopen'd
|
||||
# — if it stays inside the archive, the app works in dev and fails only once
|
||||
# packaged, which is the worst way for it to fail. ssh2 turned out to carry
|
||||
# `sshcrypto.node` as well, which is easy to miss because the library reads as
|
||||
# pure JavaScript.
|
||||
asarUnpack:
|
||||
- node_modules/node-pty/**
|
||||
- node_modules/better-sqlite3/**
|
||||
- node_modules/ssh2/**
|
||||
- node_modules/@serialport/**
|
||||
|
||||
# Deliberately off. These are all node-addon-api builds, so the binaries
|
||||
# produced by `npm install` load unchanged in Electron's Node — verified by
|
||||
# spawning a real PTY from inside Electron 44. Rebuilding would take time and
|
||||
# risk replacing working binaries with differently-built ones, for no gain.
|
||||
npmRebuild: false
|
||||
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
category: Network
|
||||
maintainer: ylhan <ylhan@example.com>
|
||||
# Without this, desktop environments cannot tell which running window
|
||||
# belongs to this .desktop entry, so the launcher shows a second icon
|
||||
# instead of indicating the running app.
|
||||
syncDesktopName: true
|
||||
synopsis: SSH session manager
|
||||
description: >
|
||||
SSH session manager with a connection tree, tabbed terminals grouped by
|
||||
host, port forwarding, a pinned local shell, and credentials held by the
|
||||
system keyring.
|
||||
# electron-builder generates the .desktop entry from these.
|
||||
desktop:
|
||||
entry:
|
||||
Name: NexusTerm
|
||||
Comment: SSH session manager
|
||||
Keywords: ssh;terminal;shell;sftp;remote;
|
||||
|
||||
deb:
|
||||
depends:
|
||||
- libgtk-3-0
|
||||
- libnotify4
|
||||
- libnss3
|
||||
- libxss1
|
||||
- libxtst6
|
||||
- xdg-utils
|
||||
- libatspi2.0-0
|
||||
- libsecret-1-0
|
||||
# node-pty needs a working /dev/pts, which every normal desktop has.
|
||||
- libuuid1
|
||||
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const sharedDir = resolve(__dirname, 'src/shared')
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
// Externalizes everything in `dependencies` so native modules
|
||||
// (better-sqlite3, and later node-pty / serialport) are `require`d from
|
||||
// node_modules at runtime instead of being fed through a bundler that
|
||||
// cannot load them. Getting this wrong produces runtime errors that look
|
||||
// nothing like a config problem.
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
alias: { '@shared': sharedDir },
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
alias: { '@shared': sharedDir },
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': sharedDir,
|
||||
'@renderer': resolve(__dirname, 'src/renderer/src'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "nexusterm",
|
||||
"version": "0.1.0",
|
||||
"description": "Desktop SSH session manager — connection tree, tabbed terminals, tunnels, local shell and serial",
|
||||
"main": "./out/main/index.js",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "ylhan",
|
||||
"email": "ylhan@example.com"
|
||||
},
|
||||
"homepage": "https://example.com/nexusterm",
|
||||
"desktopName": "dev.ylhan.nexusterm.desktop",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"smoke": "node scripts/ipc-smoke.mjs",
|
||||
"e2e:ssh": "node scripts/ssh-e2e.mjs",
|
||||
"rebuild:native": "electron-builder install-app-deps",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"verify:package": "node scripts/verify-package.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "13.0.3",
|
||||
"node-pty": "1.1.0",
|
||||
"ssh2": "1.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "4.2.0",
|
||||
"@types/better-sqlite3": "9.6.0",
|
||||
"@types/node": "24.13.5",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/ssh2": "1.15.6",
|
||||
"@vitejs/plugin-react": "5.2.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/addon-search": "0.16.0",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/addon-unicode11": "0.9.0",
|
||||
"@xterm/addon-web-links": "0.12.0",
|
||||
"@xterm/addon-webgl": "0.19.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"electron": "44.4.1",
|
||||
"electron-builder": "26.15.3",
|
||||
"electron-vite": "5.0.0",
|
||||
"i18next": "26.4.2",
|
||||
"lucide-react": "1.46.0",
|
||||
"radix-ui": "1.6.7",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-i18next": "17.0.14",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "7.3.6",
|
||||
"vitest": "5.0.1",
|
||||
"zod": "4.6.5",
|
||||
"zustand": "5.0.15"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Drives the running app's renderer over the Chrome DevTools Protocol, calling
|
||||
// the same `window.nexusterm` API the UI calls. This exercises the real path —
|
||||
// preload bridge, ipcRenderer.invoke, main-process handler, zod validation,
|
||||
// repository, SQLite, and the reply — rather than a stand-in for it.
|
||||
//
|
||||
// Usage:
|
||||
// npm run build
|
||||
// ./node_modules/electron/dist/electron --no-sandbox --remote-debugging-port=9222 .
|
||||
// npm run smoke
|
||||
const PORT = 9222
|
||||
|
||||
async function findPage() {
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${PORT}/json`)
|
||||
const targets = await res.json()
|
||||
const page = targets.find((t) => t.type === 'page' && t.webSocketDebuggerUrl)
|
||||
if (page) return page
|
||||
} catch {
|
||||
// DevTools endpoint not up yet.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
throw new Error('no debuggable page found on port ' + PORT)
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
function evaluate(ws, expression) {
|
||||
const id = nextId++
|
||||
return new Promise((resolve) => {
|
||||
const onMessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.id !== id) return
|
||||
ws.removeEventListener('message', onMessage)
|
||||
|
||||
if (msg.error) return resolve({ ok: false, error: JSON.stringify(msg.error) })
|
||||
|
||||
const result = msg.result
|
||||
if (result.exceptionDetails) {
|
||||
const d = result.exceptionDetails
|
||||
return resolve({ ok: false, error: d.exception?.description ?? d.text ?? 'exception' })
|
||||
}
|
||||
return resolve({ ok: true, value: result.result.value })
|
||||
}
|
||||
|
||||
ws.addEventListener('message', onMessage)
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id,
|
||||
method: 'Runtime.evaluate',
|
||||
params: { expression, awaitPromise: true, returnByValue: true },
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const page = await findPage()
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.addEventListener('open', resolve, { once: true })
|
||||
ws.addEventListener('error', reject, { once: true })
|
||||
})
|
||||
|
||||
const script = `(async () => {
|
||||
const api = window.nexusterm.tree
|
||||
const report = {}
|
||||
|
||||
const findByLabel = (t, label) => {
|
||||
for (const n of t) {
|
||||
if (n.kind === 'host' && n.label === label) return n
|
||||
if (n.kind === 'folder') { const hit = findByLabel(n.children, label); if (hit) return hit }
|
||||
}
|
||||
return null
|
||||
}
|
||||
const findFolder = (t, name) => {
|
||||
for (const n of t) {
|
||||
if (n.kind === 'folder' && n.name === name) return n
|
||||
if (n.kind === 'folder') { const hit = findFolder(n.children, name); if (hit) return hit }
|
||||
}
|
||||
return null
|
||||
}
|
||||
// Names of a layer in tree order, for asserting interleaving.
|
||||
const layerNames = (nodes) => nodes.map(n => n.kind === 'folder' ? n.name : n.label)
|
||||
|
||||
const before = await api.list()
|
||||
report.initialRootCount = before.length
|
||||
|
||||
// --- create and nest -------------------------------------------------
|
||||
let tree = await api.createFolder({ parentId: null, name: 'SMOKE-alpha' })
|
||||
const alpha = findFolder(tree, 'SMOKE-alpha')
|
||||
|
||||
tree = await api.createFolder({ parentId: alpha.id, name: 'SMOKE-beta' })
|
||||
const beta = findFolder(tree, 'SMOKE-beta')
|
||||
report.nestedFolder = beta && beta.parentId === alpha.id
|
||||
|
||||
tree = await api.createHost({
|
||||
parentId: beta.id, label: 'SMOKE-host', hostname: '10.9.9.9',
|
||||
port: 2222, username: 'tester', authType: 'password', keyPath: null, color: null,
|
||||
})
|
||||
report.createdHost = !!findByLabel(tree, 'SMOKE-host')
|
||||
|
||||
tree = await api.renameFolder({ id: alpha.id, name: 'SMOKE-renamed' })
|
||||
report.renamed = !!findFolder(tree, 'SMOKE-renamed')
|
||||
|
||||
// --- interleaving ----------------------------------------------------
|
||||
// Two folders with a host dropped between them. Not expressible before the
|
||||
// tree was interleaved: the two sort_order sequences had no common scale.
|
||||
tree = await api.createFolder({ parentId: null, name: 'SMOKE-first' })
|
||||
const first = findFolder(tree, 'SMOKE-first')
|
||||
tree = await api.createFolder({ parentId: null, name: 'SMOKE-third' })
|
||||
const third = findFolder(tree, 'SMOKE-third')
|
||||
tree = await api.createHost({
|
||||
parentId: null, label: 'SMOKE-middle', hostname: '10.9.9.10',
|
||||
port: 22, username: 'tester', authType: 'password', keyPath: null, color: null,
|
||||
})
|
||||
const middle = findByLabel(tree, 'SMOKE-middle')
|
||||
|
||||
tree = await api.moveNode({ id: middle.id, parentId: null, prevSiblingId: first.id, nextSiblingId: third.id })
|
||||
const order = layerNames(tree)
|
||||
report.interleaved = order.indexOf('SMOKE-first') < order.indexOf('SMOKE-middle')
|
||||
&& order.indexOf('SMOKE-middle') < order.indexOf('SMOKE-third')
|
||||
report.interleavedOrder = order.filter(n => n.startsWith('SMOKE-'))
|
||||
|
||||
// --- move back out to the root layer ---------------------------------
|
||||
const hostInBeta = findByLabel(tree, 'SMOKE-host')
|
||||
tree = await api.moveNode({ id: hostInBeta.id, parentId: null, prevSiblingId: null, nextSiblingId: null })
|
||||
report.movedToRoot = tree.some(n => n.kind === 'host' && n.label === 'SMOKE-host')
|
||||
|
||||
// --- a move into its own descendant must be refused ------------------
|
||||
const renamed = findFolder(tree, 'SMOKE-renamed')
|
||||
const betaAgain = findFolder(tree, 'SMOKE-beta')
|
||||
try {
|
||||
await api.moveNode({ id: renamed.id, parentId: betaAgain.id, prevSiblingId: null, nextSiblingId: null })
|
||||
report.cycleRefused = false
|
||||
} catch (e) {
|
||||
report.cycleRefused = /descendant/.test(String(e && e.message))
|
||||
report.cycleMessage = String(e && e.message).slice(0, 120)
|
||||
}
|
||||
|
||||
// --- validation ------------------------------------------------------
|
||||
try {
|
||||
await api.createHost({ parentId: null, label: '', hostname: '', port: 99999, username: '', authType: 'nope', keyPath: null, color: null })
|
||||
report.validationRejected = false
|
||||
} catch {
|
||||
report.validationRejected = true
|
||||
}
|
||||
|
||||
// --- clean up --------------------------------------------------------
|
||||
tree = await api.list()
|
||||
for (const node of tree) {
|
||||
if (node.kind === 'folder' && node.name.startsWith('SMOKE-')) await api.deleteNode(node.id)
|
||||
if (node.kind === 'host' && node.label.startsWith('SMOKE-')) await api.deleteNode(node.id)
|
||||
}
|
||||
const final = await api.list()
|
||||
report.cleanedUp = !final.some(n =>
|
||||
(n.kind === 'folder' && n.name.startsWith('SMOKE-')) ||
|
||||
(n.kind === 'host' && n.label.startsWith('SMOKE-')))
|
||||
report.finalRootCount = final.length
|
||||
|
||||
return report
|
||||
})()`
|
||||
|
||||
const result = await evaluate(ws, script)
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
ws.close()
|
||||
|
||||
if (!result.ok) process.exit(1)
|
||||
@@ -0,0 +1,327 @@
|
||||
// End-to-end SSH verification.
|
||||
//
|
||||
// Drives the real UI over the Chrome DevTools Protocol — creating a host,
|
||||
// double-clicking it, answering the host-key prompt, and typing into the
|
||||
// resulting terminal. Nothing here is stubbed: the keystrokes go through
|
||||
// xterm, the port, the SSH channel, and a real sshd.
|
||||
//
|
||||
// Prerequisites (see tests/dial.test.ts for the server setup):
|
||||
// - sshd listening on 127.0.0.1:2222 with key auth
|
||||
// - the app running with --remote-debugging-port=9222
|
||||
//
|
||||
// npm run build && electron . --no-sandbox --remote-debugging-port=9222 &
|
||||
// node scripts/ssh-e2e.mjs
|
||||
//
|
||||
// DESTRUCTIVE: this reloads the renderer, which closes every open session.
|
||||
// Run it against a throwaway instance, not one you are working in.
|
||||
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { userInfo } from 'node:os'
|
||||
|
||||
const PORT = 9222
|
||||
const HOST_LABEL = 'E2E-LOCAL'
|
||||
const MARKER = 'E2E_MARKER_OK'
|
||||
|
||||
let nextId = 1
|
||||
let ws
|
||||
|
||||
async function findPage() {
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${PORT}/json`)
|
||||
const targets = await res.json()
|
||||
const page = targets.find((t) => t.type === 'page' && t.webSocketDebuggerUrl)
|
||||
if (page) return page
|
||||
} catch {
|
||||
// DevTools endpoint not up yet.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
throw new Error(`no debuggable page on port ${PORT}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a CDP command, with a deadline.
|
||||
*
|
||||
* The deadline is not decoration. This script reloads the page partway through,
|
||||
* and a reload destroys the JavaScript execution context — a `Runtime.evaluate`
|
||||
* issued into the old context is simply never answered. Without a timeout that
|
||||
* hangs the whole run forever, which is a far worse failure than an error:
|
||||
* nothing to read, nothing to retry from.
|
||||
*/
|
||||
function command(method, params = {}, timeoutMs = 15_000) {
|
||||
const id = nextId++
|
||||
return new Promise((resolve, reject) => {
|
||||
const onMessage = (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.id !== id) return
|
||||
clearTimeout(timer)
|
||||
ws.removeEventListener('message', onMessage)
|
||||
if (msg.error) return reject(new Error(JSON.stringify(msg.error)))
|
||||
resolve(msg.result)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
ws.removeEventListener('message', onMessage)
|
||||
reject(new Error(`CDP ${method} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
ws.addEventListener('message', onMessage)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
async function evaluate(expression) {
|
||||
const result = await command('Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
})
|
||||
if (result.exceptionDetails) {
|
||||
const d = result.exceptionDetails
|
||||
throw new Error(d.exception?.description ?? d.text ?? 'evaluate failed')
|
||||
}
|
||||
return result.result.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll an expression until it returns truthy, or give up.
|
||||
*
|
||||
* Evaluation failures are swallowed rather than propagated: during a
|
||||
* navigation the execution context briefly does not exist, and a poll that gave
|
||||
* up on the first such hiccup would be useless.
|
||||
*/
|
||||
async function waitFor(expression, { timeoutMs = 20_000, label = expression } = {}) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (await evaluate(expression)) return true
|
||||
} catch {
|
||||
// Context is being torn down or rebuilt; try again.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
}
|
||||
throw new Error(`timed out waiting for: ${label}`)
|
||||
}
|
||||
|
||||
async function click(selectorExpression) {
|
||||
const clicked = await evaluate(`(() => {
|
||||
const el = ${selectorExpression}
|
||||
if (!el) return false
|
||||
el.click()
|
||||
return true
|
||||
})()`)
|
||||
if (!clicked) throw new Error(`nothing to click: ${selectorExpression}`)
|
||||
}
|
||||
|
||||
async function type(text) {
|
||||
for (const ch of text) {
|
||||
await command('Input.dispatchKeyEvent', { type: 'char', text: ch })
|
||||
}
|
||||
}
|
||||
|
||||
async function pressEnter() {
|
||||
await command('Input.dispatchKeyEvent', {
|
||||
type: 'rawKeyDown',
|
||||
windowsVirtualKeyCode: 13,
|
||||
nativeVirtualKeyCode: 13,
|
||||
key: 'Enter',
|
||||
})
|
||||
await command('Input.dispatchKeyEvent', { type: 'char', text: '\r' })
|
||||
await command('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
windowsVirtualKeyCode: 13,
|
||||
nativeVirtualKeyCode: 13,
|
||||
key: 'Enter',
|
||||
})
|
||||
}
|
||||
|
||||
const report = {}
|
||||
const page = await findPage()
|
||||
ws = new WebSocket(page.webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.addEventListener('open', resolve, { once: true })
|
||||
ws.addEventListener('error', reject, { once: true })
|
||||
})
|
||||
|
||||
// --- clean any previous run, then create the host ------------------------
|
||||
await evaluate(`(async () => {
|
||||
const tree = await window.nexusterm.tree.list()
|
||||
for (const n of tree) {
|
||||
if (n.kind === 'host' && n.label.startsWith('${HOST_LABEL}')) await window.nexusterm.tree.deleteNode(n.id)
|
||||
}
|
||||
return true
|
||||
})()`)
|
||||
|
||||
await evaluate(`window.nexusterm.tree.createHost({
|
||||
parentId: null,
|
||||
label: '${HOST_LABEL}',
|
||||
hostname: '127.0.0.1',
|
||||
port: 2222,
|
||||
username: ${JSON.stringify(userInfo().username)},
|
||||
authType: 'key',
|
||||
keyPath: '/tmp/testsshd/user_key',
|
||||
color: null,
|
||||
})`)
|
||||
|
||||
// Reload so the React tree picks the new host up the way a restart would.
|
||||
await evaluate('location.reload()')
|
||||
await new Promise((r) => setTimeout(r, 2_500))
|
||||
|
||||
await waitFor(
|
||||
`!!document.querySelector('.row[title^="${HOST_LABEL}"]')`,
|
||||
{ label: 'host row in the tree' },
|
||||
)
|
||||
report.hostCreated = true
|
||||
|
||||
// --- double-click it, exactly as a user would ---------------------------
|
||||
await evaluate(`(() => {
|
||||
const row = document.querySelector('.row[title^="${HOST_LABEL}"]')
|
||||
row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }))
|
||||
return true
|
||||
})()`)
|
||||
|
||||
// --- the host key prompt, if this host has not been trusted yet ---------
|
||||
//
|
||||
// Only the first connection to a host prompts. A rerun against a host whose
|
||||
// key is already recorded goes straight through — which is itself worth
|
||||
// distinguishing, so the report says which path was taken rather than
|
||||
// pretending both runs were identical.
|
||||
const prompted = await waitFor(`document.body.textContent.includes('首次连接此主机')`, {
|
||||
label: 'host key dialog',
|
||||
timeoutMs: 6_000,
|
||||
}).catch(() => false)
|
||||
|
||||
report.hostKeyPrompted = prompted
|
||||
|
||||
if (prompted) {
|
||||
report.fingerprintShown = await evaluate(`(() => {
|
||||
const el = document.querySelector('.hostkey__fingerprint')
|
||||
return el ? el.textContent : null
|
||||
})()`)
|
||||
await click(`[...document.querySelectorAll('button')].find(b => b.textContent.includes('信任并连接'))`)
|
||||
} else {
|
||||
report.alreadyTrusted = true
|
||||
}
|
||||
|
||||
// --- a terminal should now exist ----------------------------------------
|
||||
await waitFor(`!!document.querySelector('.terminal-slot .xterm canvas')`, {
|
||||
label: 'xterm mounted with a renderer',
|
||||
})
|
||||
report.terminalMounted = true
|
||||
|
||||
// Give the shell a moment to print its prompt and for the port to settle.
|
||||
await new Promise((r) => setTimeout(r, 2_000))
|
||||
|
||||
// --- the local shell is pinned: first, and unclosable --------------------
|
||||
report.tabOrder = await evaluate(`JSON.stringify(
|
||||
[...document.querySelectorAll('.tab')].map(t => ({
|
||||
label: t.querySelector('.tab__label').textContent,
|
||||
pinned: t.classList.contains('tab--pinned'),
|
||||
hasClose: !!t.querySelector('.tab__close'),
|
||||
}))
|
||||
)`)
|
||||
|
||||
report.pinnedTabIsFirst = await evaluate(`(() => {
|
||||
const tabs = [...document.querySelectorAll('.tab')]
|
||||
return tabs.length >= 2 && tabs[0].classList.contains('tab--pinned')
|
||||
})()`)
|
||||
|
||||
report.pinnedTabHasNoCloseButton = await evaluate(
|
||||
`!document.querySelector('.tab--pinned .tab__close')`,
|
||||
)
|
||||
|
||||
// --- prove the input path through an observable side effect -------------
|
||||
//
|
||||
// Terminal output cannot be read back from the DOM: the WebGL renderer draws
|
||||
// into canvases, so there is no text node to assert on. Asserting on pixels
|
||||
// would be brittle and would only prove that *something* was drawn.
|
||||
//
|
||||
// Instead the command makes the remote shell touch a file, which is checked
|
||||
// from outside the browser. That covers the whole outbound path — keystroke,
|
||||
// xterm, port, main process, SSH channel, remote shell — and the result cannot
|
||||
// be faked by a renderer bug.
|
||||
const markerPath = `/tmp/${MARKER.toLowerCase()}`
|
||||
rmSync(markerPath, { force: true })
|
||||
|
||||
await evaluate(`document.querySelector('.xterm-helper-textarea')?.focus()`)
|
||||
await type(`touch ${markerPath}`)
|
||||
await pressEnter()
|
||||
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline && !existsSync(markerPath)) {
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
report.inputRoundTrip = existsSync(markerPath)
|
||||
|
||||
// --- and back again: the remote's output must reach the terminal --------
|
||||
// `whoami` output is not readable from the DOM either, so this uses a command
|
||||
// whose effect is another file — proving output was produced and consumed.
|
||||
await type(`id -un > ${markerPath}.out`)
|
||||
await pressEnter()
|
||||
|
||||
const outDeadline = Date.now() + 15_000
|
||||
while (Date.now() < outDeadline && !existsSync(`${markerPath}.out`)) {
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
report.outputRoundTrip = existsSync(`${markerPath}.out`)
|
||||
report.remoteUser = existsSync(`${markerPath}.out`)
|
||||
? readFileSync(`${markerPath}.out`, 'utf8').trim()
|
||||
: null
|
||||
|
||||
rmSync(markerPath, { force: true })
|
||||
rmSync(`${markerPath}.out`, { force: true })
|
||||
|
||||
// --- the stored-password path -------------------------------------------
|
||||
//
|
||||
// The test server refuses password authentication, so a successful password
|
||||
// login cannot be demonstrated here. What *can* be demonstrated is that the
|
||||
// app goes to the trouble of using a saved password instead of asking for one
|
||||
// — which is the part this project actually implements, and the part the
|
||||
// user's "remember the password" requirement rests on.
|
||||
const passwordHostId = await evaluate(`(async () => {
|
||||
const created = await window.nexusterm.tree.createHost({
|
||||
parentId: null, label: '${HOST_LABEL}-PW', hostname: '127.0.0.1', port: 2222,
|
||||
username: ${JSON.stringify(userInfo().username)}, authType: 'password',
|
||||
keyPath: null, color: null,
|
||||
})
|
||||
return created.find(n => n.kind === 'host' && n.label === '${HOST_LABEL}-PW').id
|
||||
})()`)
|
||||
|
||||
const withoutStored = await evaluate(
|
||||
`(async () => (await window.nexusterm.session.open({ hostId: ${passwordHostId}, password: null, trustFingerprint: null })).status)()`,
|
||||
)
|
||||
// With nothing saved, the app must ask rather than fail or guess.
|
||||
report.asksWhenNoPasswordStored = withoutStored === 'need-password'
|
||||
|
||||
await evaluate(`window.nexusterm.credentials.set({
|
||||
hostId: ${passwordHostId}, kind: 'password', secret: 'stored-but-the-server-refuses-passwords'
|
||||
})`)
|
||||
report.credentialStored = await evaluate(
|
||||
`window.nexusterm.credentials.has(${passwordHostId}, 'password')`,
|
||||
)
|
||||
|
||||
const withStored = await evaluate(
|
||||
`(async () => (await window.nexusterm.session.open({ hostId: ${passwordHostId}, password: null, trustFingerprint: null })).status)()`,
|
||||
)
|
||||
// Reaching `auth-failed` rather than `need-password` means the stored secret
|
||||
// was read from the vault and handed to SSH — the round trip happened.
|
||||
report.usedStoredPassword = withStored === 'auth-failed'
|
||||
report.statusWithStoredPassword = withStored
|
||||
|
||||
// There is deliberately no API that returns the secret; only its existence.
|
||||
report.secretIsNotReadableFromRenderer = await evaluate(
|
||||
`typeof window.nexusterm.credentials.get === 'undefined'`,
|
||||
)
|
||||
|
||||
// --- clean up ------------------------------------------------------------
|
||||
await evaluate(`(async () => {
|
||||
const tree = await window.nexusterm.tree.list()
|
||||
for (const n of tree) {
|
||||
if (n.kind === 'host' && n.label.startsWith('${HOST_LABEL}')) await window.nexusterm.tree.deleteNode(n.id)
|
||||
}
|
||||
return true
|
||||
})()`)
|
||||
report.cleanedUp = true
|
||||
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
ws.close()
|
||||
@@ -0,0 +1,136 @@
|
||||
// Checks that a built package is actually runnable, rather than merely built.
|
||||
//
|
||||
// node scripts/verify-package.mjs [path/to/NexusTerm-*.AppImage]
|
||||
//
|
||||
// Native modules cannot be loaded from inside an asar archive, so they have to
|
||||
// be unpacked beside it — and when that goes wrong the app still builds, still
|
||||
// installs, and still starts. It fails later, on a user's machine, the first
|
||||
// time a terminal is opened. Nothing in `npm test` can catch that; this can.
|
||||
//
|
||||
// Runs the AppImage's own extractor rather than mounting it, so it works
|
||||
// without FUSE — which is exactly the situation in most CI containers.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const EXPECTED_NATIVE = [
|
||||
'node_modules/better-sqlite3/prebuilds/linux-x64.node',
|
||||
'node_modules/node-pty/build/Release/pty.node',
|
||||
]
|
||||
|
||||
/**
|
||||
* Other-platform *binaries*, not everything with a platform in its name.
|
||||
*
|
||||
* better-sqlite3 also ships `lib/darwin-arm64.js` and friends — small loader
|
||||
* shims that are part of the library on every platform. Flagging those would
|
||||
* make this check cry wolf, and a check that cries wolf gets ignored.
|
||||
*/
|
||||
const FORBIDDEN = [
|
||||
/win32-.*\.node$/,
|
||||
/win32-.*\.dll$/,
|
||||
/darwin-.*\.node$/,
|
||||
/darwin-.*spawn-helper$/,
|
||||
/linuxmusl-.*\.node$/,
|
||||
/linux-arm64.*\.node$/,
|
||||
]
|
||||
|
||||
function fail(message) {
|
||||
console.error(`\n ✗ ${message}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
function findDefaultAppImage() {
|
||||
const dir = resolve('release')
|
||||
if (!existsSync(dir)) return null
|
||||
const name = readdirSync(dir).find((f) => f.endsWith('.AppImage'))
|
||||
return name ? join(dir, name) : null
|
||||
}
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) walk(full, out)
|
||||
else out.push(full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const appImage = process.argv[2] ? resolve(process.argv[2]) : findDefaultAppImage()
|
||||
if (!appImage || !existsSync(appImage)) {
|
||||
fail('no AppImage found — build one first with `npx electron-builder --linux`')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(` inspecting ${appImage}`)
|
||||
|
||||
const workdir = mkdtempSync(join(tmpdir(), 'nexusterm-verify-'))
|
||||
|
||||
try {
|
||||
// Either a packaged AppImage or an already-unpacked `--dir` build. The dir
|
||||
// form matters because `electron-builder --dir` is what you iterate against —
|
||||
// it skips the squashfs and compression steps, which dominate a full build.
|
||||
let resources
|
||||
if (statSync(appImage).isDirectory()) {
|
||||
resources = join(appImage, 'resources')
|
||||
} else {
|
||||
// `--appimage-extract` writes ./squashfs-root relative to the working
|
||||
// directory, so this has to run from inside the scratch dir.
|
||||
execFileSync(appImage, ['--appimage-extract'], {
|
||||
cwd: workdir,
|
||||
stdio: 'ignore',
|
||||
timeout: 180_000,
|
||||
})
|
||||
resources = join(workdir, 'squashfs-root', 'resources')
|
||||
}
|
||||
|
||||
const unpacked = join(resources, 'app.asar.unpacked')
|
||||
|
||||
if (!existsSync(unpacked)) {
|
||||
fail('app.asar.unpacked is missing — every native module is trapped inside the archive')
|
||||
} else {
|
||||
const files = walk(unpacked)
|
||||
|
||||
for (const expected of EXPECTED_NATIVE) {
|
||||
const found = files.some((f) => f.endsWith(expected))
|
||||
if (found) console.log(` ✓ ${expected}`)
|
||||
else fail(`${expected} is not unpacked — this module will fail to load at runtime`)
|
||||
}
|
||||
|
||||
// ssh2's native crypto binding. Worth asserting separately because the
|
||||
// library reads as pure JavaScript, and because a failed load falls back to
|
||||
// a JS implementation silently — so nothing would ever report it.
|
||||
const sshCrypto = files.filter((f) => f.endsWith('sshcrypto.node'))
|
||||
if (sshCrypto.length > 0) console.log(` ✓ ssh2 sshcrypto.node (${sshCrypto.length})`)
|
||||
else fail('ssh2 sshcrypto.node is missing')
|
||||
|
||||
const strays = files.filter((f) => FORBIDDEN.some((p) => p.test(f)))
|
||||
if (strays.length === 0) {
|
||||
console.log(' ✓ no other-platform binaries')
|
||||
} else {
|
||||
// A warning rather than a failure: these are dead weight, not a defect.
|
||||
console.log(` ! ${strays.length} other-platform file(s) shipped:`)
|
||||
for (const stray of strays.slice(0, 5)) {
|
||||
console.log(` ${stray.slice(unpacked.length + 1)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const size = Number(
|
||||
execFileSync('du', ['-sb', unpacked], { encoding: 'utf8' }).split('\t')[0],
|
||||
)
|
||||
console.log(` · unpacked: ${(size / 1024 / 1024).toFixed(1)} MB`)
|
||||
|
||||
const asar = join(resources, 'app.asar')
|
||||
if (existsSync(asar)) {
|
||||
console.log(` · app.asar: ${(statSync(asar).size / 1024 / 1024).toFixed(1)} MB`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log(
|
||||
process.exitCode
|
||||
? '\n package verification FAILED\n'
|
||||
: '\n package verification passed\n',
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { closeDatabase, openDatabase, type Db } from './db/database'
|
||||
import { electronKeyring } from './security/keyring'
|
||||
import { Vault, type VaultStatus } from './security/vault'
|
||||
import { SessionManager } from './session/SessionManager'
|
||||
|
||||
export interface AppContext {
|
||||
db: Db
|
||||
vault: Vault
|
||||
sessions: SessionManager
|
||||
/** Captured at startup so the renderer can render the state without re-deriving it. */
|
||||
vaultStatus: VaultStatus
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the main process owns that has a lifecycle: the database
|
||||
* connection, the vault, and the live sessions.
|
||||
*
|
||||
* Constructed once, after the app is ready, and torn down on quit. Sessions are
|
||||
* disposed first — a shell still writing to a stream whose database has gone
|
||||
* away is a worse failure than a clean refusal to close. The vault is locked
|
||||
* before the database closes so the master key stops being usable even if a
|
||||
* later teardown step throws.
|
||||
*/
|
||||
export function createAppContext(): AppContext {
|
||||
const databasePath = join(app.getPath('userData'), 'nexusterm.db')
|
||||
const db = openDatabase(databasePath)
|
||||
|
||||
const vault = new Vault(db, electronKeyring)
|
||||
const vaultStatus = vault.initialize()
|
||||
const sessions = new SessionManager(db, vault)
|
||||
|
||||
return {
|
||||
db,
|
||||
vault,
|
||||
sessions,
|
||||
vaultStatus,
|
||||
dispose: () => {
|
||||
sessions.disposeAll()
|
||||
vault.lock()
|
||||
closeDatabase(db)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { MIGRATIONS } from './migrations'
|
||||
|
||||
export type Db = Database.Database
|
||||
|
||||
export interface OpenOptions {
|
||||
/** Skip WAL. In-memory databases do not support it. */
|
||||
memory?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the database, apply pragmas, and run any pending migrations.
|
||||
*
|
||||
* Called from the main process only. better-sqlite3 is synchronous, which is
|
||||
* exactly what we want here: the main process is the sole writer, the queries
|
||||
* are small, and synchronous access removes an entire class of interleaving
|
||||
* bugs that async drivers would introduce.
|
||||
*/
|
||||
export function openDatabase(path: string, options: OpenOptions = {}): Db {
|
||||
const db = new Database(path)
|
||||
|
||||
if (!options.memory) {
|
||||
// WAL lets reads proceed during writes. With a single writer the practical
|
||||
// benefit is durability behaviour rather than concurrency, but it also
|
||||
// makes crashes much less likely to corrupt the file.
|
||||
db.pragma('journal_mode = WAL')
|
||||
}
|
||||
|
||||
// Off by default in SQLite. Without this, ON DELETE CASCADE is silently
|
||||
// ignored and deleting a folder would orphan its hosts.
|
||||
db.pragma('foreign_keys = ON')
|
||||
db.pragma('synchronous = NORMAL')
|
||||
db.pragma('busy_timeout = 5000')
|
||||
|
||||
migrate(db)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply every migration newer than the database's recorded `user_version`.
|
||||
*
|
||||
* Each migration runs inside a transaction. SQLite makes DDL transactional, so
|
||||
* a migration that fails partway leaves the database exactly as it was rather
|
||||
* than half-migrated — which is the difference between a recoverable bug and a
|
||||
* corrupted install.
|
||||
*/
|
||||
export function migrate(db: Db): number {
|
||||
const current = db.pragma('user_version', { simple: true }) as number
|
||||
const pending = MIGRATIONS.filter((m) => m.version > current).sort(
|
||||
(a, b) => a.version - b.version,
|
||||
)
|
||||
|
||||
for (const migration of pending) {
|
||||
const rebuild = migration.rebuildsTables === true
|
||||
|
||||
// Outside the transaction on purpose: SQLite silently ignores both of
|
||||
// these inside one.
|
||||
if (rebuild) {
|
||||
db.pragma('foreign_keys = OFF')
|
||||
db.pragma('legacy_alter_table = ON')
|
||||
}
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.exec(migration.sql)
|
||||
|
||||
if (rebuild) {
|
||||
// A rebuild that leaves dangling references must not commit. Failing
|
||||
// here rolls the whole migration back, so a bad rebuild leaves the
|
||||
// previous schema intact rather than a half-converted database.
|
||||
const violations = db.pragma('foreign_key_check') as unknown[]
|
||||
if (violations.length > 0) {
|
||||
throw new Error(
|
||||
`migration ${migration.version} (${migration.name}) left ` +
|
||||
`${violations.length} foreign key violation(s)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Interpolated, not bound: PRAGMA does not accept bound parameters. The
|
||||
// value comes from our own migration table, never from user input.
|
||||
db.pragma(`user_version = ${migration.version}`)
|
||||
})()
|
||||
} finally {
|
||||
if (rebuild) {
|
||||
db.pragma('legacy_alter_table = OFF')
|
||||
db.pragma('foreign_keys = ON')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pending.length
|
||||
}
|
||||
|
||||
export function closeDatabase(db: Db): void {
|
||||
db.close()
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Initial schema.
|
||||
--
|
||||
-- Timestamps are unix milliseconds (INTEGER): sortable, timezone-free, and
|
||||
-- directly comparable against Date.now() without a parse on every read.
|
||||
--
|
||||
-- Encryption boundary — only three columns in this database hold ciphertext:
|
||||
-- credentials.ciphertext, private_keys.ciphertext, master_key_meta.ciphertext
|
||||
-- Everything else (hostnames, ports, usernames, folder names) is plaintext on
|
||||
-- purpose. The connection inventory has to be queryable, sortable and
|
||||
-- searchable; encrypting it wholesale would degrade all three, and a leaked
|
||||
-- hostname is a different order of problem from a leaked password.
|
||||
|
||||
-- ---------------------------------------------------------------- the tree --
|
||||
--
|
||||
-- Folders and hosts form one adjacency list, each with a nullable parent.
|
||||
-- Chosen over a materialized path or nested set because real depth is 3-5
|
||||
-- levels, so a recursive CTE read is cheap, while a move is a single-row
|
||||
-- UPDATE — which is exactly what drag-and-drop does constantly.
|
||||
|
||||
CREATE TABLE folders (
|
||||
id INTEGER PRIMARY KEY,
|
||||
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
-- REAL, not INTEGER. Reordering writes the midpoint between two neighbours
|
||||
-- instead of renumbering every sibling. The cost is precision exhaustion
|
||||
-- after ~50 repeated midpoint inserts in the same gap, at which point the
|
||||
-- layer is renumbered in one pass.
|
||||
sort_order REAL NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_folders_parent ON folders (parent_id, sort_order);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22 CHECK (port BETWEEN 1 AND 65535),
|
||||
username TEXT NOT NULL,
|
||||
auth_type TEXT NOT NULL CHECK (auth_type IN ('password', 'key', 'agent')),
|
||||
-- Only meaningful when auth_type = 'key'.
|
||||
key_path TEXT,
|
||||
color TEXT,
|
||||
sort_order REAL NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_hosts_folder ON hosts (folder_id, sort_order);
|
||||
|
||||
-- ----------------------------------------------------------- the secrets --
|
||||
|
||||
CREATE TABLE credentials (
|
||||
id INTEGER PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('password', 'passphrase')),
|
||||
ciphertext BLOB NOT NULL,
|
||||
nonce BLOB NOT NULL,
|
||||
-- Per-record HKDF salt. The data key is derived as HKDF(master, salt, info)
|
||||
-- rather than using the master key directly, so a nonce collision in one
|
||||
-- record cannot compromise another.
|
||||
salt BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
-- One password (and one passphrase) per host.
|
||||
UNIQUE (host_id, kind)
|
||||
);
|
||||
|
||||
-- Imported private key material, for keys the user pastes in rather than
|
||||
-- referencing by path. Same encryption treatment as credentials.
|
||||
CREATE TABLE private_keys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
ciphertext BLOB NOT NULL,
|
||||
nonce BLOB NOT NULL,
|
||||
salt BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- The data master key (DMK), sealed by the OS keyring via Electron's
|
||||
-- safeStorage. Exactly one row, enforced by the CHECK.
|
||||
CREATE TABLE master_key_meta (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
ciphertext BLOB NOT NULL,
|
||||
-- Which safeStorage backend sealed it. Recorded so a later downgrade is
|
||||
-- detectable: a record sealed by gnome_libsecret on a machine that now
|
||||
-- reports basic_text means the keyring changed underneath us, and an
|
||||
-- opaque "decrypt failed" would be the wrong thing to show the user.
|
||||
backend TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------- trust and tunnels --
|
||||
|
||||
-- Trust-on-first-use host key store.
|
||||
CREATE TABLE known_hosts (
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
key_type TEXT NOT NULL,
|
||||
-- SHA256 base64 fingerprint, OpenSSH format.
|
||||
fingerprint TEXT NOT NULL,
|
||||
added_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (host, port, key_type)
|
||||
);
|
||||
|
||||
CREATE TABLE port_forwards (
|
||||
id INTEGER PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL CHECK (type IN ('local', 'remote', 'dynamic')),
|
||||
bind_host TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
bind_port INTEGER NOT NULL,
|
||||
-- Null for 'dynamic' (-D), which is a SOCKS proxy and has no fixed target.
|
||||
dest_host TEXT,
|
||||
dest_port INTEGER,
|
||||
auto_start INTEGER NOT NULL DEFAULT 0 CHECK (auto_start IN (0, 1)),
|
||||
created_at INTEGER NOT NULL,
|
||||
CHECK (
|
||||
(type = 'dynamic' AND dest_host IS NULL AND dest_port IS NULL)
|
||||
OR (type IN ('local', 'remote') AND dest_host IS NOT NULL AND dest_port IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_port_forwards_host ON port_forwards (host_id);
|
||||
|
||||
CREATE TABLE history (
|
||||
id INTEGER PRIMARY KEY,
|
||||
host_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL,
|
||||
connected_at INTEGER NOT NULL,
|
||||
duration_ms INTEGER,
|
||||
exit_status TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_history_host ON history (host_id, connected_at DESC);
|
||||
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,156 @@
|
||||
-- Give folders and hosts a single ordering space so they can interleave.
|
||||
--
|
||||
-- Before this, `folders.sort_order` and `hosts.sort_order` were independent
|
||||
-- sequences in separate tables. Their values were not comparable, so the tree
|
||||
-- could only ever render all folders before all hosts, and dragging a host
|
||||
-- between two folders was not expressible at all.
|
||||
--
|
||||
-- The rebuild introduces `nodes` as the tree structure — id, kind, parent,
|
||||
-- position — and leaves `folders`/`hosts` holding only their own attributes,
|
||||
-- keyed by node id. `folders.id` and `hosts.id` ARE node ids, deliberately
|
||||
-- named `id` rather than `node_id` so the `REFERENCES hosts(id)` clauses in
|
||||
-- `credentials`, `port_forwards` and `history` keep resolving without those
|
||||
-- tables being rebuilt.
|
||||
--
|
||||
-- Runs with foreign_keys OFF and legacy_alter_table ON; see the runner in
|
||||
-- `database.ts` for why both are required.
|
||||
|
||||
PRAGMA legacy_alter_table = ON;
|
||||
|
||||
ALTER TABLE folders RENAME TO folders_old;
|
||||
ALTER TABLE hosts RENAME TO hosts_old;
|
||||
|
||||
-- ---------------------------------------------------------------- structure
|
||||
|
||||
CREATE TABLE nodes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('folder', 'host')),
|
||||
parent_id INTEGER REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
sort_order REAL NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
-- Holds the pre-migration id while this migration runs, so old and new rows
|
||||
-- can be paired without depending on the order SQLite happens to assign
|
||||
-- rowids. Dropped at the end.
|
||||
legacy_id INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX idx_nodes_parent ON nodes (parent_id, sort_order);
|
||||
CREATE INDEX idx_nodes_legacy ON nodes (kind, legacy_id);
|
||||
|
||||
-- ---------------------------------------------------------------- attributes
|
||||
|
||||
CREATE TABLE folders (
|
||||
id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
|
||||
username TEXT NOT NULL,
|
||||
auth_type TEXT NOT NULL CHECK (auth_type IN ('password', 'key', 'agent')),
|
||||
key_path TEXT,
|
||||
color TEXT
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------- move data
|
||||
|
||||
INSERT INTO nodes (kind, parent_id, sort_order, created_at, updated_at, legacy_id)
|
||||
SELECT 'folder', NULL, sort_order, created_at, updated_at, id FROM folders_old;
|
||||
|
||||
INSERT INTO nodes (kind, parent_id, sort_order, created_at, updated_at, legacy_id)
|
||||
SELECT 'host', NULL, sort_order, created_at, updated_at, id FROM hosts_old;
|
||||
|
||||
-- Reconnect parents. `legacy_id` is only meaningful within a kind, which the
|
||||
-- WHERE clauses and the join conditions both pin down.
|
||||
UPDATE nodes
|
||||
SET parent_id = (
|
||||
SELECT parent_node.id
|
||||
FROM nodes AS parent_node
|
||||
WHERE parent_node.kind = 'folder'
|
||||
AND parent_node.legacy_id = (
|
||||
SELECT old.parent_id FROM folders_old AS old WHERE old.id = nodes.legacy_id
|
||||
)
|
||||
)
|
||||
WHERE kind = 'folder'
|
||||
AND (SELECT old.parent_id FROM folders_old AS old WHERE old.id = nodes.legacy_id) IS NOT NULL;
|
||||
|
||||
UPDATE nodes
|
||||
SET parent_id = (
|
||||
SELECT parent_node.id
|
||||
FROM nodes AS parent_node
|
||||
WHERE parent_node.kind = 'folder'
|
||||
AND parent_node.legacy_id = (
|
||||
SELECT old.folder_id FROM hosts_old AS old WHERE old.id = nodes.legacy_id
|
||||
)
|
||||
)
|
||||
WHERE kind = 'host'
|
||||
AND (SELECT old.folder_id FROM hosts_old AS old WHERE old.id = nodes.legacy_id) IS NOT NULL;
|
||||
|
||||
-- Renumber each layer into a single sequence.
|
||||
--
|
||||
-- The two old sequences collide head-on: a folder with sort_order 1024 and a
|
||||
-- host with 1024 are now siblings competing for the same slot. Enumerating the
|
||||
-- layer, folders first and old order within each kind, both resolves the ties
|
||||
-- and reproduces the order the tree displayed before this migration — so
|
||||
-- nothing visibly jumps, and the user can rearrange from there.
|
||||
UPDATE nodes
|
||||
SET sort_order = (
|
||||
SELECT numbered.position * 1024.0
|
||||
FROM (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY parent_id
|
||||
ORDER BY CASE kind WHEN 'folder' THEN 0 ELSE 1 END, sort_order, id
|
||||
) AS position
|
||||
FROM nodes
|
||||
) AS numbered
|
||||
WHERE numbered.id = nodes.id
|
||||
);
|
||||
|
||||
INSERT INTO folders (id, name)
|
||||
SELECT node.id, old.name
|
||||
FROM folders_old AS old
|
||||
JOIN nodes AS node ON node.kind = 'folder' AND node.legacy_id = old.id;
|
||||
|
||||
INSERT INTO hosts (id, label, hostname, port, username, auth_type, key_path, color)
|
||||
SELECT node.id, old.label, old.hostname, old.port, old.username, old.auth_type,
|
||||
old.key_path, old.color
|
||||
FROM hosts_old AS old
|
||||
JOIN nodes AS node ON node.kind = 'host' AND node.legacy_id = old.id;
|
||||
|
||||
-- ---------------------------------------------------------------- dependents
|
||||
--
|
||||
-- All three are empty in practice at this point, but the migration must not
|
||||
-- depend on that — silently orphaning saved credentials is exactly the kind of
|
||||
-- bug that only surfaces months later.
|
||||
|
||||
UPDATE credentials
|
||||
SET host_id = (
|
||||
SELECT node.id FROM nodes AS node
|
||||
WHERE node.kind = 'host' AND node.legacy_id = credentials.host_id
|
||||
);
|
||||
|
||||
UPDATE port_forwards
|
||||
SET host_id = (
|
||||
SELECT node.id FROM nodes AS node
|
||||
WHERE node.kind = 'host' AND node.legacy_id = port_forwards.host_id
|
||||
);
|
||||
|
||||
UPDATE history
|
||||
SET host_id = (
|
||||
SELECT node.id FROM nodes AS node
|
||||
WHERE node.kind = 'host' AND node.legacy_id = history.host_id
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------- teardown
|
||||
|
||||
DROP TABLE folders_old;
|
||||
DROP TABLE hosts_old;
|
||||
|
||||
DROP INDEX idx_nodes_legacy;
|
||||
ALTER TABLE nodes DROP COLUMN legacy_id;
|
||||
@@ -0,0 +1,34 @@
|
||||
import init001 from './001_init.sql?raw'
|
||||
import interleave002 from './002_interleave.sql?raw'
|
||||
|
||||
export interface Migration {
|
||||
version: number
|
||||
name: string
|
||||
sql: string
|
||||
/**
|
||||
* Run with foreign keys disabled and re-verified afterwards.
|
||||
*
|
||||
* Required for table rebuilds, which is SQLite's only way to restructure a
|
||||
* table. Two reasons it is unavoidable: intermediate states during a rebuild
|
||||
* legitimately violate foreign keys, and `ALTER TABLE ... RENAME` would
|
||||
* otherwise rewrite references in *other* tables — silently repointing
|
||||
* `credentials` at a table that is about to be dropped.
|
||||
*
|
||||
* Both pragmas must be toggled outside the transaction; SQLite ignores them
|
||||
* inside one.
|
||||
*/
|
||||
rebuildsTables?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The migration list, applied in ascending version order and tracked by
|
||||
* SQLite's `PRAGMA user_version`.
|
||||
*
|
||||
* Append-only: once a migration has shipped, never edit it — a database in the
|
||||
* field has already applied the old text, and editing it means two machines
|
||||
* disagree about what version 1 means. Add a new entry instead.
|
||||
*/
|
||||
export const MIGRATIONS: readonly Migration[] = [
|
||||
{ version: 1, name: 'init', sql: init001 },
|
||||
{ version: 2, name: 'interleave folders and hosts', sql: interleave002, rebuildsTables: true },
|
||||
]
|
||||
@@ -0,0 +1,363 @@
|
||||
import type { Db } from '../database'
|
||||
import type { AuthType, FolderNode, HostInput, HostNode, TreeNode } from '@shared/ipc-schemas'
|
||||
import { sortOrderBetween } from './ordering'
|
||||
|
||||
/**
|
||||
* The connection tree.
|
||||
*
|
||||
* Folders and hosts live as rows in one `nodes` table with a `kind`
|
||||
* discriminator, sharing a single id space and one `sort_order` sequence per
|
||||
* layer. That is what makes free interleaving possible: a host can be dropped
|
||||
* between two folders because there is only one ordering to reason about.
|
||||
*
|
||||
* Their attributes live in `folders` and `hosts`, keyed by node id. The split
|
||||
* exists so a foreign key to `hosts(id)` genuinely cannot be satisfied by a
|
||||
* folder — `credentials` and `port_forwards` rely on that, and a `kind`
|
||||
* discriminator alone cannot express it.
|
||||
*/
|
||||
|
||||
export type NodeKind = 'folder' | 'host'
|
||||
|
||||
export interface HostRecord {
|
||||
id: number
|
||||
parentId: number | null
|
||||
label: string
|
||||
hostname: string
|
||||
port: number
|
||||
username: string
|
||||
authType: AuthType
|
||||
keyPath: string | null
|
||||
color: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
interface NodeRow {
|
||||
id: number
|
||||
kind: NodeKind
|
||||
parent_id: number | null
|
||||
sort_order: number
|
||||
name: string | null
|
||||
label: string | null
|
||||
hostname: string | null
|
||||
port: number | null
|
||||
username: string | null
|
||||
auth_type: AuthType | null
|
||||
key_path: string | null
|
||||
color: string | null
|
||||
}
|
||||
|
||||
const SELECT_NODES = `
|
||||
SELECT n.id, n.kind, n.parent_id, n.sort_order,
|
||||
f.name,
|
||||
h.label, h.hostname, h.port, h.username, h.auth_type, h.key_path, h.color
|
||||
FROM nodes n
|
||||
LEFT JOIN folders f ON f.id = n.id
|
||||
LEFT JOIN hosts h ON h.id = n.id
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------- reading
|
||||
|
||||
/**
|
||||
* Assemble the nested tree the sidebar renders.
|
||||
*
|
||||
* One query, linked in memory. A recursive CTE per level would be more SQL and
|
||||
* slower at this scale — a connection inventory is hundreds of rows.
|
||||
*/
|
||||
export function getConnectionTree(db: Db): TreeNode[] {
|
||||
const rows = db.prepare(`${SELECT_NODES} ORDER BY n.parent_id, n.sort_order`).all() as NodeRow[]
|
||||
|
||||
const childrenOf = new Map<number | null, TreeNode[]>()
|
||||
|
||||
for (const row of rows) {
|
||||
const node = toTreeNode(row)
|
||||
if (!node) continue
|
||||
|
||||
const bucket = childrenOf.get(row.parent_id)
|
||||
if (bucket) bucket.push(node)
|
||||
else childrenOf.set(row.parent_id, [node])
|
||||
}
|
||||
|
||||
const fill = (parentId: number | null): TreeNode[] =>
|
||||
(childrenOf.get(parentId) ?? []).map((node) => {
|
||||
if (node.kind === 'folder') {
|
||||
node.children = fill(node.id)
|
||||
}
|
||||
return node
|
||||
})
|
||||
|
||||
return fill(null)
|
||||
}
|
||||
|
||||
function toTreeNode(row: NodeRow): TreeNode | null {
|
||||
if (row.kind === 'folder') {
|
||||
// A node row without its folder row would mean the rebuild left the
|
||||
// structure and the attributes out of step. Skipping it silently would
|
||||
// hide data loss, so it is worth noticing — but it must not take the whole
|
||||
// tree down with it.
|
||||
if (row.name === null) return null
|
||||
|
||||
const node: FolderNode = {
|
||||
kind: 'folder',
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
name: row.name,
|
||||
sortOrder: row.sort_order,
|
||||
children: [],
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
if (
|
||||
row.label === null ||
|
||||
row.hostname === null ||
|
||||
row.port === null ||
|
||||
row.username === null ||
|
||||
row.auth_type === null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const node: HostNode = {
|
||||
kind: 'host',
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
label: row.label,
|
||||
hostname: row.hostname,
|
||||
port: row.port,
|
||||
username: row.username,
|
||||
authType: row.auth_type,
|
||||
color: row.color,
|
||||
sortOrder: row.sort_order,
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
export function getHost(db: Db, id: number): HostRecord | null {
|
||||
const row = db
|
||||
.prepare(`${SELECT_NODES} WHERE n.id = ? AND n.kind = 'host'`)
|
||||
.get(id) as NodeRow | undefined
|
||||
|
||||
if (
|
||||
!row ||
|
||||
row.label === null ||
|
||||
row.hostname === null ||
|
||||
row.port === null ||
|
||||
row.username === null ||
|
||||
row.auth_type === null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
label: row.label,
|
||||
hostname: row.hostname,
|
||||
port: row.port,
|
||||
username: row.username,
|
||||
authType: row.auth_type,
|
||||
keyPath: row.key_path,
|
||||
color: row.color,
|
||||
sortOrder: row.sort_order,
|
||||
}
|
||||
}
|
||||
|
||||
export function listHosts(db: Db): HostRecord[] {
|
||||
const rows = db
|
||||
.prepare(`${SELECT_NODES} WHERE n.kind = 'host' ORDER BY n.parent_id, n.sort_order`)
|
||||
.all() as NodeRow[]
|
||||
|
||||
return rows.flatMap((row) => {
|
||||
const host = getHostFromRow(row)
|
||||
return host ? [host] : []
|
||||
})
|
||||
}
|
||||
|
||||
function getHostFromRow(row: NodeRow): HostRecord | null {
|
||||
if (
|
||||
row.label === null ||
|
||||
row.hostname === null ||
|
||||
row.port === null ||
|
||||
row.username === null ||
|
||||
row.auth_type === null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
parentId: row.parent_id,
|
||||
label: row.label,
|
||||
hostname: row.hostname,
|
||||
port: row.port,
|
||||
username: row.username,
|
||||
authType: row.auth_type,
|
||||
keyPath: row.key_path,
|
||||
color: row.color,
|
||||
sortOrder: row.sort_order,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- writing
|
||||
|
||||
/** Position a new node at the end of its layer. */
|
||||
function appendSortOrder(db: Db, parentId: number | null): number {
|
||||
const last = db
|
||||
.prepare('SELECT sort_order FROM nodes WHERE parent_id IS ? ORDER BY sort_order DESC LIMIT 1')
|
||||
.get(parentId) as { sort_order: number } | undefined
|
||||
|
||||
return sortOrderBetween(last?.sort_order ?? null, null)
|
||||
}
|
||||
|
||||
function insertNode(
|
||||
db: Db,
|
||||
kind: NodeKind,
|
||||
parentId: number | null,
|
||||
sortOrder: number,
|
||||
now: number,
|
||||
): number {
|
||||
const result = db
|
||||
.prepare(
|
||||
'INSERT INTO nodes (kind, parent_id, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
|
||||
)
|
||||
.run(kind, parentId, sortOrder, now, now)
|
||||
|
||||
return Number(result.lastInsertRowid)
|
||||
}
|
||||
|
||||
export function createFolder(db: Db, parentId: number | null, name: string): number {
|
||||
const now = Date.now()
|
||||
|
||||
return db.transaction(() => {
|
||||
const id = insertNode(db, 'folder', parentId, appendSortOrder(db, parentId), now)
|
||||
db.prepare('INSERT INTO folders (id, name) VALUES (?, ?)').run(id, name)
|
||||
return id
|
||||
})()
|
||||
}
|
||||
|
||||
export function createHost(db: Db, input: HostInput): number {
|
||||
const now = Date.now()
|
||||
|
||||
// Node and attributes must land together. A node without its host row would
|
||||
// render as nothing while still occupying a position and an id.
|
||||
return db.transaction(() => {
|
||||
const id = insertNode(db, 'host', input.parentId, appendSortOrder(db, input.parentId), now)
|
||||
db.prepare(
|
||||
`INSERT INTO hosts (id, label, hostname, port, username, auth_type, key_path, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
id,
|
||||
input.label,
|
||||
input.hostname,
|
||||
input.port,
|
||||
input.username,
|
||||
input.authType,
|
||||
input.keyPath,
|
||||
input.color,
|
||||
)
|
||||
return id
|
||||
})()
|
||||
}
|
||||
|
||||
export function renameFolder(db: Db, id: number, name: string): void {
|
||||
const result = db.prepare('UPDATE folders SET name = ? WHERE id = ?').run(name, id)
|
||||
if (result.changes === 0) throw new Error(`folder ${id} not found`)
|
||||
|
||||
touch(db, id)
|
||||
}
|
||||
|
||||
export function updateHost(db: Db, id: number, input: HostInput): void {
|
||||
const result = db
|
||||
.prepare(
|
||||
`UPDATE hosts SET
|
||||
label = ?, hostname = ?, port = ?, username = ?,
|
||||
auth_type = ?, key_path = ?, color = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(
|
||||
input.label,
|
||||
input.hostname,
|
||||
input.port,
|
||||
input.username,
|
||||
input.authType,
|
||||
input.keyPath,
|
||||
input.color,
|
||||
id,
|
||||
)
|
||||
|
||||
// SQLite reports an UPDATE matching no rows as a successful no-op. Left
|
||||
// unchecked that becomes a UI reporting success for a change that never
|
||||
// happened — far more confusing to debug than a plain error.
|
||||
if (result.changes === 0) throw new Error(`host ${id} not found`)
|
||||
|
||||
touch(db, id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a node to a new parent, positioned between the given siblings.
|
||||
*
|
||||
* Works for folders and hosts alike, because both are the same kind of thing as
|
||||
* far as the tree is concerned.
|
||||
*/
|
||||
export function moveNode(
|
||||
db: Db,
|
||||
id: number,
|
||||
parentId: number | null,
|
||||
prevSiblingId: number | null = null,
|
||||
nextSiblingId: number | null = null,
|
||||
): void {
|
||||
if (parentId !== null) {
|
||||
if (parentId === id) throw new Error('a node cannot be its own parent')
|
||||
if (isDescendantOf(db, parentId, id)) {
|
||||
throw new Error(`cannot move node ${id} into its own descendant ${parentId}`)
|
||||
}
|
||||
}
|
||||
|
||||
const sortOrder = sortOrderBetween(readSortOrder(db, prevSiblingId), readSortOrder(db, nextSiblingId))
|
||||
|
||||
const result = db
|
||||
.prepare('UPDATE nodes SET parent_id = ?, sort_order = ?, updated_at = ? WHERE id = ?')
|
||||
.run(parentId, sortOrder, Date.now(), id)
|
||||
|
||||
if (result.changes === 0) throw new Error(`node ${id} not found`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a node and, for a folder, everything beneath it.
|
||||
*
|
||||
* Relies on `ON DELETE CASCADE`, which in turn relies on `PRAGMA
|
||||
* foreign_keys = ON` being set at connection open. That pragma is off by
|
||||
* default in SQLite — without it this orphans every descendant instead of
|
||||
* removing them, and the rows linger unreachable from the UI.
|
||||
*/
|
||||
export function deleteNode(db: Db, id: number): void {
|
||||
const result = db.prepare('DELETE FROM nodes WHERE id = ?').run(id)
|
||||
if (result.changes === 0) throw new Error(`node ${id} not found`)
|
||||
}
|
||||
|
||||
function touch(db: Db, id: number): void {
|
||||
db.prepare('UPDATE nodes SET updated_at = ? WHERE id = ?').run(Date.now(), id)
|
||||
}
|
||||
|
||||
function readSortOrder(db: Db, id: number | null): number | null {
|
||||
if (id === null) return null
|
||||
const row = db.prepare('SELECT sort_order FROM nodes WHERE id = ?').get(id) as
|
||||
| { sort_order: number }
|
||||
| undefined
|
||||
return row?.sort_order ?? null
|
||||
}
|
||||
|
||||
/** True when `candidate` is `ancestor` itself or sits anywhere beneath it. */
|
||||
export function isDescendantOf(db: Db, candidate: number, ancestor: number): boolean {
|
||||
const row = db
|
||||
.prepare(
|
||||
`WITH RECURSIVE subtree(id) AS (
|
||||
SELECT id FROM nodes WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN subtree s ON n.parent_id = s.id
|
||||
)
|
||||
SELECT 1 AS found FROM subtree WHERE id = ? LIMIT 1`,
|
||||
)
|
||||
.get(ancestor, candidate) as { found: number } | undefined
|
||||
|
||||
return row !== undefined
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Db } from '../database'
|
||||
import type { Vault } from '../../security/vault'
|
||||
import { SECRET_CONTEXT, type SealedSecret } from '../../security/secretBox'
|
||||
|
||||
export type CredentialKind = 'password' | 'passphrase'
|
||||
|
||||
interface CredentialRow {
|
||||
ciphertext: Buffer
|
||||
nonce: Buffer
|
||||
salt: Buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential storage. Every value goes through the vault, and every method here
|
||||
* takes the vault explicitly so that no code path can accidentally read or
|
||||
* write a secret without it.
|
||||
*
|
||||
* A locked vault makes `setCredential` throw rather than silently skipping the
|
||||
* write: a caller that believes it saved a password must not be told otherwise.
|
||||
*/
|
||||
|
||||
export function setCredential(
|
||||
db: Db,
|
||||
vault: Vault,
|
||||
hostId: number,
|
||||
kind: CredentialKind,
|
||||
secret: string,
|
||||
): void {
|
||||
const sealed = vault.sealSecret(secret, SECRET_CONTEXT.credential)
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO credentials (host_id, kind, ciphertext, nonce, salt, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (host_id, kind) DO UPDATE SET
|
||||
ciphertext = excluded.ciphertext,
|
||||
nonce = excluded.nonce,
|
||||
salt = excluded.salt,
|
||||
created_at = excluded.created_at`,
|
||||
).run(hostId, kind, sealed.ciphertext, sealed.nonce, sealed.salt, Date.now())
|
||||
}
|
||||
|
||||
export function getCredential(
|
||||
db: Db,
|
||||
vault: Vault,
|
||||
hostId: number,
|
||||
kind: CredentialKind,
|
||||
): string | null {
|
||||
const row = db
|
||||
.prepare('SELECT ciphertext, nonce, salt FROM credentials WHERE host_id = ? AND kind = ?')
|
||||
.get(hostId, kind) as CredentialRow | undefined
|
||||
|
||||
if (!row) return null
|
||||
|
||||
const sealed: SealedSecret = {
|
||||
ciphertext: row.ciphertext,
|
||||
nonce: row.nonce,
|
||||
salt: row.salt,
|
||||
}
|
||||
|
||||
return vault.unsealSecret(sealed, SECRET_CONTEXT.credential)
|
||||
}
|
||||
|
||||
export function hasCredential(db: Db, hostId: number, kind: CredentialKind): boolean {
|
||||
const row = db
|
||||
.prepare('SELECT 1 AS present FROM credentials WHERE host_id = ? AND kind = ?')
|
||||
.get(hostId, kind) as { present: number } | undefined
|
||||
return row !== undefined
|
||||
}
|
||||
|
||||
export function deleteCredential(db: Db, hostId: number, kind: CredentialKind): void {
|
||||
db.prepare('DELETE FROM credentials WHERE host_id = ? AND kind = ?').run(hostId, kind)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Db } from '../database'
|
||||
|
||||
export interface KnownHost {
|
||||
host: string
|
||||
port: number
|
||||
keyType: string
|
||||
fingerprint: string
|
||||
addedAt: number
|
||||
}
|
||||
|
||||
interface KnownHostRow {
|
||||
host: string
|
||||
port: number
|
||||
key_type: string
|
||||
fingerprint: string
|
||||
added_at: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust-on-first-use store.
|
||||
*
|
||||
* Keyed by (host, port, keyType) because a server legitimately presents one key
|
||||
* per algorithm, and matching only on the fingerprint would mean a server that
|
||||
* legitimately rotates its ed25519 key also silently re-trusts a new RSA key.
|
||||
*/
|
||||
export function findKnownHost(
|
||||
db: Db,
|
||||
host: string,
|
||||
port: number,
|
||||
keyType: string,
|
||||
): KnownHost | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT host, port, key_type, fingerprint, added_at FROM known_hosts WHERE host = ? AND port = ? AND key_type = ?',
|
||||
)
|
||||
.get(host, port, keyType) as KnownHostRow | undefined
|
||||
|
||||
if (!row) return null
|
||||
|
||||
return {
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
keyType: row.key_type,
|
||||
fingerprint: row.fingerprint,
|
||||
addedAt: row.added_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function listKnownHosts(db: Db, host: string, port: number): KnownHost[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT host, port, key_type, fingerprint, added_at FROM known_hosts WHERE host = ? AND port = ? ORDER BY key_type',
|
||||
)
|
||||
.all(host, port) as KnownHostRow[]
|
||||
|
||||
return rows.map((row) => ({
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
keyType: row.key_type,
|
||||
fingerprint: row.fingerprint,
|
||||
addedAt: row.added_at,
|
||||
}))
|
||||
}
|
||||
|
||||
export function recordKnownHost(
|
||||
db: Db,
|
||||
host: string,
|
||||
port: number,
|
||||
keyType: string,
|
||||
fingerprint: string,
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO known_hosts (host, port, key_type, fingerprint, added_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (host, port, key_type) DO UPDATE SET
|
||||
fingerprint = excluded.fingerprint,
|
||||
added_at = excluded.added_at`,
|
||||
).run(host, port, keyType, fingerprint, Date.now())
|
||||
}
|
||||
|
||||
/** Forget every key recorded for a host — the "trust it again" escape hatch. */
|
||||
export function forgetKnownHost(db: Db, host: string, port: number): void {
|
||||
db.prepare('DELETE FROM known_hosts WHERE host = ? AND port = ?').run(host, port)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Fractional ordering for the connection tree.
|
||||
*
|
||||
* `sort_order` is a REAL rather than an INTEGER so that reordering inserts a
|
||||
* value *between* two neighbours instead of renumbering every sibling. With N
|
||||
* hosts in a folder, a drag costs one UPDATE either way; the difference is that
|
||||
* this one stays O(1) as N grows.
|
||||
*
|
||||
* The tradeoff: repeatedly inserting into the same gap halves the interval each
|
||||
* time, and after roughly 50 such inserts the midpoint stops being
|
||||
* representable. `needsRebalance` detects that so the caller can renumber the
|
||||
* layer in one pass.
|
||||
*/
|
||||
|
||||
/** Default spacing between adjacent items. */
|
||||
export const SORT_GAP = 1024
|
||||
|
||||
/**
|
||||
* Smallest gap worth keeping. Below this, doubling precision has stopped buying
|
||||
* meaningful room and the layer should be renumbered.
|
||||
*/
|
||||
export const MIN_GAP = 1e-6
|
||||
|
||||
/**
|
||||
* Compute a sort order that places an item between `prev` and `next`.
|
||||
*
|
||||
* `null` means "no neighbour on that side" — i.e. the item is moving to the
|
||||
* start or the end of its layer.
|
||||
*/
|
||||
export function sortOrderBetween(prev: number | null, next: number | null): number {
|
||||
if (prev === null && next === null) return SORT_GAP
|
||||
if (prev === null) return (next as number) - SORT_GAP / 2
|
||||
if (next === null) return prev + SORT_GAP
|
||||
return (prev + next) / 2
|
||||
}
|
||||
|
||||
/** True when the gap between two neighbours has collapsed too far to split. */
|
||||
export function needsRebalance(prev: number | null, next: number | null): boolean {
|
||||
if (prev === null || next === null) return false
|
||||
return Math.abs(next - prev) < MIN_GAP
|
||||
}
|
||||
|
||||
/** Evenly spaced sort orders for renumbering a layer, preserving current order. */
|
||||
export function rebalancedOrders(count: number): number[] {
|
||||
return Array.from({ length: count }, (_, i) => (i + 1) * SORT_GAP)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Db } from '../database'
|
||||
|
||||
interface SettingRow {
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Untyped key/value store. Values are JSON so callers can persist structured settings. */
|
||||
export function getSetting<T>(db: Db, key: string, fallback: T): T {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as
|
||||
| SettingRow
|
||||
| undefined
|
||||
|
||||
if (!row) return fallback
|
||||
|
||||
try {
|
||||
return JSON.parse(row.value) as T
|
||||
} catch {
|
||||
// A corrupt value should not take down the caller. Falling back is the
|
||||
// right trade for settings, which are by definition non-critical.
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function setSetting(db: Db, key: string, value: unknown): void {
|
||||
db.prepare(
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value`,
|
||||
).run(key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
export function deleteSetting(db: Db, key: string): void {
|
||||
db.prepare('DELETE FROM settings WHERE key = ?').run(key)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Vite `?raw` imports, used to inline migration SQL at build time.
|
||||
*
|
||||
* Inlining matters: migrations must survive being packed into an asar archive,
|
||||
* and a runtime `readFileSync` against a path inside the bundle would not.
|
||||
*/
|
||||
declare module '*.sql?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { app, BrowserWindow, session, shell } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { createAppContext, type AppContext } from './context'
|
||||
import { registerIpcHandlers } from './ipc/registry'
|
||||
|
||||
/**
|
||||
* Content Security Policy.
|
||||
*
|
||||
* Applied as a response header from the main process rather than a `<meta>` tag
|
||||
* in index.html, because the dev server needs to be looser than production and
|
||||
* a meta tag cannot tell the two apart. `ws:` and the vite origin are required
|
||||
* for HMR; production gets the strict policy.
|
||||
*/
|
||||
const DEV_CSP = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"font-src 'self' data:",
|
||||
"img-src 'self' data:",
|
||||
"connect-src 'self' ws: http://localhost:*",
|
||||
].join('; ')
|
||||
|
||||
const PROD_CSP = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"font-src 'self' data:",
|
||||
"img-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
].join('; ')
|
||||
|
||||
const isDev = !!process.env.ELECTRON_RENDERER_URL
|
||||
|
||||
let context: AppContext | null = null
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 900,
|
||||
minHeight: 560,
|
||||
show: false,
|
||||
backgroundColor: '#14161a',
|
||||
title: 'NexusTerm',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
// These three are the security boundary. The renderer runs untrusted
|
||||
// content and must never reach Node, the filesystem, or ssh2 directly —
|
||||
// all privileged work happens in main behind a narrow, validated IPC API.
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webviewTag: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Avoid the white flash before React has painted.
|
||||
win.once('ready-to-show', () => win.show())
|
||||
|
||||
// Anything that tries to open a new window or navigate away goes to the
|
||||
// system browser instead. A terminal emulator has no business hosting
|
||||
// arbitrary web content in-process.
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
void shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
if (isDev) {
|
||||
void win.loadURL(process.env.ELECTRON_RENDERER_URL as string)
|
||||
} else {
|
||||
void win.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the security posture at startup.
|
||||
*
|
||||
* An unusable keyring or an unavailable vault is not a crash — it is a
|
||||
* condition the UI renders and the user has to decide about. Logging it here
|
||||
* means the diagnosis is in the terminal for anyone debugging, rather than
|
||||
* something that has to be inferred from a banner.
|
||||
*/
|
||||
function logSecurityPosture(ctx: AppContext): void {
|
||||
const { vaultStatus } = ctx
|
||||
|
||||
if (vaultStatus.state === 'unlocked') {
|
||||
console.info(
|
||||
`[security] vault unlocked (backend=${vaultStatus.backend ?? 'unknown'}` +
|
||||
`${vaultStatus.createdNow ? ', master key created' : ''})`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[security] vault unavailable (${vaultStatus.reason}): ${vaultStatus.detail}. ` +
|
||||
'Credentials cannot be stored on this machine.',
|
||||
)
|
||||
}
|
||||
|
||||
// A second instance would open the same SQLite file and fight over the
|
||||
// keychain. Refuse to start one, and focus the window that already exists.
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
const [win] = BrowserWindow.getAllWindows()
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.focus()
|
||||
}
|
||||
})
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
'Content-Security-Policy': [isDev ? DEV_CSP : PROD_CSP],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
context = createAppContext()
|
||||
logSecurityPosture(context)
|
||||
|
||||
registerIpcHandlers(context)
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
app.on('will-quit', () => {
|
||||
context?.dispose()
|
||||
context = null
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { CH } from '@shared/ipc-contract'
|
||||
import {
|
||||
appInfoSchema,
|
||||
connectInputSchema,
|
||||
credentialDeleteInputSchema,
|
||||
credentialRefInputSchema,
|
||||
credentialSetInputSchema,
|
||||
folderCreateInputSchema,
|
||||
folderRenameInputSchema,
|
||||
hostInputSchema,
|
||||
hostUpdateInputSchema,
|
||||
idInputSchema,
|
||||
moveInputSchema,
|
||||
sessionCloseInputSchema,
|
||||
type AppInfo,
|
||||
type TreeNode,
|
||||
} from '@shared/ipc-schemas'
|
||||
import type { AppContext } from '../context'
|
||||
import { inspectSafeStorage } from '../security/safeStorageStatus'
|
||||
import * as connections from '../db/repositories/connections'
|
||||
import {
|
||||
deleteCredential,
|
||||
hasCredential,
|
||||
setCredential,
|
||||
} from '../db/repositories/credentials'
|
||||
|
||||
/**
|
||||
* Central registration point for every `ipcMain.handle` channel.
|
||||
*
|
||||
* Keeping them in one place makes the main process's entire attack surface
|
||||
* reviewable at a glance — which matters because each handler is reachable from
|
||||
* the renderer, and the renderer is the part of the app most likely to be
|
||||
* compromised (remote content, dependency supply chain, injected script).
|
||||
*
|
||||
* Every handler parses its arguments through a schema before touching the
|
||||
* database. A renderer that has been tampered with should be able to send
|
||||
* nonsense and get a validation error, not a corrupted connection inventory.
|
||||
*
|
||||
* Note what is absent: no channel returns a stored secret. The renderer will
|
||||
* eventually ask to *connect to* a host, never to *read its password*.
|
||||
*/
|
||||
export function registerIpcHandlers(context: AppContext): void {
|
||||
const { db } = context
|
||||
|
||||
// Every mutation answers with the full refreshed tree. A move rewrites sort
|
||||
// orders, so patching the renderer's copy in place would mean reimplementing
|
||||
// the ordering rules on the client and keeping the two in step forever.
|
||||
const tree = (): TreeNode[] => connections.getConnectionTree(db)
|
||||
|
||||
ipcMain.handle(CH.APP_INFO, (): AppInfo =>
|
||||
appInfoSchema.parse({
|
||||
appVersion: app.getVersion(),
|
||||
electron: process.versions.electron,
|
||||
chrome: process.versions.chrome,
|
||||
node: process.versions.node,
|
||||
platform: process.platform,
|
||||
safeStorage: inspectSafeStorage(),
|
||||
vault: context.vault.status,
|
||||
}),
|
||||
)
|
||||
|
||||
ipcMain.handle(CH.TREE_LIST, tree)
|
||||
|
||||
ipcMain.handle(CH.FOLDER_CREATE, (_event, raw: unknown) => {
|
||||
const { parentId, name } = folderCreateInputSchema.parse(raw)
|
||||
connections.createFolder(db, parentId, name)
|
||||
return tree()
|
||||
})
|
||||
|
||||
ipcMain.handle(CH.FOLDER_RENAME, (_event, raw: unknown) => {
|
||||
const { id, name } = folderRenameInputSchema.parse(raw)
|
||||
connections.renameFolder(db, id, name)
|
||||
return tree()
|
||||
})
|
||||
|
||||
ipcMain.handle(CH.HOST_CREATE, (_event, raw: unknown) => {
|
||||
connections.createHost(db, hostInputSchema.parse(raw))
|
||||
return tree()
|
||||
})
|
||||
|
||||
ipcMain.handle(CH.HOST_UPDATE, (_event, raw: unknown) => {
|
||||
const { id, input } = hostUpdateInputSchema.parse(raw)
|
||||
connections.updateHost(db, id, input)
|
||||
return tree()
|
||||
})
|
||||
|
||||
ipcMain.handle(CH.NODE_MOVE, (_event, raw: unknown) => {
|
||||
const { id, parentId, prevSiblingId, nextSiblingId } = moveInputSchema.parse(raw)
|
||||
// Throws on a move into the node's own subtree; the message reaches the
|
||||
// renderer as a rejected promise and is shown to the user.
|
||||
connections.moveNode(db, id, parentId, prevSiblingId, nextSiblingId)
|
||||
return tree()
|
||||
})
|
||||
|
||||
ipcMain.handle(CH.NODE_DELETE, (_event, raw: unknown) => {
|
||||
const { id } = idInputSchema.parse(raw)
|
||||
connections.deleteNode(db, id)
|
||||
return tree()
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------- sessions
|
||||
|
||||
ipcMain.handle(CH.SESSION_OPEN, (event, raw: unknown) => {
|
||||
const input = connectInputSchema.parse(raw)
|
||||
return context.sessions.open(input, event.sender)
|
||||
})
|
||||
|
||||
ipcMain.handle(CH.SESSION_OPEN_LOCAL, (event) => context.sessions.openLocal(event.sender))
|
||||
|
||||
ipcMain.handle(CH.SESSION_CLOSE, (_event, raw: unknown) => {
|
||||
const { sessionId } = sessionCloseInputSchema.parse(raw)
|
||||
context.sessions.close(sessionId)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------- credentials
|
||||
//
|
||||
// Write-only. Note the asymmetry: `set` and `remove` exist, `has` answers
|
||||
// whether a secret is there, and nothing returns the value itself. The renderer
|
||||
// asks to *connect to* a host; it never asks for the password.
|
||||
|
||||
ipcMain.handle(CH.CREDENTIAL_SET, (_event, raw: unknown) => {
|
||||
const { hostId, kind, secret } = credentialSetInputSchema.parse(raw)
|
||||
// Throws when the vault is locked or the keyring is untrustworthy — the
|
||||
// caller is told the secret was not saved rather than being told it was.
|
||||
setCredential(db, context.vault, hostId, kind, secret)
|
||||
})
|
||||
|
||||
ipcMain.handle(CH.CREDENTIAL_DELETE, (_event, raw: unknown) => {
|
||||
const { hostId, kind } = credentialDeleteInputSchema.parse(raw)
|
||||
deleteCredential(db, hostId, kind)
|
||||
})
|
||||
|
||||
ipcMain.handle(CH.CREDENTIAL_HAS, (_event, raw: unknown) => {
|
||||
const { hostId, kind } = credentialRefInputSchema.parse(raw)
|
||||
return hasCredential(db, hostId, kind)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { safeStorage } from 'electron'
|
||||
import type { SafeStorageStatus } from '@shared/ipc-schemas'
|
||||
import { inspectSafeStorage } from './safeStorageStatus'
|
||||
|
||||
/**
|
||||
* The OS keyring, as the vault sees it.
|
||||
*
|
||||
* This is an interface rather than a direct `safeStorage` import so that the
|
||||
* vault's logic — master key lifecycle, refusal to run on an untrustworthy
|
||||
* keyring, locking — can be tested for real under plain vitest. Electron's
|
||||
* `safeStorage` only exists inside a running Electron process, so without this
|
||||
* seam the security-critical code would either go untested or be exercised
|
||||
* against a mock so loose that it proved nothing.
|
||||
*/
|
||||
export interface Keyring {
|
||||
status(): SafeStorageStatus
|
||||
/** Wrap a secret for storage. Returns an opaque blob. */
|
||||
wrap(plaintext: string): Buffer
|
||||
/** Unwrap a blob produced by `wrap`. Throws if it cannot be recovered. */
|
||||
unwrap(sealed: Buffer): string
|
||||
}
|
||||
|
||||
export const electronKeyring: Keyring = {
|
||||
status: inspectSafeStorage,
|
||||
wrap: (plaintext) => safeStorage.encryptString(plaintext),
|
||||
unwrap: (sealed) => safeStorage.decryptString(sealed),
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { safeStorage } from 'electron'
|
||||
import type { SafeStorageStatus } from '@shared/ipc-schemas'
|
||||
|
||||
/**
|
||||
* Inspect how — or whether — Electron can protect secrets on this machine.
|
||||
*
|
||||
* This is the single most load-bearing check in the app's security story, and
|
||||
* it is deliberately paranoid. On Linux, when no keyring daemon is reachable,
|
||||
* Electron does not fail: it silently selects a backend called `basic_text`
|
||||
* that "encrypts" using a hardcoded, publicly-known key. The database still
|
||||
* looks like ciphertext. Without this check the app would cheerfully store
|
||||
* passwords in a form equivalent to plaintext while reporting success.
|
||||
*
|
||||
* Verified on this machine: gnome-keyring-daemon is running and
|
||||
* DBUS_SESSION_BUS_ADDRESS is set, so a real backend should be selected — but
|
||||
* that must not be assumed for anyone else's machine.
|
||||
*/
|
||||
export function inspectSafeStorage(): SafeStorageStatus {
|
||||
let available = false
|
||||
|
||||
try {
|
||||
available = safeStorage.isEncryptionAvailable()
|
||||
} catch {
|
||||
// Throws if called before the app is ready.
|
||||
return {
|
||||
available: false,
|
||||
backend: null,
|
||||
secure: false,
|
||||
reason: 'not-ready',
|
||||
}
|
||||
}
|
||||
|
||||
if (!available) {
|
||||
return {
|
||||
available: false,
|
||||
backend: null,
|
||||
secure: false,
|
||||
reason: 'unavailable',
|
||||
}
|
||||
}
|
||||
|
||||
// `getSelectedStorageBackend()` exists ONLY on Linux. Calling it on macOS or
|
||||
// Windows throws a TypeError, which would crash the app at startup — so the
|
||||
// platform guard here is mandatory, not defensive style.
|
||||
let backend: string | null = null
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
backend = safeStorage.getSelectedStorageBackend()
|
||||
} catch {
|
||||
backend = null
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== 'linux') {
|
||||
// Keychain (macOS) and DPAPI (Windows) are both real OS-level protection.
|
||||
return { available: true, backend: null, secure: true, reason: 'ok' }
|
||||
}
|
||||
|
||||
if (backend === 'basic_text') {
|
||||
return { available: true, backend, secure: false, reason: 'basic-text' }
|
||||
}
|
||||
|
||||
if (backend === null) {
|
||||
return { available: true, backend, secure: false, reason: 'unknown-backend' }
|
||||
}
|
||||
|
||||
// kwallet, kwallet5, kwallet6, gnome_libsecret
|
||||
return { available: true, backend, secure: true, reason: 'ok' }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Authenticated encryption for individual secrets.
|
||||
*
|
||||
* AES-256-GCM gives confidentiality and integrity together: a tampered
|
||||
* ciphertext fails to decrypt rather than yielding garbage, so a corrupted or
|
||||
* maliciously edited database row surfaces as an error the caller must handle
|
||||
* rather than as a wrong password silently sent to a server.
|
||||
*/
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
const KEY_BYTES = 32
|
||||
/** 96-bit nonce — the size GCM is specified and optimized for. */
|
||||
const NONCE_BYTES = 12
|
||||
const SALT_BYTES = 16
|
||||
const TAG_BYTES = 16
|
||||
|
||||
/** HKDF `info` strings. Changing one makes existing ciphertext undecryptable. */
|
||||
export const SECRET_CONTEXT = {
|
||||
credential: 'nexusterm/v1/credential',
|
||||
privateKey: 'nexusterm/v1/private-key',
|
||||
} as const
|
||||
|
||||
export type SecretContext = (typeof SECRET_CONTEXT)[keyof typeof SECRET_CONTEXT]
|
||||
|
||||
export interface SealedSecret {
|
||||
/** Ciphertext with the 16-byte GCM auth tag appended. */
|
||||
ciphertext: Buffer
|
||||
nonce: Buffer
|
||||
/** Per-record HKDF salt. */
|
||||
salt: Buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a per-record data key from the master key.
|
||||
*
|
||||
* Each record gets its own salt, so the same master key produces unrelated
|
||||
* data keys. This is belt-and-braces on top of random nonces: even if two
|
||||
* records somehow ended up sharing a nonce, the keys would still differ and
|
||||
* neither would leak the other.
|
||||
*/
|
||||
export function deriveKey(masterKey: Buffer, salt: Buffer, context: string): Buffer {
|
||||
return Buffer.from(hkdfSync('sha256', masterKey, salt, context, KEY_BYTES))
|
||||
}
|
||||
|
||||
export function seal(
|
||||
masterKey: Buffer,
|
||||
plaintext: string,
|
||||
context: string,
|
||||
salt: Buffer = randomBytes(SALT_BYTES),
|
||||
): SealedSecret {
|
||||
const key = deriveKey(masterKey, salt, context)
|
||||
const nonce = randomBytes(NONCE_BYTES)
|
||||
|
||||
const cipher = createCipheriv(ALGORITHM, key, nonce)
|
||||
const body = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
|
||||
return {
|
||||
ciphertext: Buffer.concat([body, cipher.getAuthTag()]),
|
||||
nonce,
|
||||
salt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws if the ciphertext fails authentication — wrong key, tampered data, or
|
||||
* truncated. Callers must treat a throw as "cannot recover this secret", never
|
||||
* as "empty password".
|
||||
*/
|
||||
export function unseal(masterKey: Buffer, sealed: SealedSecret, context: string): string {
|
||||
if (sealed.ciphertext.length < TAG_BYTES) {
|
||||
throw new Error('sealed secret is too short to contain an auth tag')
|
||||
}
|
||||
|
||||
const key = deriveKey(masterKey, sealed.salt, context)
|
||||
|
||||
const tag = sealed.ciphertext.subarray(sealed.ciphertext.length - TAG_BYTES)
|
||||
const body = sealed.ciphertext.subarray(0, sealed.ciphertext.length - TAG_BYTES)
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, sealed.nonce)
|
||||
decipher.setAuthTag(tag)
|
||||
|
||||
return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8')
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type { Db } from '../db/database'
|
||||
import type { Keyring } from './keyring'
|
||||
import { seal, unseal, type SealedSecret } from './secretBox'
|
||||
|
||||
/** 256-bit data master key. */
|
||||
const DMK_BYTES = 32
|
||||
|
||||
export type VaultStatus =
|
||||
| { state: 'unlocked'; backend: string | null; createdNow: boolean }
|
||||
| {
|
||||
state: 'unavailable'
|
||||
reason: 'insecure-keyring' | 'seal-failed' | 'unseal-failed'
|
||||
detail: string
|
||||
}
|
||||
|
||||
interface MasterKeyRow {
|
||||
ciphertext: Buffer
|
||||
backend: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the data master key (DMK) and is the only place plaintext secrets
|
||||
* exist. Everything else — repositories, IPC, the renderer — deals in sealed
|
||||
* blobs.
|
||||
*
|
||||
* The DMK is wrapped by the OS keyring and stored in `master_key_meta`. It is
|
||||
* never written anywhere else, never sent over IPC, and never returned by any
|
||||
* method here. Note there is deliberately no `getMasterKey()`: the moment such
|
||||
* a method exists, some caller will eventually expose it across the process
|
||||
* boundary, and every saved credential goes with it.
|
||||
*
|
||||
* When the keyring is not trustworthy the vault refuses to seal rather than
|
||||
* degrading to something weaker. Storing a password under a hardcoded key is
|
||||
* worse than refusing to store it, because the user believes it is protected.
|
||||
*/
|
||||
export class Vault {
|
||||
private dmk: Buffer | null = null
|
||||
private currentStatus: VaultStatus = {
|
||||
state: 'unavailable',
|
||||
reason: 'insecure-keyring',
|
||||
detail: 'not initialized',
|
||||
}
|
||||
|
||||
private readonly db: Db
|
||||
private readonly keyring: Keyring
|
||||
|
||||
constructor(db: Db, keyring: Keyring) {
|
||||
this.db = db
|
||||
this.keyring = keyring
|
||||
}
|
||||
|
||||
get status(): VaultStatus {
|
||||
return this.currentStatus
|
||||
}
|
||||
|
||||
get isUnlocked(): boolean {
|
||||
return this.dmk !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the DMK, generating one on first run.
|
||||
*
|
||||
* Returns a status rather than throwing, because "this machine cannot protect
|
||||
* secrets" is a state the app must render, not an error it should crash on.
|
||||
*/
|
||||
initialize(): VaultStatus {
|
||||
const keyring = this.keyring.status()
|
||||
|
||||
if (!keyring.secure) {
|
||||
this.currentStatus = {
|
||||
state: 'unavailable',
|
||||
reason: 'insecure-keyring',
|
||||
detail: `backend=${keyring.backend ?? 'none'} reason=${keyring.reason ?? 'unknown'}`,
|
||||
}
|
||||
return this.currentStatus
|
||||
}
|
||||
|
||||
const existing = this.db
|
||||
.prepare('SELECT ciphertext, backend FROM master_key_meta WHERE id = 1')
|
||||
.get() as MasterKeyRow | undefined
|
||||
|
||||
return existing ? this.loadMasterKey(existing) : this.createMasterKey(keyring.backend)
|
||||
}
|
||||
|
||||
private createMasterKey(backend: string | null): VaultStatus {
|
||||
const dmk = randomBytes(DMK_BYTES)
|
||||
|
||||
try {
|
||||
const wrapped = this.keyring.wrap(dmk.toString('base64'))
|
||||
this.db
|
||||
.prepare(
|
||||
'INSERT INTO master_key_meta (id, ciphertext, backend, created_at) VALUES (1, ?, ?, ?)',
|
||||
)
|
||||
.run(wrapped, backend ?? 'unknown', Date.now())
|
||||
} catch (cause) {
|
||||
dmk.fill(0)
|
||||
this.currentStatus = {
|
||||
state: 'unavailable',
|
||||
reason: 'seal-failed',
|
||||
detail: cause instanceof Error ? cause.message : String(cause),
|
||||
}
|
||||
return this.currentStatus
|
||||
}
|
||||
|
||||
this.dmk = dmk
|
||||
this.currentStatus = { state: 'unlocked', backend, createdNow: true }
|
||||
return this.currentStatus
|
||||
}
|
||||
|
||||
private loadMasterKey(row: MasterKeyRow): VaultStatus {
|
||||
let dmk: Buffer
|
||||
try {
|
||||
dmk = Buffer.from(this.keyring.unwrap(row.ciphertext), 'base64')
|
||||
} catch (cause) {
|
||||
// Most likely cause: the OS keyring changed underneath us — a different
|
||||
// keyring daemon, a restored backup, a reimaged machine. The row records
|
||||
// which backend sealed it, which makes that diagnosable rather than an
|
||||
// opaque "decrypt failed".
|
||||
this.currentStatus = {
|
||||
state: 'unavailable',
|
||||
reason: 'unseal-failed',
|
||||
detail: `sealed by ${row.backend}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
}
|
||||
return this.currentStatus
|
||||
}
|
||||
|
||||
if (dmk.length !== DMK_BYTES) {
|
||||
dmk.fill(0)
|
||||
this.currentStatus = {
|
||||
state: 'unavailable',
|
||||
reason: 'unseal-failed',
|
||||
detail: `unwrapped key has length ${dmk.length}, expected ${DMK_BYTES}`,
|
||||
}
|
||||
return this.currentStatus
|
||||
}
|
||||
|
||||
this.dmk = dmk
|
||||
this.currentStatus = { state: 'unlocked', backend: row.backend, createdNow: false }
|
||||
return this.currentStatus
|
||||
}
|
||||
|
||||
/** Encrypt a secret. Throws when the vault is locked — never stores plaintext. */
|
||||
sealSecret(plaintext: string, context: string): SealedSecret {
|
||||
return seal(this.requireDmk(), plaintext, context)
|
||||
}
|
||||
|
||||
/** Decrypt a secret. Throws on a locked vault or a failed auth tag. */
|
||||
unsealSecret(sealed: SealedSecret, context: string): string {
|
||||
return unseal(this.requireDmk(), sealed, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the DMK from memory. Called on quit, and available to a future
|
||||
* "lock now" action. Overwriting before release does not guarantee the bytes
|
||||
* leave the heap — V8 may have copied them — but it shortens the window and
|
||||
* costs nothing.
|
||||
*/
|
||||
lock(): void {
|
||||
this.dmk?.fill(0)
|
||||
this.dmk = null
|
||||
this.currentStatus = {
|
||||
state: 'unavailable',
|
||||
reason: 'insecure-keyring',
|
||||
detail: 'locked',
|
||||
}
|
||||
}
|
||||
|
||||
private requireDmk(): Buffer {
|
||||
if (!this.dmk) {
|
||||
throw new Error(`vault is locked (${this.currentStatus.state})`)
|
||||
}
|
||||
return this.dmk
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { MessagePortMain } from 'electron'
|
||||
import { FLOW_CONTROL, type ToMainFrame, type ToRendererFrame } from '@shared/session-protocol'
|
||||
|
||||
export interface ChannelCallbacks {
|
||||
onInput(data: Buffer): void
|
||||
onResize(cols: number, rows: number): void
|
||||
onReady(): void
|
||||
onClosed(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's terminal pipe, with credit-based flow control.
|
||||
*
|
||||
* The remote side is paused once this many bytes are unacknowledged and resumed
|
||||
* once the renderer has drawn them. Without that, `cat` of a large file queues
|
||||
* the entire output in the renderer process and the UI dies — a certainty, not
|
||||
* a risk, because the network is faster than the terminal renderer by a wide
|
||||
* margin.
|
||||
*
|
||||
* Bytes are always carried as `Uint8Array`, never as a string. UTF-8
|
||||
* characters are routinely split across chunk boundaries by TCP, and decoding a
|
||||
* chunk in isolation corrupts them — a bug that only appears with CJK or emoji
|
||||
* output and is easy to miss in testing. xterm.js decodes incrementally if
|
||||
* handed bytes.
|
||||
*/
|
||||
export class SessionChannel {
|
||||
private readonly port: MessagePortMain
|
||||
private readonly callbacks: ChannelCallbacks
|
||||
private outstanding = 0
|
||||
private paused = false
|
||||
private ready = false
|
||||
private closeReported = false
|
||||
|
||||
/** Output produced before the renderer attached — the banner, mostly. */
|
||||
private readonly preReady: Buffer[] = []
|
||||
private preReadyBytes = 0
|
||||
|
||||
private pauseSource: (() => void) | null = null
|
||||
private resumeSource: (() => void) | null = null
|
||||
|
||||
constructor(port: MessagePortMain, callbacks: ChannelCallbacks) {
|
||||
this.port = port
|
||||
this.callbacks = callbacks
|
||||
|
||||
port.on('message', (event: { data: ToMainFrame }) => this.receive(event.data))
|
||||
port.on('close', () => this.reportClosed())
|
||||
port.start()
|
||||
}
|
||||
|
||||
/** How to stop and start the remote side producing. */
|
||||
setSourceControl(pause: () => void, resume: () => void): void {
|
||||
this.pauseSource = pause
|
||||
this.resumeSource = resume
|
||||
}
|
||||
|
||||
/** Send terminal output toward the renderer. */
|
||||
push(chunk: Buffer): void {
|
||||
if (chunk.length === 0 || this.closeReported) return
|
||||
|
||||
if (!this.ready) {
|
||||
this.preReady.push(chunk)
|
||||
this.preReadyBytes += chunk.length
|
||||
// A shell that produces unbounded output before the renderer attaches is
|
||||
// pathological, but it must not be allowed to grow without limit.
|
||||
while (this.preReadyBytes > FLOW_CONTROL.highWater && this.preReady.length > 1) {
|
||||
const dropped = this.preReady.shift()
|
||||
this.preReadyBytes -= dropped?.length ?? 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.deliver(chunk)
|
||||
this.outstanding += chunk.length
|
||||
|
||||
if (!this.paused && this.outstanding >= FLOW_CONTROL.highWater) {
|
||||
this.paused = true
|
||||
this.pauseSource?.()
|
||||
}
|
||||
}
|
||||
|
||||
/** Report the remote shell ending. */
|
||||
sendExit(code: number | null, signal: string | null): void {
|
||||
if (this.closeReported) return
|
||||
this.send({ type: 'exit', code, signal })
|
||||
}
|
||||
|
||||
sendError(message: string): void {
|
||||
if (this.closeReported) return
|
||||
this.send({ type: 'error', message })
|
||||
}
|
||||
|
||||
sendReconnecting(attempt: number, delayMs: number): void {
|
||||
this.send({ type: 'reconnecting', attempt, delayMs })
|
||||
}
|
||||
|
||||
sendReconnected(attempt: number): void {
|
||||
this.send({ type: 'reconnected', attempt })
|
||||
}
|
||||
|
||||
sendDisconnected(message: string): void {
|
||||
this.send({ type: 'disconnected', message })
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.closeReported = true
|
||||
try {
|
||||
this.port.close()
|
||||
} catch {
|
||||
// Already closed at the other end; nothing to unwind.
|
||||
}
|
||||
}
|
||||
|
||||
private receive(frame: ToMainFrame): void {
|
||||
switch (frame.type) {
|
||||
case 'input':
|
||||
this.callbacks.onInput(Buffer.from(frame.data))
|
||||
break
|
||||
|
||||
case 'resize':
|
||||
this.callbacks.onResize(frame.cols, frame.rows)
|
||||
break
|
||||
|
||||
case 'ack':
|
||||
this.outstanding = Math.max(0, this.outstanding - frame.bytes)
|
||||
if (this.paused && this.outstanding <= FLOW_CONTROL.lowWater) {
|
||||
this.paused = false
|
||||
this.resumeSource?.()
|
||||
}
|
||||
break
|
||||
|
||||
case 'ready':
|
||||
this.ready = true
|
||||
for (const buffered of this.preReady) this.deliver(buffered)
|
||||
this.outstanding += this.preReadyBytes
|
||||
this.preReady.length = 0
|
||||
this.preReadyBytes = 0
|
||||
this.callbacks.onReady()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy into a freshly allocated buffer before handing it to the port.
|
||||
*
|
||||
* A Buffer is often a view onto a much larger pooled allocation, and the
|
||||
* structured clone would carry the whole pool across. Copying costs one pass
|
||||
* over the chunk and makes the transfer exact.
|
||||
*/
|
||||
private deliver(chunk: Buffer): void {
|
||||
const copy = new Uint8Array(chunk.byteLength)
|
||||
copy.set(chunk)
|
||||
this.send({ type: 'data', data: copy })
|
||||
}
|
||||
|
||||
private send(frame: ToRendererFrame): void {
|
||||
if (this.closeReported) return
|
||||
try {
|
||||
this.port.postMessage(frame)
|
||||
} catch {
|
||||
this.reportClosed()
|
||||
}
|
||||
}
|
||||
|
||||
private reportClosed(): void {
|
||||
if (this.closeReported) return
|
||||
this.closeReported = true
|
||||
this.callbacks.onClosed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { spawn, type IPty } from 'node-pty'
|
||||
import { homedir } from 'node:os'
|
||||
import type { SessionBackend } from './backend'
|
||||
|
||||
/** Fallback when the environment does not say which shell the user prefers. */
|
||||
const FALLBACK_SHELL = '/bin/bash'
|
||||
|
||||
export interface LocalBackendOptions {
|
||||
shell?: string
|
||||
args?: string[]
|
||||
cwd?: string
|
||||
cols?: number
|
||||
rows?: number
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* A shell running on this machine, over a PTY.
|
||||
*
|
||||
* node-pty hands back `string`, not `Buffer`, because it decodes the PTY stream
|
||||
* itself — with a UTF-8 decoder that holds partial multi-byte sequences across
|
||||
* reads rather than mangling them. Re-encoding to UTF-8 here is therefore
|
||||
* lossless, and it keeps the boundary above this class uniform: everything
|
||||
* speaks bytes, exactly as the SSH side does.
|
||||
*/
|
||||
export class LocalBackend implements SessionBackend {
|
||||
private readonly pty: IPty
|
||||
private closed = false
|
||||
|
||||
constructor(options: LocalBackendOptions = {}) {
|
||||
const shell = options.shell ?? process.env.SHELL ?? FALLBACK_SHELL
|
||||
|
||||
this.pty = spawn(shell, options.args ?? ['-l'], {
|
||||
name: 'xterm-256color',
|
||||
cols: options.cols ?? 80,
|
||||
rows: options.rows ?? 24,
|
||||
cwd: options.cwd ?? homedir(),
|
||||
env: options.env ?? (process.env as Record<string, string>),
|
||||
})
|
||||
}
|
||||
|
||||
get pid(): number {
|
||||
return this.pty.pid
|
||||
}
|
||||
|
||||
get process(): string {
|
||||
return this.pty.process
|
||||
}
|
||||
|
||||
write(data: Buffer): void {
|
||||
if (this.closed) return
|
||||
this.pty.write(data.toString('utf8'))
|
||||
}
|
||||
|
||||
/** node-pty also takes cols before rows, so this is a direct pass-through. */
|
||||
resize(cols: number, rows: number): void {
|
||||
if (this.closed) return
|
||||
this.pty.resize(cols, rows)
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (this.closed) return
|
||||
this.pty.pause()
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this.closed) return
|
||||
this.pty.resume()
|
||||
}
|
||||
|
||||
onData(listener: (chunk: Buffer) => void): void {
|
||||
this.pty.onData((chunk) => listener(Buffer.from(chunk, 'utf8')))
|
||||
}
|
||||
|
||||
onExit(listener: (code: number | null, signal: string | null) => void): void {
|
||||
this.pty.onExit(({ exitCode, signal }) => {
|
||||
this.closed = true
|
||||
listener(exitCode ?? null, signal === undefined ? null : String(signal))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately empty: node-pty has no error channel. A failure to start
|
||||
* throws out of `spawn`, and a failure later arrives as an exit — there is
|
||||
* nothing in between to report. The method exists so the interface is uniform
|
||||
* with the SSH backend, where transport errors genuinely do need forwarding.
|
||||
*/
|
||||
onError(_listener: (message: string) => void): void {
|
||||
// Nothing to subscribe to.
|
||||
}
|
||||
|
||||
/**
|
||||
* The process exiting is the whole story for a local shell — there is no
|
||||
* transport underneath it that can fail on its own, so this fires alongside
|
||||
* `onExit` and reconnect never applies.
|
||||
*/
|
||||
onClosed(listener: () => void): void {
|
||||
this.pty.onExit(() => listener())
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
try {
|
||||
this.pty.kill()
|
||||
} catch {
|
||||
// Already gone — killing a reaped process is a no-op at worst.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { MessagePortMain } from 'electron'
|
||||
import type { SessionBackend } from './backend'
|
||||
import { SessionChannel } from './Channel'
|
||||
|
||||
export interface SessionInit {
|
||||
id: string
|
||||
/** Null for a local shell — it belongs to no host. */
|
||||
hostId: number | null
|
||||
title: string
|
||||
/** Pinned sessions are permanent: the renderer offers no way to close them. */
|
||||
pinned: boolean
|
||||
backend: SessionBackend
|
||||
/** Main's end of the session's MessagePort. */
|
||||
port: MessagePortMain
|
||||
}
|
||||
|
||||
/**
|
||||
* A live shell, over whatever backend is beneath it.
|
||||
*
|
||||
* Owns the terminal pipe and the flow-control wiring between it and the remote
|
||||
* end. Everything the renderer sends and receives for this session goes through
|
||||
* `channel`.
|
||||
*
|
||||
* Deliberately backend-agnostic, and deliberately separable from it: an SSH
|
||||
* channel to another machine and a PTY on this one are the same thing from here
|
||||
* up, and when a connection drops this object survives while only the backend
|
||||
* beneath it is replaced. The renderer keeps its scrollback and its tab because
|
||||
* the terminal was never torn down.
|
||||
*
|
||||
* What does *not* survive is the shell itself. A reconnect gets a new process
|
||||
* on the far end, with a fresh working directory — the terminal says so
|
||||
* explicitly rather than letting the user believe they are still where they
|
||||
* were.
|
||||
*/
|
||||
export class Session {
|
||||
readonly id: string
|
||||
readonly hostId: number | null
|
||||
readonly title: string
|
||||
readonly pinned: boolean
|
||||
readonly channel: SessionChannel
|
||||
|
||||
private backend: SessionBackend
|
||||
private disposed = false
|
||||
/** True once the user asked for this to end, which makes a close expected. */
|
||||
private closingRequested = false
|
||||
private disconnectListener: (() => void) | null = null
|
||||
|
||||
constructor(init: SessionInit) {
|
||||
this.id = init.id
|
||||
this.hostId = init.hostId
|
||||
this.title = init.title
|
||||
this.pinned = init.pinned
|
||||
this.backend = init.backend
|
||||
|
||||
this.channel = new SessionChannel(init.port, {
|
||||
onInput: (data) => this.write(data),
|
||||
onResize: (cols, rows) => this.resize(cols, rows),
|
||||
onReady: () => undefined,
|
||||
onClosed: () => this.dispose(),
|
||||
})
|
||||
|
||||
this.attach(init.backend)
|
||||
}
|
||||
|
||||
/** Wire a backend's output and flow control into this session's channel. */
|
||||
private attach(backend: SessionBackend): void {
|
||||
backend.onData((chunk) => this.channel.push(chunk))
|
||||
backend.onExit((code, signal) => this.channel.sendExit(code, signal))
|
||||
|
||||
// Stop and start the remote side as the renderer falls behind or catches up.
|
||||
this.channel.setSourceControl(
|
||||
() => backend.pause(),
|
||||
() => backend.resume(),
|
||||
)
|
||||
|
||||
backend.onClosed(() => {
|
||||
if (this.disposed || this.closingRequested) return
|
||||
this.disconnectListener?.()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap in a fresh transport after a drop.
|
||||
*
|
||||
* The channel, the port, and everything the renderer holds stay exactly as
|
||||
* they are; only the thing on the far end changes.
|
||||
*/
|
||||
replaceBackend(backend: SessionBackend): void {
|
||||
if (this.disposed) {
|
||||
backend.close()
|
||||
return
|
||||
}
|
||||
this.backend = backend
|
||||
this.attach(backend)
|
||||
}
|
||||
|
||||
/** Called when the transport drops and a retry is worth attempting. */
|
||||
onDisconnected(listener: () => void): void {
|
||||
this.disconnectListener = listener
|
||||
}
|
||||
|
||||
write(data: Buffer): void {
|
||||
if (this.disposed) return
|
||||
this.backend.write(data)
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
if (this.disposed) return
|
||||
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 1 || rows < 1) return
|
||||
this.backend.resize(cols, rows)
|
||||
}
|
||||
|
||||
/** Ask the remote to end, then tear down. */
|
||||
close(): void {
|
||||
this.closingRequested = true
|
||||
this.dispose()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.channel.dispose()
|
||||
this.backend.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { MessageChannelMain, type WebContents } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Client, ClientChannel } from 'ssh2'
|
||||
import { RECONNECT, SESSION_PORT_CHANNEL, reconnectDelay } from '@shared/session-protocol'
|
||||
import type { ConnectInput, ConnectResult, LocalSessionResult } from '@shared/ipc-schemas'
|
||||
import type { Db } from '../db/database'
|
||||
import type { Vault } from '../security/vault'
|
||||
import { getHost, type HostRecord } from '../db/repositories/connections'
|
||||
import { getCredential } from '../db/repositories/credentials'
|
||||
import type { SessionBackend } from './backend'
|
||||
import { LocalBackend } from './LocalBackend'
|
||||
import { SshBackend } from './SshBackend'
|
||||
import { dial, type DialFailure } from './dial'
|
||||
import { Session } from './Session'
|
||||
|
||||
/** Opens with a conventional size; the renderer sends the real one on attach. */
|
||||
const DEFAULT_COLS = 80
|
||||
const DEFAULT_ROWS = 24
|
||||
|
||||
interface ManagedSession {
|
||||
session: Session
|
||||
/** Null for a local shell, which has nothing to redial. */
|
||||
host: HostRecord | null
|
||||
/**
|
||||
* Held so a reconnect can redial without prompting again.
|
||||
*
|
||||
* In memory only, never written anywhere, cleared when the session closes.
|
||||
* The alternative — re-reading the vault — does not work for a password the
|
||||
* user chose not to save, and prompting mid-reconnect would turn a blip into
|
||||
* an interrogation.
|
||||
*/
|
||||
password: string | null
|
||||
attempts: number
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private readonly sessions = new Map<string, ManagedSession>()
|
||||
private readonly db: Db
|
||||
private readonly vault: Vault
|
||||
|
||||
constructor(db: Db, vault: Vault) {
|
||||
this.db = db
|
||||
this.vault = vault
|
||||
}
|
||||
|
||||
/**
|
||||
* Dial a host and, on success, hand the renderer a terminal.
|
||||
*
|
||||
* Never returns a credential or accepts one implicitly: a password comes
|
||||
* either from the vault inside this process, or from an explicit prompt.
|
||||
*/
|
||||
async open(input: ConnectInput, webContents: WebContents): Promise<ConnectResult> {
|
||||
const host = getHost(this.db, input.hostId)
|
||||
if (!host) {
|
||||
return { status: 'error', message: `host ${input.hostId} not found` }
|
||||
}
|
||||
|
||||
let password = input.password ?? null
|
||||
if (password === null && host.authType === 'password') {
|
||||
password = this.storedPassword(host.id)
|
||||
// Nothing to try with. The renderer prompts and calls again with one.
|
||||
if (password === null) return { status: 'need-password' }
|
||||
}
|
||||
|
||||
const result = await dial({
|
||||
db: this.db,
|
||||
host,
|
||||
password,
|
||||
passphrase: null,
|
||||
trustFingerprint: input.trustFingerprint ?? null,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return this.describeFailure(host, result.failure)
|
||||
}
|
||||
|
||||
let stream: ClientChannel
|
||||
try {
|
||||
stream = await openShell(result.client)
|
||||
} catch (cause) {
|
||||
result.client.end()
|
||||
return {
|
||||
status: 'error',
|
||||
message: `could not open a shell: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = this.register(
|
||||
new SshBackend(result.client, stream),
|
||||
{ hostId: host.id, title: host.label, pinned: false },
|
||||
webContents,
|
||||
{ host, password },
|
||||
)
|
||||
|
||||
return { status: 'connected', sessionId, fingerprint: result.fingerprint }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a shell on this machine.
|
||||
*
|
||||
* Pinned and owned by the renderer's lifetime rather than a host's — the app
|
||||
* always has a terminal it can fall back to, which is also the only way to
|
||||
* reach a shell when the network is the thing that is broken.
|
||||
*/
|
||||
openLocal(webContents: WebContents): LocalSessionResult {
|
||||
let backend: LocalBackend
|
||||
try {
|
||||
backend = new LocalBackend({ cols: DEFAULT_COLS, rows: DEFAULT_ROWS })
|
||||
} catch (cause) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `could not start a local shell: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = this.register(
|
||||
backend,
|
||||
{ hostId: null, title: backend.process, pinned: true },
|
||||
webContents,
|
||||
{ host: null, password: null },
|
||||
)
|
||||
|
||||
return { ok: true, sessionId }
|
||||
}
|
||||
|
||||
close(sessionId: string): void {
|
||||
const managed = this.sessions.get(sessionId)
|
||||
if (!managed) return
|
||||
this.sessions.delete(sessionId)
|
||||
if (managed.timer) clearTimeout(managed.timer)
|
||||
managed.session.close()
|
||||
}
|
||||
|
||||
disposeAll(): void {
|
||||
for (const managed of this.sessions.values()) {
|
||||
if (managed.timer) clearTimeout(managed.timer)
|
||||
managed.session.dispose()
|
||||
}
|
||||
this.sessions.clear()
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.sessions.size
|
||||
}
|
||||
|
||||
/** Wire a backend to a port and hand that port to the renderer. */
|
||||
private register(
|
||||
backend: SessionBackend,
|
||||
meta: { hostId: number | null; title: string; pinned: boolean },
|
||||
webContents: WebContents,
|
||||
reconnectWith: { host: HostRecord | null; password: string | null },
|
||||
): string {
|
||||
const sessionId = randomUUID()
|
||||
const { port1, port2 } = new MessageChannelMain()
|
||||
|
||||
const session = new Session({
|
||||
id: sessionId,
|
||||
hostId: meta.hostId,
|
||||
title: meta.title,
|
||||
pinned: meta.pinned,
|
||||
backend,
|
||||
port: port1,
|
||||
})
|
||||
|
||||
const managed: ManagedSession = {
|
||||
session,
|
||||
host: reconnectWith.host,
|
||||
password: reconnectWith.password,
|
||||
attempts: 0,
|
||||
timer: null,
|
||||
}
|
||||
|
||||
session.onDisconnected(() => this.scheduleReconnect(sessionId))
|
||||
this.sessions.set(sessionId, managed)
|
||||
|
||||
// The port is transferred, not copied: after this the main process's handle
|
||||
// is `port1` and the renderer's is `port2`, with nothing shared.
|
||||
webContents.postMessage(SESSION_PORT_CHANNEL, { sessionId }, [port2])
|
||||
|
||||
return sessionId
|
||||
}
|
||||
|
||||
private scheduleReconnect(sessionId: string): void {
|
||||
const managed = this.sessions.get(sessionId)
|
||||
// A local shell has nothing to redial, and a session the user closed has
|
||||
// been removed from the map already.
|
||||
if (!managed?.host) return
|
||||
|
||||
managed.attempts += 1
|
||||
|
||||
if (managed.attempts > RECONNECT.maxAttempts) {
|
||||
managed.session.channel.sendDisconnected(
|
||||
`the connection to ${managed.host.hostname}:${managed.host.port} could not be re-established after ${RECONNECT.maxAttempts} attempts`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const delay = reconnectDelay(managed.attempts)
|
||||
managed.session.channel.sendReconnecting(managed.attempts, delay)
|
||||
managed.timer = setTimeout(() => void this.attemptReconnect(sessionId), delay)
|
||||
}
|
||||
|
||||
private async attemptReconnect(sessionId: string): Promise<void> {
|
||||
const managed = this.sessions.get(sessionId)
|
||||
if (!managed?.host) return
|
||||
|
||||
const { host, password } = managed
|
||||
managed.timer = null
|
||||
|
||||
const result = await dial({
|
||||
db: this.db,
|
||||
host,
|
||||
password,
|
||||
passphrase: null,
|
||||
trustFingerprint: null,
|
||||
})
|
||||
|
||||
// The user may have closed the tab while this was in flight.
|
||||
if (this.sessions.get(sessionId) !== managed) {
|
||||
if (result.ok) result.client.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
// Retrying makes sense for a transport that is not there yet. It makes
|
||||
// none at all for a host key that changed or credentials the server
|
||||
// rejected — those will fail identically every time, and hammering the
|
||||
// server would be the only result.
|
||||
if (isRetryable(result.failure)) {
|
||||
this.scheduleReconnect(sessionId)
|
||||
} else {
|
||||
managed.session.channel.sendDisconnected(describeFailureText(host, result.failure))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let stream: ClientChannel
|
||||
try {
|
||||
stream = await openShell(result.client)
|
||||
} catch (cause) {
|
||||
result.client.end()
|
||||
managed.session.channel.sendDisconnected(
|
||||
`reconnected, but no shell could be opened: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.sessions.get(sessionId) !== managed) {
|
||||
result.client.end()
|
||||
return
|
||||
}
|
||||
|
||||
const succeededOn = managed.attempts
|
||||
managed.attempts = 0
|
||||
managed.session.replaceBackend(new SshBackend(result.client, stream))
|
||||
managed.session.channel.sendReconnected(succeededOn)
|
||||
}
|
||||
|
||||
private describeFailure(host: HostRecord, failure: DialFailure): ConnectResult {
|
||||
switch (failure.reason) {
|
||||
case 'unknown-host-key':
|
||||
return {
|
||||
status: 'unknown-host-key',
|
||||
fingerprint: failure.fingerprint,
|
||||
keyType: failure.keyType,
|
||||
host: host.hostname,
|
||||
port: host.port,
|
||||
}
|
||||
case 'host-key-mismatch':
|
||||
return {
|
||||
status: 'host-key-mismatch',
|
||||
expected: failure.expected,
|
||||
actual: failure.actual,
|
||||
host: host.hostname,
|
||||
port: host.port,
|
||||
}
|
||||
case 'auth-failed':
|
||||
return { status: 'auth-failed', message: failure.message }
|
||||
case 'error':
|
||||
return { status: 'error', message: failure.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a stored password, if there is a usable one.
|
||||
*
|
||||
* A locked vault, or a record that fails its authentication tag, both mean
|
||||
* "no password available" — the user should be prompted. Surfacing the
|
||||
* difference would turn a recoverable situation into a dead end.
|
||||
*/
|
||||
private storedPassword(hostId: number): string | null {
|
||||
if (!this.vault.isUnlocked) return null
|
||||
try {
|
||||
return getCredential(this.db, this.vault, hostId, 'password')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether waiting and trying again could plausibly produce a different result. */
|
||||
function isRetryable(failure: DialFailure): boolean {
|
||||
return failure.reason === 'error'
|
||||
}
|
||||
|
||||
function describeFailureText(host: HostRecord, failure: DialFailure): string {
|
||||
switch (failure.reason) {
|
||||
case 'unknown-host-key':
|
||||
return `${host.hostname}:${host.port} presented an unrecognised host key while reconnecting`
|
||||
case 'host-key-mismatch':
|
||||
return (
|
||||
`the host key for ${host.hostname}:${host.port} changed while reconnecting — ` +
|
||||
`expected ${failure.expected}, received ${failure.actual}`
|
||||
)
|
||||
case 'auth-failed':
|
||||
return `the server rejected the saved credentials while reconnecting`
|
||||
case 'error':
|
||||
return failure.message
|
||||
}
|
||||
}
|
||||
|
||||
function openShell(client: Client): Promise<ClientChannel> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.shell(
|
||||
{ term: 'xterm-256color', cols: DEFAULT_COLS, rows: DEFAULT_ROWS },
|
||||
(error: Error | undefined, stream: ClientChannel) => {
|
||||
if (error) reject(error)
|
||||
else resolve(stream)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Client, ClientChannel } from 'ssh2'
|
||||
import type { SessionBackend } from './backend'
|
||||
|
||||
/**
|
||||
* An SSH channel, presented as a session backend.
|
||||
*
|
||||
* stderr is merged into the same stream as stdout. A shell interleaves the two
|
||||
* anyway, and keeping them apart would need a second pane nobody asked for —
|
||||
* so both arrive on `onData`, in whatever order the remote produced them.
|
||||
*/
|
||||
export class SshBackend implements SessionBackend {
|
||||
private readonly client: Client
|
||||
private readonly stream: ClientChannel
|
||||
private closed = false
|
||||
|
||||
constructor(client: Client, stream: ClientChannel) {
|
||||
this.client = client
|
||||
this.stream = stream
|
||||
}
|
||||
|
||||
write(data: Buffer): void {
|
||||
if (this.closed) return
|
||||
this.stream.write(data)
|
||||
}
|
||||
|
||||
/** ssh2 takes rows before cols here, unlike every other terminal API. */
|
||||
resize(cols: number, rows: number): void {
|
||||
if (this.closed) return
|
||||
this.stream.setWindow(rows, cols, 0, 0)
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (this.closed) return
|
||||
this.stream.pause()
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this.closed) return
|
||||
this.stream.resume()
|
||||
}
|
||||
|
||||
onData(listener: (chunk: Buffer) => void): void {
|
||||
this.stream.on('data', listener)
|
||||
this.stream.stderr.on('data', listener)
|
||||
}
|
||||
|
||||
onExit(listener: (code: number | null, signal: string | null) => void): void {
|
||||
this.stream.on('exit', (code: number | null, signal: string | null) => {
|
||||
listener(code ?? null, signal ?? null)
|
||||
})
|
||||
}
|
||||
|
||||
onError(listener: (message: string) => void): void {
|
||||
// A transport-level failure ends the session, and the renderer should see
|
||||
// why rather than a terminal that simply stops responding.
|
||||
this.client.on('error', (cause: Error) => listener(cause.message))
|
||||
}
|
||||
|
||||
onClosed(listener: () => void): void {
|
||||
// Either end closing means the transport is gone. `stream` closing is the
|
||||
// channel ending; `client` closing is the connection itself going away,
|
||||
// which is what a network drop looks like and what `stream` may never
|
||||
// report.
|
||||
this.stream.on('close', listener)
|
||||
this.client.on('close', listener)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
// Each teardown is isolated: one failure must not leave the other socket
|
||||
// open.
|
||||
try {
|
||||
this.stream.close()
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
try {
|
||||
this.client.end()
|
||||
} catch {
|
||||
// Already ended.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* What a session needs from whatever is on the other end of it.
|
||||
*
|
||||
* An SSH channel and a local PTY are different enough underneath — one is a
|
||||
* socket to another machine, the other a fork on this one — but identical from
|
||||
* the terminal's point of view. Everything above this interface (flow control,
|
||||
* the port, the tab) is written once against it.
|
||||
*
|
||||
* `pause`/`resume` are part of the contract rather than optional, because the
|
||||
* credit-based flow control that keeps `cat` of a large file from killing the
|
||||
* renderer depends on them. Both ssh2's channel and node-pty provide them,
|
||||
* though with different semantics worth knowing about: ssh2's is a Readable
|
||||
* stream, node-pty's is its own implementation over the PTY file descriptor.
|
||||
*/
|
||||
export interface SessionBackend {
|
||||
write(data: Buffer): void
|
||||
/** Rows before cols, matching ssh2. Implementations translate as needed. */
|
||||
resize(cols: number, rows: number): void
|
||||
|
||||
pause(): void
|
||||
resume(): void
|
||||
|
||||
onData(listener: (chunk: Buffer) => void): void
|
||||
/** The remote shell ended, with whatever status it reported. */
|
||||
onExit(listener: (code: number | null, signal: string | null) => void): void
|
||||
onError(listener: (message: string) => void): void
|
||||
/**
|
||||
* The transport is gone, for any reason.
|
||||
*
|
||||
* Deliberately separate from `onExit`. A shell exiting is a normal end; a
|
||||
* socket dropping is not, and only the second is worth reconnecting. Folding
|
||||
* them together would have the app redial a host every time someone types
|
||||
* `exit`.
|
||||
*/
|
||||
onClosed(listener: () => void): void
|
||||
|
||||
close(): void
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Client, type ConnectConfig } from 'ssh2'
|
||||
import type { Db } from '../db/database'
|
||||
import { findKnownHost, recordKnownHost } from '../db/repositories/knownHosts'
|
||||
import type { HostRecord } from '../db/repositories/connections'
|
||||
import { fingerprintOf, keyTypeOf } from './hostKey'
|
||||
|
||||
export type DialFailure =
|
||||
| { reason: 'unknown-host-key'; fingerprint: string; keyType: string }
|
||||
| { reason: 'host-key-mismatch'; expected: string; actual: string }
|
||||
| { reason: 'auth-failed'; message: string }
|
||||
| { reason: 'error'; message: string }
|
||||
|
||||
export type DialResult = { ok: true; client: Client; fingerprint: string; keyType: string } | {
|
||||
ok: false
|
||||
failure: DialFailure
|
||||
}
|
||||
|
||||
export interface DialOptions {
|
||||
db: Db
|
||||
host: HostRecord
|
||||
password: string | null
|
||||
passphrase: string | null
|
||||
/** Set only on a retry, after the user has seen and accepted a fingerprint. */
|
||||
trustFingerprint: string | null
|
||||
readyTimeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a host, verifying its identity on the way in.
|
||||
*
|
||||
* Verification is deliberately hostile to unknown hosts. An unrecognised key
|
||||
* never connects on the first attempt — it comes back as `unknown-host-key` so
|
||||
* the UI can show the fingerprint, and only an explicit `trustFingerprint` on
|
||||
* the retry gets through. Skipping this is the single easiest way to make an
|
||||
* SSH client that is wide open to a man in the middle while looking completely
|
||||
* normal, so the default has to be refusal.
|
||||
*
|
||||
* The whole thing dials twice in the first-connect case. That is the price of
|
||||
* not round-tripping a prompt through the renderer while a socket hangs in a
|
||||
* half-open state, and connecting is not a hot path.
|
||||
*/
|
||||
export function dial(options: DialOptions): Promise<DialResult> {
|
||||
const { db, host, password, passphrase, trustFingerprint } = options
|
||||
|
||||
return new Promise<DialResult>((resolve) => {
|
||||
const client = new Client()
|
||||
let settled = false
|
||||
|
||||
// Filled in by the verifier, which ssh2 runs before `ready` or `error`, so
|
||||
// both are populated by the time either fires.
|
||||
let seenFingerprint = ''
|
||||
let seenKeyType = ''
|
||||
let hostKeyFailure: DialFailure | null = null
|
||||
|
||||
const finish = (result: DialResult): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (!result.ok) client.end()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
// Belt and braces: ssh2's own readyTimeout normally fires first, but a
|
||||
// dial that hangs forever would leak a session entry with no way to
|
||||
// observe it.
|
||||
const timer = setTimeout(() => {
|
||||
finish({ ok: false, failure: { reason: 'error', message: 'connection timed out' } })
|
||||
}, (options.readyTimeoutMs ?? 20_000) + 2_000)
|
||||
|
||||
const hostVerifier = (key: Buffer, verify: (accepted: boolean) => void): void => {
|
||||
seenFingerprint = fingerprintOf(key)
|
||||
seenKeyType = keyTypeOf(key)
|
||||
|
||||
// Keyed by algorithm: a server legitimately presents one key per
|
||||
// algorithm, and matching on fingerprint alone would let a rotated
|
||||
// ed25519 key silently re-trust a brand new RSA one.
|
||||
const known = findKnownHost(db, host.hostname, host.port, seenKeyType)
|
||||
|
||||
if (known) {
|
||||
if (known.fingerprint === seenFingerprint) {
|
||||
verify(true)
|
||||
return
|
||||
}
|
||||
// The key changed. This is either a rebuilt server or an attack, and
|
||||
// there is no way to tell from here — so it is never auto-accepted.
|
||||
hostKeyFailure = {
|
||||
reason: 'host-key-mismatch',
|
||||
expected: known.fingerprint,
|
||||
actual: seenFingerprint,
|
||||
}
|
||||
verify(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (trustFingerprint === seenFingerprint) {
|
||||
// The user was shown exactly this fingerprint and accepted it. Record
|
||||
// it so the next connection does not ask again.
|
||||
recordKnownHost(db, host.hostname, host.port, seenKeyType, seenFingerprint)
|
||||
verify(true)
|
||||
return
|
||||
}
|
||||
|
||||
hostKeyFailure = trustFingerprint
|
||||
? // The key changed between the prompt and the retry — the prompt was
|
||||
// answered by a different server than the one we just reached.
|
||||
{ reason: 'host-key-mismatch', expected: trustFingerprint, actual: seenFingerprint }
|
||||
: { reason: 'unknown-host-key', fingerprint: seenFingerprint, keyType: seenKeyType }
|
||||
|
||||
verify(false)
|
||||
}
|
||||
|
||||
const config: ConnectConfig = {
|
||||
host: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
hostVerifier,
|
||||
readyTimeout: options.readyTimeoutMs ?? 20_000,
|
||||
// Without keepalives an idle connection is dropped by NAT or a firewall
|
||||
// with no notification, and the user discovers it when they next type.
|
||||
keepaliveInterval: 15_000,
|
||||
keepaliveCountMax: 3,
|
||||
}
|
||||
|
||||
if (host.authType === 'password') {
|
||||
if (password === null) {
|
||||
clearTimeout(timer)
|
||||
settled = true
|
||||
client.end()
|
||||
return resolve({ ok: false, failure: { reason: 'auth-failed', message: 'no password' } })
|
||||
}
|
||||
config.password = password
|
||||
} else if (host.authType === 'key') {
|
||||
if (!host.keyPath) {
|
||||
clearTimeout(timer)
|
||||
settled = true
|
||||
client.end()
|
||||
return resolve({ ok: false, failure: { reason: 'error', message: 'no private key path' } })
|
||||
}
|
||||
try {
|
||||
config.privateKey = readFileSync(expandHome(host.keyPath))
|
||||
} catch (cause) {
|
||||
clearTimeout(timer)
|
||||
settled = true
|
||||
client.end()
|
||||
return resolve({
|
||||
ok: false,
|
||||
failure: {
|
||||
reason: 'error',
|
||||
message: `cannot read private key: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (passphrase) config.passphrase = passphrase
|
||||
} else {
|
||||
const socket = process.env.SSH_AUTH_SOCK
|
||||
if (!socket) {
|
||||
clearTimeout(timer)
|
||||
settled = true
|
||||
client.end()
|
||||
return resolve({
|
||||
ok: false,
|
||||
failure: { reason: 'error', message: 'SSH_AUTH_SOCK is not set' },
|
||||
})
|
||||
}
|
||||
config.agent = socket
|
||||
}
|
||||
|
||||
client.on('ready', () => {
|
||||
finish({ ok: true, client, fingerprint: seenFingerprint, keyType: seenKeyType })
|
||||
})
|
||||
|
||||
client.on('error', (cause: Error & { level?: string }) => {
|
||||
if (hostKeyFailure) {
|
||||
finish({ ok: false, failure: hostKeyFailure })
|
||||
return
|
||||
}
|
||||
|
||||
const message = cause.message || String(cause)
|
||||
// ssh2 reports exhausted auth as "All configured authentication methods
|
||||
// failed"; distinguishing it lets the UI offer a password prompt rather
|
||||
// than a dead end.
|
||||
const authFailed =
|
||||
cause.level === 'client-authentication' || /authentication/i.test(message)
|
||||
|
||||
finish({
|
||||
ok: false,
|
||||
failure: authFailed ? { reason: 'auth-failed', message } : { reason: 'error', message },
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
client.connect(config)
|
||||
} catch (cause) {
|
||||
finish({
|
||||
ok: false,
|
||||
failure: {
|
||||
reason: 'error',
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function expandHome(path: string): string {
|
||||
if (path === '~') return homedir()
|
||||
if (path.startsWith('~/')) return join(homedir(), path.slice(2))
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* OpenSSH-format SHA256 fingerprint: `SHA256:` followed by the base64 digest
|
||||
* with padding stripped.
|
||||
*
|
||||
* Computed here rather than using ssh2's `hostHash` option, because that yields
|
||||
* hex while `ssh-keygen -lf`, `known_hosts` and every piece of documentation a
|
||||
* user might compare against use base64. A fingerprint the user cannot check
|
||||
* against anything is not a fingerprint worth showing.
|
||||
*/
|
||||
export function fingerprintOf(keyBlob: Buffer): string {
|
||||
const digest = createHash('sha256').update(keyBlob).digest('base64')
|
||||
return `SHA256:${digest.replace(/=+$/, '')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The key's algorithm name, read from the SSH wire format.
|
||||
*
|
||||
* A public key blob is a length-prefixed string holding the algorithm name
|
||||
* ("ssh-ed25519", "rsa-sha2-512", …), followed by the key material.
|
||||
*/
|
||||
export function keyTypeOf(keyBlob: Buffer): string {
|
||||
if (keyBlob.length < 4) return 'unknown'
|
||||
|
||||
const nameLength = keyBlob.readUInt32BE(0)
|
||||
if (nameLength <= 0 || nameLength > 64 || 4 + nameLength > keyBlob.length) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
return keyBlob.subarray(4, 4 + nameLength).toString('ascii')
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* The preload script runs in a renderer context, so `window` exists at runtime.
|
||||
*
|
||||
* But `tsconfig.node.json` — which covers main *and* preload — deliberately
|
||||
* omits the DOM lib, so that main-process code cannot accidentally reference a
|
||||
* `document` that is not there. Pulling the whole DOM in to satisfy one call
|
||||
* would give up that guarantee.
|
||||
*
|
||||
* So only the single method the preload actually needs is declared.
|
||||
*/
|
||||
declare const window: {
|
||||
postMessage(message: unknown, targetOrigin: string, transfer?: MessagePort[]): void
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { CH } from '@shared/ipc-contract'
|
||||
import { SESSION_PORT_CHANNEL, type PortHandshake } from '@shared/session-protocol'
|
||||
import type { NexusTermApi } from '@shared/api'
|
||||
|
||||
/**
|
||||
* The entire surface the renderer can reach. This file *is* the security
|
||||
* boundary — everything not named here is unreachable from renderer code.
|
||||
*
|
||||
* The `: NexusTermApi` annotation is load-bearing rather than decorative: the
|
||||
* interface lives in `@shared/api`, and it is what the renderer's
|
||||
* `window.nexusterm` is declared as. Annotating here means the compiler rejects
|
||||
* any drift between the two, in either direction.
|
||||
*
|
||||
* Note what is deliberately missing: there is no generic `invoke(channel, ...)`
|
||||
* escape hatch, and no method that returns a stored credential.
|
||||
*/
|
||||
const api: NexusTermApi = {
|
||||
app: {
|
||||
info: () => ipcRenderer.invoke(CH.APP_INFO),
|
||||
},
|
||||
|
||||
tree: {
|
||||
list: () => ipcRenderer.invoke(CH.TREE_LIST),
|
||||
|
||||
createFolder: (input) => ipcRenderer.invoke(CH.FOLDER_CREATE, input),
|
||||
renameFolder: (input) => ipcRenderer.invoke(CH.FOLDER_RENAME, input),
|
||||
|
||||
createHost: (input) => ipcRenderer.invoke(CH.HOST_CREATE, input),
|
||||
updateHost: (id, input) => ipcRenderer.invoke(CH.HOST_UPDATE, { id, input }),
|
||||
|
||||
moveNode: (input) => ipcRenderer.invoke(CH.NODE_MOVE, input),
|
||||
deleteNode: (id) => ipcRenderer.invoke(CH.NODE_DELETE, { id }),
|
||||
},
|
||||
|
||||
session: {
|
||||
open: (input) => ipcRenderer.invoke(CH.SESSION_OPEN, input),
|
||||
openLocal: () => ipcRenderer.invoke(CH.SESSION_OPEN_LOCAL),
|
||||
close: (sessionId) => ipcRenderer.invoke(CH.SESSION_CLOSE, { sessionId }),
|
||||
},
|
||||
|
||||
credentials: {
|
||||
set: (input) => ipcRenderer.invoke(CH.CREDENTIAL_SET, input),
|
||||
remove: (input) => ipcRenderer.invoke(CH.CREDENTIAL_DELETE, input),
|
||||
has: (hostId, kind) => ipcRenderer.invoke(CH.CREDENTIAL_HAS, { hostId, kind }),
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand session ports to the main world.
|
||||
*
|
||||
* `contextBridge` cannot carry a `MessagePort` — one passed through it arrives
|
||||
* unusable (electron/electron#27024). `window.postMessage` does transfer
|
||||
* properly, so the port is forwarded here and the renderer picks it up with its
|
||||
* own `message` listener.
|
||||
*
|
||||
* The target origin is `*` because this document has an opaque origin (it is
|
||||
* loaded from `file://`), so there is no meaningful origin to narrow to. The
|
||||
* window only ever hosts this app's own code; if the renderer is compromised
|
||||
* the terminals are the least of it.
|
||||
*/
|
||||
ipcRenderer.on(SESSION_PORT_CHANNEL, (event, handshake: PortHandshake) => {
|
||||
window.postMessage({ type: SESSION_PORT_CHANNEL, sessionId: handshake.sessionId }, '*', event.ports)
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('nexusterm', api)
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NexusTerm</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle, Languages } from 'lucide-react'
|
||||
import type { AppInfo } from '@shared/ipc-schemas'
|
||||
import SecurityBanner from './components/SecurityBanner'
|
||||
import ConnectionTree from './components/Sidebar/ConnectionTree'
|
||||
import TabBar from './components/Tabs/TabBar'
|
||||
import TerminalHost from './components/Terminal/TerminalHost'
|
||||
import HostKeyDialog from './components/Session/HostKeyDialog'
|
||||
import PasswordDialog from './components/Session/PasswordDialog'
|
||||
import { useSessionStore } from './stores/sessionStore'
|
||||
import { SUPPORTED_LOCALES, type Locale } from './i18n'
|
||||
|
||||
export default function App() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [info, setInfo] = useState<AppInfo | null>(null)
|
||||
const [ipcError, setIpcError] = useState<string | null>(null)
|
||||
|
||||
const sessionError = useSessionStore((state) => state.error)
|
||||
const clearSessionError = useSessionStore((state) => state.clearError)
|
||||
|
||||
// No session is opened at startup. The fixed local entry in the sidebar is
|
||||
// always there to double-click; opening a shell nobody asked for would just
|
||||
// be a tab to close.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
window.nexusterm.app
|
||||
.info()
|
||||
.then((value) => {
|
||||
if (!cancelled) setInfo(value)
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
if (!cancelled) {
|
||||
setIpcError(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cycleLocale = (): void => {
|
||||
const current = i18n.resolvedLanguage as Locale
|
||||
const next = SUPPORTED_LOCALES[(SUPPORTED_LOCALES.indexOf(current) + 1) % SUPPORTED_LOCALES.length]
|
||||
void i18n.changeLanguage(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="header">
|
||||
<span className="header__mark">❯_</span>
|
||||
<span className="header__name">{t('app.name')}</span>
|
||||
<span className="header__tagline">{t('app.tagline')}</span>
|
||||
<div className="header__spacer" />
|
||||
<SecurityBanner info={info} error={ipcError} />
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
onClick={cycleLocale}
|
||||
aria-label={t('app.switchLanguage')}
|
||||
title={t('app.switchLanguage')}
|
||||
>
|
||||
<Languages size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="body">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar__heading">{t('sidebar.heading')}</div>
|
||||
<ConnectionTree />
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
<div className="tabbar">
|
||||
<TabBar />
|
||||
</div>
|
||||
|
||||
{sessionError ? (
|
||||
<div className="session-error" role="alert">
|
||||
<AlertTriangle size={14} />
|
||||
<pre className="session-error__body">{sessionError}</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="session-error__dismiss"
|
||||
onClick={clearSessionError}
|
||||
aria-label={t('common.dismiss')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="terminal-area">
|
||||
<TerminalHost />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer className="statusbar">
|
||||
<span className="statusbar__item">
|
||||
<span className="statusbar__label">electron</span>
|
||||
<span className="statusbar__value">{info?.electron ?? '—'}</span>
|
||||
</span>
|
||||
<span className="statusbar__item">
|
||||
<span className="statusbar__label">chrome</span>
|
||||
<span className="statusbar__value">{info?.chrome ?? '—'}</span>
|
||||
</span>
|
||||
<span className="statusbar__item">
|
||||
<span className="statusbar__label">node</span>
|
||||
<span className="statusbar__value">{info?.node ?? '—'}</span>
|
||||
</span>
|
||||
<span className="statusbar__item">
|
||||
<span className="statusbar__label">app</span>
|
||||
<span className="statusbar__value">{info?.appVersion ?? '—'}</span>
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
<HostKeyDialog />
|
||||
<PasswordDialog />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle, ShieldCheck } from 'lucide-react'
|
||||
import type { AppInfo } from '@shared/ipc-schemas'
|
||||
|
||||
interface SecurityBannerProps {
|
||||
info: AppInfo | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Surfaces whether credentials can actually be protected on this machine.
|
||||
*
|
||||
* Deliberately prominent and deliberately not dismissible. The failures it
|
||||
* reports are silent by nature: on Linux, a keyring-less machine makes Electron
|
||||
* fall back to a `basic_text` backend that writes secrets under a hardcoded
|
||||
* key. Nothing errors, the database looks encrypted, and the user has no way to
|
||||
* notice. A quiet indicator would be worse than none.
|
||||
*/
|
||||
export default function SecurityBanner({ info, error }: SecurityBannerProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const danger = (message: ReactNode) => (
|
||||
<div className="banner banner--danger">
|
||||
<AlertTriangle size={13} />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (error) return danger(<>IPC error: {error}</>)
|
||||
if (!info) return null
|
||||
|
||||
const { safeStorage, vault } = info
|
||||
|
||||
// The keyring check comes first: if the keyring is untrustworthy, nothing
|
||||
// downstream of it can be trusted either, so a vault-level message would be
|
||||
// describing a symptom rather than the cause.
|
||||
if (!safeStorage.secure) {
|
||||
const reason = safeStorage.reason ?? 'unknown-backend'
|
||||
return danger(
|
||||
<>
|
||||
<strong>{t('security.heading')}</strong>{' '}
|
||||
{t(`security.reason.${reason}` as const)}
|
||||
</>,
|
||||
)
|
||||
}
|
||||
|
||||
// Keyring is fine, but the vault could not use it — a different failure, and
|
||||
// one worth distinguishing, since the keyring banner would be misleading.
|
||||
if (vault.state === 'unavailable') {
|
||||
return danger(
|
||||
<>
|
||||
<strong>{t('security.heading')}</strong>{' '}
|
||||
{t(`security.vault.${vault.reason}` as const, { detail: vault.detail })}
|
||||
</>,
|
||||
)
|
||||
}
|
||||
|
||||
const backend = vault.backend ?? safeStorage.backend
|
||||
|
||||
return (
|
||||
<div className="banner banner--ok">
|
||||
<ShieldCheck size={13} />
|
||||
<span>
|
||||
{backend ? t('security.keyringOk', { backend }) : t('security.keyringMac')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ShieldAlert } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import Modal from '../common/Modal'
|
||||
|
||||
/**
|
||||
* Trust-on-first-use prompt.
|
||||
*
|
||||
* Shown only for a host key that has never been seen. A key that *changed*
|
||||
* never reaches this dialog — that case is an error, because a prompt with a
|
||||
* continue button is exactly what an attacker wants the user to see.
|
||||
*
|
||||
* The fingerprint is displayed in full and in the same format `ssh-keygen -lf`
|
||||
* prints, so it can actually be checked against something.
|
||||
*/
|
||||
export default function HostKeyDialog() {
|
||||
const { t } = useTranslation()
|
||||
const prompt = useSessionStore((state) => state.prompt)
|
||||
const answer = useSessionStore((state) => state.answerPrompt)
|
||||
const dismiss = useSessionStore((state) => state.dismissPrompt)
|
||||
|
||||
if (prompt?.kind !== 'host-key') return null
|
||||
const { host, fingerprint, keyType } = prompt
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={t('hostKey.title')}
|
||||
onClose={dismiss}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn" onClick={dismiss}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={() => void answer(fingerprint)}
|
||||
>
|
||||
{t('hostKey.accept')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="hostkey__banner">
|
||||
<ShieldAlert size={15} />
|
||||
<span>{t('hostKey.unknown', { host: host.hostname, port: host.port })}</span>
|
||||
</div>
|
||||
|
||||
<dl className="hostkey__facts">
|
||||
<dt>{t('hostKey.keyType')}</dt>
|
||||
<dd className="mono">{keyType}</dd>
|
||||
<dt>{t('hostKey.fingerprint')}</dt>
|
||||
<dd className="mono hostkey__fingerprint">{fingerprint}</dd>
|
||||
</dl>
|
||||
|
||||
<p className="field__hint">{t('hostKey.explain')}</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import Modal from '../common/Modal'
|
||||
|
||||
function describe(cause: unknown): string {
|
||||
const raw = cause instanceof Error ? cause.message : String(cause)
|
||||
const marker = raw.lastIndexOf('Error: ')
|
||||
return marker >= 0 ? raw.slice(marker + 'Error: '.length) : raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Password prompt, with the option to save it.
|
||||
*
|
||||
* The password is handed to the main process and never comes back: it goes
|
||||
* through a write-only channel, and the connect call takes it as an argument
|
||||
* rather than reading it out of storage. There is no path that returns it to
|
||||
* this process.
|
||||
*
|
||||
* Saving is attempted *before* connecting, and a failure stops the flow rather
|
||||
* than being logged and forgotten. If the vault is locked or the OS keyring is
|
||||
* untrustworthy, the honest outcome is to say so and let the user decide —
|
||||
* silently not saving would leave them believing a password is stored.
|
||||
*/
|
||||
export default function PasswordDialog() {
|
||||
const { t } = useTranslation()
|
||||
const prompt = useSessionStore((state) => state.prompt)
|
||||
const answer = useSessionStore((state) => state.answerPrompt)
|
||||
const dismiss = useSessionStore((state) => state.dismissPrompt)
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [remember, setRemember] = useState(true)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const active = prompt?.kind === 'password'
|
||||
const hostId = active ? prompt.host.id : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
setPassword('')
|
||||
setRemember(true)
|
||||
setSaveError(null)
|
||||
setBusy(false)
|
||||
}, [active, hostId])
|
||||
|
||||
if (!active) return null
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (!password || busy) return
|
||||
setBusy(true)
|
||||
|
||||
if (remember) {
|
||||
try {
|
||||
await window.nexusterm.credentials.set({
|
||||
hostId: prompt.host.id,
|
||||
kind: 'password',
|
||||
secret: password,
|
||||
})
|
||||
} catch (cause) {
|
||||
setSaveError(describe(cause))
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await answer(password)
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={t('password.title', { host: prompt.host.label })}
|
||||
onClose={dismiss}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn" onClick={dismiss}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" className="btn btn--primary" onClick={() => void submit()}>
|
||||
{t('password.connect')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{prompt.failure ? (
|
||||
<div className="hostkey__banner">
|
||||
<AlertTriangle size={15} />
|
||||
<span>{t('password.retry', { message: prompt.failure })}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="session-password">
|
||||
{t('password.label', { username: prompt.host.username })}
|
||||
</label>
|
||||
<input
|
||||
id="session-password"
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void submit()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
/>
|
||||
<span>{t('password.remember')}</span>
|
||||
</label>
|
||||
|
||||
{saveError ? (
|
||||
<p className="field__error">{t('password.saveFailed', { message: saveError })}</p>
|
||||
) : null}
|
||||
|
||||
<p className="field__hint">{t('password.hint')}</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
FolderClosed,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Server,
|
||||
ServerPlus,
|
||||
SquareTerminal,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import type { FolderNode, HostInput, HostNode } from '@shared/ipc-schemas'
|
||||
import { useConnectionStore } from '../../stores/connectionStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import {
|
||||
buildSidebarRows,
|
||||
computeMove,
|
||||
dropPositionFor,
|
||||
hostKey,
|
||||
LOCAL_KEY,
|
||||
sessionLabel,
|
||||
type DropPosition,
|
||||
type SidebarRow,
|
||||
} from '../../lib/sidebar'
|
||||
import HostDialog from './HostDialog'
|
||||
import Modal from '../common/Modal'
|
||||
import ContextMenu, { type MenuItem } from '../common/ContextMenu'
|
||||
|
||||
type DialogState =
|
||||
| { kind: 'new-folder'; parentId: number | null }
|
||||
| { kind: 'rename-folder'; id: number; name: string }
|
||||
| { kind: 'new-host'; parentId: number | null }
|
||||
| { kind: 'edit-host'; host: HostNode }
|
||||
/** Carries the node itself, so the dialog can name it and the caller can act on it. */
|
||||
| { kind: 'confirm-delete'; node: FolderNode | HostNode }
|
||||
| { kind: 'rename-session'; id: string; name: string }
|
||||
| null
|
||||
|
||||
/** The row a context menu was opened on. */
|
||||
interface MenuState {
|
||||
x: number
|
||||
y: number
|
||||
row: SidebarRow | null
|
||||
}
|
||||
|
||||
export default function ConnectionTree() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
tree,
|
||||
expanded,
|
||||
selected,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
toggleExpanded,
|
||||
expand,
|
||||
select,
|
||||
clearError,
|
||||
createFolder,
|
||||
renameFolder,
|
||||
createHost,
|
||||
updateHost,
|
||||
deleteNode,
|
||||
move,
|
||||
} = useConnectionStore()
|
||||
|
||||
const tabs = useSessionStore((state) => state.tabs)
|
||||
const activeId = useSessionStore((state) => state.activeId)
|
||||
const openSession = useSessionStore((state) => state.openSession)
|
||||
const closeTab = useSessionStore((state) => state.closeTab)
|
||||
const togglePinned = useSessionStore((state) => state.togglePinned)
|
||||
const renameTab = useSessionStore((state) => state.renameTab)
|
||||
const setActive = useSessionStore((state) => state.setActive)
|
||||
|
||||
const [dialog, setDialog] = useState<DialogState>(null)
|
||||
const [menu, setMenu] = useState<MenuState | null>(null)
|
||||
const [dragging, setDragging] = useState<number | null>(null)
|
||||
const [dropTarget, setDropTarget] = useState<{ key: string; position: DropPosition } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
/**
|
||||
* Reveal each new session in the sidebar.
|
||||
*
|
||||
* Without this, opening a shell on a collapsed host produces a tab and
|
||||
* nothing else — the session is there but invisible, and the sidebar looks
|
||||
* like it ignored the action. Tracked by session id rather than by host, so
|
||||
* collapsing a group and opening another session for the same host opens it
|
||||
* again.
|
||||
*/
|
||||
const revealed = useRef(new Set<string>())
|
||||
useEffect(() => {
|
||||
for (const tab of tabs) {
|
||||
if (revealed.current.has(tab.id)) continue
|
||||
revealed.current.add(tab.id)
|
||||
expand(tab.hostId === null ? LOCAL_KEY : hostKey(tab.hostId))
|
||||
}
|
||||
}, [tabs, expand])
|
||||
|
||||
const rows = useMemo(
|
||||
() => buildSidebarRows(tree, tabs, expanded),
|
||||
[tree, tabs, expanded],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return
|
||||
const dismiss = (): void => setMenu(null)
|
||||
window.addEventListener('resize', dismiss)
|
||||
window.addEventListener('blur', dismiss)
|
||||
return () => {
|
||||
window.removeEventListener('resize', dismiss)
|
||||
window.removeEventListener('blur', dismiss)
|
||||
}
|
||||
}, [menu])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(target: { row: SidebarRow; position: DropPosition }) => {
|
||||
const draggedId = dragging
|
||||
setDragging(null)
|
||||
setDropTarget(null)
|
||||
if (draggedId === null || !target.row.node) return
|
||||
|
||||
const draggedRow = rows.find((r) => r.node?.id === draggedId)
|
||||
if (!draggedRow?.node) return
|
||||
|
||||
const input = computeMove(draggedRow.node, target)
|
||||
// Null means the drop is not expressible — onto itself, or into its own
|
||||
// subtree. The indicator already refused to appear.
|
||||
if (!input) return
|
||||
|
||||
void move(input)
|
||||
},
|
||||
[dragging, rows, move],
|
||||
)
|
||||
|
||||
/** Move a node back out to the root layer. */
|
||||
const handleDropToRoot = useCallback(() => {
|
||||
const draggedId = dragging
|
||||
setDragging(null)
|
||||
setDropTarget(null)
|
||||
if (draggedId === null) return
|
||||
|
||||
const draggedRow = rows.find((r) => r.node?.id === draggedId)
|
||||
if (!draggedRow?.node || draggedRow.parentId == null) return
|
||||
|
||||
const rootSiblings = rows.filter((r) => r.parentId === null && r.node)
|
||||
const last = rootSiblings[rootSiblings.length - 1]
|
||||
|
||||
void move({
|
||||
id: draggedId,
|
||||
parentId: null,
|
||||
prevSiblingId: last?.node?.id ?? null,
|
||||
nextSiblingId: null,
|
||||
})
|
||||
}, [dragging, rows, move])
|
||||
|
||||
const submitHost = (input: HostInput): void => {
|
||||
if (dialog?.kind === 'edit-host') void updateHost(dialog.host.id, input)
|
||||
else void createHost(input)
|
||||
setDialog(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* The menu for a row. Deliberately the same five actions the tab bar offers
|
||||
* for a session, so the two views are interchangeable rather than each having
|
||||
* its own half of the vocabulary.
|
||||
*/
|
||||
const buildMenu = (state: MenuState): MenuItem[] => {
|
||||
const session = state.row?.session ?? null
|
||||
const node = state.row?.node ?? null
|
||||
|
||||
if (session) {
|
||||
return [
|
||||
{
|
||||
key: 'rename',
|
||||
label: t('tab.rename'),
|
||||
icon: <Pencil size={13} />,
|
||||
run: () =>
|
||||
setDialog({
|
||||
kind: 'rename-session',
|
||||
id: session.id,
|
||||
name: session.customLabel ?? '',
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'pin',
|
||||
label: session.pinned ? t('session.unpin') : t('session.pin'),
|
||||
icon: session.pinned ? <PinOff size={13} /> : <Pin size={13} />,
|
||||
run: () => togglePinned(session.id),
|
||||
},
|
||||
{
|
||||
key: 'duplicate',
|
||||
label: t('tab.duplicate'),
|
||||
icon: <Copy size={13} />,
|
||||
run: () => void openSession(session.host, { after: session.id }),
|
||||
},
|
||||
{
|
||||
key: 'new',
|
||||
label: t('tab.openNew'),
|
||||
icon: <Plus size={13} />,
|
||||
run: () => void openSession(session.host),
|
||||
},
|
||||
{
|
||||
key: 'close',
|
||||
label: t('tab.close'),
|
||||
icon: <X size={13} />,
|
||||
danger: true,
|
||||
run: () => closeTab(session.id),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const items: MenuItem[] = []
|
||||
|
||||
if (state.row?.kind === 'local') {
|
||||
items.push({
|
||||
key: 'new-local',
|
||||
label: t('sidebar.newLocal'),
|
||||
icon: <SquareTerminal size={13} />,
|
||||
run: () => void openSession(null),
|
||||
})
|
||||
}
|
||||
|
||||
if (node?.kind === 'host') {
|
||||
items.push({
|
||||
key: 'new-session',
|
||||
label: t('tab.openNew'),
|
||||
icon: <Plus size={13} />,
|
||||
run: () => void openSession(node),
|
||||
})
|
||||
}
|
||||
|
||||
if (state.row === null || node?.kind === 'folder') {
|
||||
const parentId = node?.kind === 'folder' ? node.id : null
|
||||
items.push({
|
||||
key: 'new-host',
|
||||
label: t('sidebar.newHost'),
|
||||
icon: <ServerPlus size={13} />,
|
||||
run: () => setDialog({ kind: 'new-host', parentId }),
|
||||
})
|
||||
items.push({
|
||||
key: 'new-folder',
|
||||
label: t('sidebar.newFolder'),
|
||||
icon: <FolderPlus size={13} />,
|
||||
run: () => setDialog({ kind: 'new-folder', parentId }),
|
||||
})
|
||||
}
|
||||
|
||||
if (node?.kind === 'folder') {
|
||||
items.push({
|
||||
key: 'rename',
|
||||
label: t('common.rename'),
|
||||
icon: <Pencil size={13} />,
|
||||
run: () => setDialog({ kind: 'rename-folder', id: node.id, name: node.name }),
|
||||
})
|
||||
}
|
||||
|
||||
if (node?.kind === 'host') {
|
||||
items.push({
|
||||
key: 'edit',
|
||||
label: t('common.edit'),
|
||||
icon: <Pencil size={13} />,
|
||||
run: () => setDialog({ kind: 'edit-host', host: node }),
|
||||
})
|
||||
}
|
||||
|
||||
if (node) {
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: t('common.delete'),
|
||||
icon: <Trash2 size={13} />,
|
||||
danger: true,
|
||||
run: () => setDialog({ kind: 'confirm-delete', node }),
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tree">
|
||||
<div className="tree__toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
title={t('sidebar.newHost')}
|
||||
aria-label={t('sidebar.newHost')}
|
||||
onClick={() => setDialog({ kind: 'new-host', parentId: null })}
|
||||
>
|
||||
<ServerPlus size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
title={t('sidebar.newFolder')}
|
||||
aria-label={t('sidebar.newFolder')}
|
||||
onClick={() => setDialog({ kind: 'new-folder', parentId: null })}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
<span className="tree__toolbar-spacer" />
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="tree__error" role="alert">
|
||||
<span>{error}</span>
|
||||
<button type="button" className="tree__error-dismiss" onClick={clearError}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="tree__scroll"
|
||||
role="tree"
|
||||
aria-label={t('sidebar.heading')}
|
||||
onDragOver={(e) => {
|
||||
if (dragging !== null) e.preventDefault()
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
handleDropToRoot()
|
||||
}}
|
||||
onClick={() => {
|
||||
select(null)
|
||||
setMenu(null)
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="empty">
|
||||
<span className="empty__detail">{t('common.loading')}</span>
|
||||
</div>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<SidebarRowView
|
||||
key={row.key}
|
||||
row={row}
|
||||
selected={selected === row.key}
|
||||
active={row.session ? row.session.id === activeId : false}
|
||||
dragging={dragging !== null && row.node?.id === dragging}
|
||||
dropPosition={dropTarget?.key === row.key ? dropTarget.position : null}
|
||||
onToggle={() => toggleExpanded(row.key)}
|
||||
onSelect={() => {
|
||||
select(row.key)
|
||||
if (row.kind === 'session' && row.session) setActive(row.session.id)
|
||||
}}
|
||||
onActivate={() => {
|
||||
if (row.kind === 'local') void openSession(null)
|
||||
else if (row.kind === 'host' && row.node?.kind === 'host') {
|
||||
void openSession(row.node)
|
||||
}
|
||||
}}
|
||||
onOpenMenu={(x, y) => setMenu({ x, y, row })}
|
||||
onDragStart={() => row.node && setDragging(row.node.id)}
|
||||
onDragEnd={() => {
|
||||
setDragging(null)
|
||||
setDropTarget(null)
|
||||
}}
|
||||
onDragOver={(pointerY, rect) => {
|
||||
if (dragging === null || !row.node) return
|
||||
const draggedRow = rows.find((r) => r.node?.id === dragging)
|
||||
if (!draggedRow?.node) return
|
||||
|
||||
const position = dropPositionFor(rect, pointerY, row.node)
|
||||
const accepted =
|
||||
computeMove(draggedRow.node, { row, position }) !== null
|
||||
setDropTarget(accepted ? { key: row.key, position } : null)
|
||||
}}
|
||||
onDrop={(pointerY, rect) => {
|
||||
if (!row.node) return
|
||||
handleDrop({ row, position: dropPositionFor(rect, pointerY, row.node) })
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{!loading && tree.length === 0 ? (
|
||||
<div className="empty">
|
||||
<span className="empty__detail">{t('sidebar.empty')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{menu ? (
|
||||
<ContextMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
items={buildMenu(menu)}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<HostDialog
|
||||
open={dialog?.kind === 'new-host' || dialog?.kind === 'edit-host'}
|
||||
parentId={dialog?.kind === 'new-host' ? dialog.parentId : null}
|
||||
host={dialog?.kind === 'edit-host' ? dialog.host : null}
|
||||
onCancel={() => setDialog(null)}
|
||||
onSubmit={submitHost}
|
||||
/>
|
||||
|
||||
<NameDialog
|
||||
open={
|
||||
dialog?.kind === 'new-folder' ||
|
||||
dialog?.kind === 'rename-folder' ||
|
||||
dialog?.kind === 'rename-session'
|
||||
}
|
||||
title={
|
||||
dialog?.kind === 'rename-session'
|
||||
? t('tab.rename')
|
||||
: dialog?.kind === 'rename-folder'
|
||||
? t('folder.rename')
|
||||
: t('folder.create')
|
||||
}
|
||||
initialValue={
|
||||
dialog?.kind === 'rename-folder' || dialog?.kind === 'rename-session'
|
||||
? dialog.name
|
||||
: ''
|
||||
}
|
||||
placeholder={
|
||||
dialog?.kind === 'rename-session'
|
||||
? t('tab.namePlaceholder')
|
||||
: t('folder.namePlaceholder')
|
||||
}
|
||||
hint={dialog?.kind === 'rename-session' ? t('tab.renameHint') : undefined}
|
||||
onCancel={() => setDialog(null)}
|
||||
onSubmit={(name) => {
|
||||
if (dialog?.kind === 'rename-folder') void renameFolder(dialog.id, name)
|
||||
else if (dialog?.kind === 'rename-session') renameTab(dialog.id, name)
|
||||
else if (dialog?.kind === 'new-folder') void createFolder(dialog.parentId, name)
|
||||
setDialog(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={dialog?.kind === 'confirm-delete'}
|
||||
node={dialog?.kind === 'confirm-delete' ? dialog.node : null}
|
||||
onCancel={() => setDialog(null)}
|
||||
onConfirm={() => {
|
||||
if (dialog?.kind === 'confirm-delete') void deleteNode(dialog.node.id)
|
||||
setDialog(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- row
|
||||
|
||||
interface SidebarRowViewProps {
|
||||
row: SidebarRow
|
||||
selected: boolean
|
||||
active: boolean
|
||||
dragging: boolean
|
||||
dropPosition: DropPosition | null
|
||||
onToggle: () => void
|
||||
onSelect: () => void
|
||||
/** Double-click: open a new session for this host, or the local entry. */
|
||||
onActivate: () => void
|
||||
onOpenMenu: (x: number, y: number) => void
|
||||
onDragStart: () => void
|
||||
onDragEnd: () => void
|
||||
onDragOver: (pointerY: number, rect: { top: number; height: number }) => void
|
||||
onDrop: (pointerY: number, rect: { top: number; height: number }) => void
|
||||
}
|
||||
|
||||
function SidebarRowView({
|
||||
row,
|
||||
selected,
|
||||
active,
|
||||
dragging,
|
||||
dropPosition,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onActivate,
|
||||
onOpenMenu,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
}: SidebarRowViewProps) {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const rect = (): { top: number; height: number } => {
|
||||
const box = ref.current?.getBoundingClientRect()
|
||||
return { top: box?.top ?? 0, height: box?.height || 1 }
|
||||
}
|
||||
|
||||
const classes = ['row', `row--${row.kind}`]
|
||||
if (selected) classes.push('row--selected')
|
||||
if (active) classes.push('row--active')
|
||||
if (dragging) classes.push('row--dragging')
|
||||
if (dropPosition === 'before') classes.push('row--drop-before')
|
||||
if (dropPosition === 'after') classes.push('row--drop-after')
|
||||
if (dropPosition === 'inside') classes.push('row--drop-inside')
|
||||
|
||||
const { icon, label, meta, title } = describeRow(row, t)
|
||||
|
||||
const isSession = row.kind === 'session'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes.join(' ')}
|
||||
style={{ paddingLeft: 6 + row.depth * 14 }}
|
||||
title={title}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
aria-expanded={row.expandable ? row.expanded : undefined}
|
||||
tabIndex={-1}
|
||||
draggable={!isSession}
|
||||
onDragStart={(e) => {
|
||||
if (isSession) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
e.dataTransfer.setData('text/plain', String(row.node?.id ?? ''))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
onDragStart()
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => {
|
||||
if (isSession) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDragOver(e.clientY, rect())
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (isSession) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDrop(e.clientY, rect())
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect()
|
||||
// Clicking a container opens or closes it; clicking a session switches
|
||||
// to it. Nodes without children have nothing to toggle.
|
||||
if (row.kind !== 'session' && row.expandable) onToggle()
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (row.kind === 'host' || row.kind === 'local') onActivate()
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onSelect()
|
||||
onOpenMenu(e.clientX, e.clientY)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onSelect()
|
||||
if (row.kind === 'host' || row.kind === 'local') onActivate()
|
||||
else if (row.expandable) onToggle()
|
||||
} else if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelect()
|
||||
if (row.expandable) onToggle()
|
||||
} else if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {
|
||||
e.preventDefault()
|
||||
const box = ref.current?.getBoundingClientRect()
|
||||
onOpenMenu(box?.left ?? 0, box?.bottom ?? 0)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="row__chevron" aria-hidden="true">
|
||||
{row.expandable ? (
|
||||
<ChevronRight size={12} className={row.expanded ? 'row__chevron--open' : ''} />
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
<span className="row__icon" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
|
||||
<span className="row__label">{label}</span>
|
||||
|
||||
{row.kind === 'session' && row.session?.pinned ? (
|
||||
<span className="row__badge" title={t('session.pinned')}>
|
||||
<Pin size={10} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{!isSession && row.sessionCount > 0 ? (
|
||||
<span className="row__count">{row.sessionCount}</span>
|
||||
) : null}
|
||||
|
||||
{meta ? <span className="row__meta">{meta}</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Icon, label and secondary text for a row — the only place kind is branched on. */
|
||||
function describeRow(
|
||||
row: SidebarRow,
|
||||
t: (key: string) => string,
|
||||
): { icon: ReactNode; label: string; meta: string | null; title: string } {
|
||||
switch (row.kind) {
|
||||
case 'local':
|
||||
return {
|
||||
icon: <SquareTerminal size={13} />,
|
||||
label: t('sidebar.local'),
|
||||
meta: null,
|
||||
title: t('sidebar.localHint'),
|
||||
}
|
||||
|
||||
case 'folder':
|
||||
return {
|
||||
icon: row.expanded ? <FolderOpen size={13} /> : <FolderClosed size={13} />,
|
||||
label: row.node?.kind === 'folder' ? row.node.name : '',
|
||||
meta: null,
|
||||
title: row.node?.kind === 'folder' ? row.node.name : '',
|
||||
}
|
||||
|
||||
case 'host': {
|
||||
const host = row.node?.kind === 'host' ? row.node : null
|
||||
const connection = host
|
||||
? `${host.username}@${host.hostname}${host.port !== 22 ? `:${host.port}` : ''}`
|
||||
: ''
|
||||
return {
|
||||
icon: <Server size={13} />,
|
||||
label: host?.label ?? '',
|
||||
meta: connection,
|
||||
title: host ? `${host.label}\n${connection}` : '',
|
||||
}
|
||||
}
|
||||
|
||||
case 'session':
|
||||
return {
|
||||
icon: <span className={`row__dot row__dot--${row.session?.status ?? 'connected'}`} />,
|
||||
label: row.session ? sessionLabel(row.session, t('sidebar.local')) : '',
|
||||
meta: null,
|
||||
title: row.session?.status === 'closed' ? t('session.exited') : t('session.open'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- menus and dialogs
|
||||
|
||||
interface NameDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
initialValue: string
|
||||
placeholder: string
|
||||
/** Optional explanatory line, used by the session rename. */
|
||||
hint?: string
|
||||
onCancel: () => void
|
||||
onSubmit: (name: string) => void
|
||||
}
|
||||
|
||||
function NameDialog({
|
||||
open,
|
||||
title,
|
||||
initialValue,
|
||||
placeholder,
|
||||
hint,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: NameDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setValue(initialValue)
|
||||
}, [open, initialValue])
|
||||
|
||||
const submit = (): void => {
|
||||
if (value.trim()) onSubmit(value.trim())
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" className="btn btn--primary" onClick={submit}>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="field">
|
||||
<input
|
||||
className="input"
|
||||
value={value}
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{hint ? <p className="field__hint">{hint}</p> : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
node: FolderNode | HostNode | null
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleting a folder cascades to everything beneath it, so it gets a
|
||||
* confirmation even though deleting a single host would not need one.
|
||||
*/
|
||||
function ConfirmDialog({ open, node, onCancel, onConfirm }: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const name = node === null ? '' : node.kind === 'folder' ? node.name : node.label
|
||||
const body =
|
||||
node?.kind === 'folder'
|
||||
? t('confirm.deleteFolder', { name })
|
||||
: t('confirm.deleteHost', { name })
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('confirm.title')}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" className="btn btn--danger" onClick={onConfirm}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="confirm__body">{body}</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AuthType, HostInput, HostNode } from '@shared/ipc-schemas'
|
||||
import Modal from '../common/Modal'
|
||||
|
||||
interface HostDialogProps {
|
||||
open: boolean
|
||||
/** Destination folder for a new host; ignored when editing. */
|
||||
parentId: number | null
|
||||
/** The host being edited, or null to create one. */
|
||||
host: HostNode | null
|
||||
onCancel: () => void
|
||||
onSubmit: (input: HostInput) => void
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
label: string
|
||||
hostname: string
|
||||
port: string
|
||||
username: string
|
||||
authType: AuthType
|
||||
keyPath: string
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
label: '',
|
||||
hostname: '',
|
||||
port: '22',
|
||||
username: '',
|
||||
authType: 'password',
|
||||
keyPath: '',
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or edit a host.
|
||||
*
|
||||
* Deliberately no password field. Credentials are written through a separate
|
||||
* write-only path that never reads a secret back to the renderer, and they
|
||||
* belong with connection support — where a saved password can actually be
|
||||
* exercised rather than stored blind.
|
||||
*/
|
||||
export default function HostDialog({
|
||||
open,
|
||||
parentId,
|
||||
host,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: HostDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState<FormState>(EMPTY)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Reset whenever the dialog opens, so a cancelled edit does not bleed into
|
||||
// the next one.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setError(null)
|
||||
setForm(
|
||||
host
|
||||
? {
|
||||
label: host.label,
|
||||
hostname: host.hostname,
|
||||
port: String(host.port),
|
||||
username: host.username,
|
||||
authType: host.authType,
|
||||
keyPath: '',
|
||||
}
|
||||
: EMPTY,
|
||||
)
|
||||
}, [open, host])
|
||||
|
||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]): void => {
|
||||
setForm((previous) => ({ ...previous, [key]: value }))
|
||||
}
|
||||
|
||||
const submit = (): void => {
|
||||
const port = Number.parseInt(form.port, 10)
|
||||
|
||||
if (!form.label.trim()) return setError(t('host.error.label'))
|
||||
if (!form.hostname.trim()) return setError(t('host.error.hostname'))
|
||||
if (!form.username.trim()) return setError(t('host.error.username'))
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return setError(t('host.error.port'))
|
||||
}
|
||||
|
||||
setError(null)
|
||||
onSubmit({
|
||||
parentId: host ? host.parentId : parentId,
|
||||
label: form.label.trim(),
|
||||
hostname: form.hostname.trim(),
|
||||
port,
|
||||
username: form.username.trim(),
|
||||
authType: form.authType,
|
||||
keyPath: form.authType === 'key' && form.keyPath.trim() ? form.keyPath.trim() : null,
|
||||
color: host?.color ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={host ? t('host.edit') : t('host.create')}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" className="btn btn--primary" onClick={submit}>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="host-label">
|
||||
{t('host.label')}
|
||||
</label>
|
||||
<input
|
||||
id="host-label"
|
||||
className="input"
|
||||
value={form.label}
|
||||
autoFocus
|
||||
placeholder={t('host.labelPlaceholder')}
|
||||
onChange={(e) => update('label', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field-row">
|
||||
<div className="field field--grow">
|
||||
<label className="field__label" htmlFor="host-hostname">
|
||||
{t('host.hostname')}
|
||||
</label>
|
||||
<input
|
||||
id="host-hostname"
|
||||
className="input"
|
||||
value={form.hostname}
|
||||
placeholder="10.0.1.5"
|
||||
onChange={(e) => update('hostname', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field field--port">
|
||||
<label className="field__label" htmlFor="host-port">
|
||||
{t('host.port')}
|
||||
</label>
|
||||
<input
|
||||
id="host-port"
|
||||
className="input"
|
||||
value={form.port}
|
||||
inputMode="numeric"
|
||||
onChange={(e) => update('port', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="host-username">
|
||||
{t('host.username')}
|
||||
</label>
|
||||
<input
|
||||
id="host-username"
|
||||
className="input"
|
||||
value={form.username}
|
||||
placeholder="root"
|
||||
onChange={(e) => update('username', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="host-auth">
|
||||
{t('host.authType')}
|
||||
</label>
|
||||
<select
|
||||
id="host-auth"
|
||||
className="input"
|
||||
value={form.authType}
|
||||
onChange={(e) => update('authType', e.target.value as AuthType)}
|
||||
>
|
||||
<option value="password">{t('host.auth.password')}</option>
|
||||
<option value="key">{t('host.auth.key')}</option>
|
||||
<option value="agent">{t('host.auth.agent')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{form.authType === 'key' ? (
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor="host-key">
|
||||
{t('host.keyPath')}
|
||||
</label>
|
||||
<input
|
||||
id="host-key"
|
||||
className="input"
|
||||
value={form.keyPath}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
onChange={(e) => update('keyPath', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="field__hint">{t('host.passwordDeferred')}</p>
|
||||
|
||||
{error ? <p className="field__error">{error}</p> : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Copy, Pencil, Pin, PinOff, Plus, SquareTerminal, X } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { computeTabMove, orderAllSessions, sessionLabel } from '../../lib/sidebar'
|
||||
import ContextMenu, { type MenuItem } from '../common/ContextMenu'
|
||||
import Modal from '../common/Modal'
|
||||
|
||||
/**
|
||||
* A stable accent per host, so tabs belonging to the same machine read as a
|
||||
* group. Derived from the host id rather than assigned, so it does not change
|
||||
* as tabs open and close.
|
||||
*/
|
||||
function hostHue(hostId: number): number {
|
||||
return (hostId * 47) % 360
|
||||
}
|
||||
|
||||
interface MenuState {
|
||||
x: number
|
||||
y: number
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export default function TabBar() {
|
||||
const { t } = useTranslation()
|
||||
const tabs = useSessionStore((state) => state.tabs)
|
||||
const activeId = useSessionStore((state) => state.activeId)
|
||||
const setActive = useSessionStore((state) => state.setActive)
|
||||
const closeTab = useSessionStore((state) => state.closeTab)
|
||||
const togglePinned = useSessionStore((state) => state.togglePinned)
|
||||
const moveTab = useSessionStore((state) => state.moveTab)
|
||||
const openSession = useSessionStore((state) => state.openSession)
|
||||
const renameTab = useSessionStore((state) => state.renameTab)
|
||||
|
||||
const [menu, setMenu] = useState<MenuState | null>(null)
|
||||
const [renaming, setRenaming] = useState<string | null>(null)
|
||||
const [dragging, setDragging] = useState<string | null>(null)
|
||||
const [dropAt, setDropAt] = useState<{ id: string; position: 'before' | 'after' } | null>(null)
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return <span className="tabbar__empty">{t('tabs.empty')}</span>
|
||||
}
|
||||
|
||||
// Same order the sidebar shows, so a session is in the same place in both.
|
||||
const ordered = orderAllSessions(tabs)
|
||||
const renamingTab = tabs.find((tab) => tab.id === renaming) ?? null
|
||||
|
||||
const handleDrop = (targetId: string, position: 'before' | 'after'): void => {
|
||||
const draggedId = dragging
|
||||
setDragging(null)
|
||||
setDropAt(null)
|
||||
if (!draggedId) return
|
||||
// Refused moves simply do nothing; the indicator never appeared for them.
|
||||
moveTab(draggedId, targetId, position)
|
||||
}
|
||||
|
||||
const openMenu = (): MenuItem[] => {
|
||||
if (!menu) return []
|
||||
const tab = tabs.find((item) => item.id === menu.sessionId)
|
||||
if (!tab) return []
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'rename',
|
||||
label: t('tab.rename'),
|
||||
icon: <Pencil size={13} />,
|
||||
run: () => setRenaming(tab.id),
|
||||
},
|
||||
{
|
||||
key: 'pin',
|
||||
label: tab.pinned ? t('session.unpin') : t('session.pin'),
|
||||
icon: tab.pinned ? <PinOff size={13} /> : <Pin size={13} />,
|
||||
run: () => togglePinned(tab.id),
|
||||
},
|
||||
{
|
||||
key: 'duplicate',
|
||||
label: t('tab.duplicate'),
|
||||
icon: <Copy size={13} />,
|
||||
run: () => void openSession(tab.host, { after: tab.id }),
|
||||
},
|
||||
{
|
||||
key: 'new',
|
||||
label: t('tab.openNew'),
|
||||
icon: tab.hostId === null ? <SquareTerminal size={13} /> : <Plus size={13} />,
|
||||
run: () => void openSession(tab.host),
|
||||
},
|
||||
{
|
||||
key: 'close',
|
||||
label: t('tab.close'),
|
||||
icon: <X size={13} />,
|
||||
danger: true,
|
||||
run: () => closeTab(tab.id),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ordered.map((tab) => {
|
||||
const label = sessionLabel(tab, t('sidebar.local'))
|
||||
const isDragging = dragging === tab.id
|
||||
const drop = dropAt?.id === tab.id ? dropAt.position : null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={[
|
||||
'tab',
|
||||
tab.id === activeId ? 'tab--active' : '',
|
||||
isDragging ? 'tab--dragging' : '',
|
||||
drop === 'before' ? 'tab--drop-before' : '',
|
||||
drop === 'after' ? 'tab--drop-after' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="tab"
|
||||
aria-selected={tab.id === activeId}
|
||||
tabIndex={-1}
|
||||
title={label}
|
||||
draggable
|
||||
onClick={() => setActive(tab.id)}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button === 1) closeTab(tab.id)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') setActive(tab.id)
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
setActive(tab.id)
|
||||
setMenu({ x: e.clientX, y: e.clientY, sessionId: tab.id })
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', tab.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
setDragging(tab.id)
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragging(null)
|
||||
setDropAt(null)
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
const draggedId = dragging
|
||||
if (!draggedId || draggedId === tab.id) return
|
||||
|
||||
const box = e.currentTarget.getBoundingClientRect()
|
||||
const position = e.clientX < box.left + box.width / 2 ? 'before' : 'after'
|
||||
|
||||
// Only show an indicator for a move that will be accepted.
|
||||
const accepted = computeTabMove(tabs, draggedId, tab.id, position) !== null
|
||||
if (accepted) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDropAt({ id: tab.id, position })
|
||||
} else {
|
||||
setDropAt(null)
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const box = e.currentTarget.getBoundingClientRect()
|
||||
const position = e.clientX < box.left + box.width / 2 ? 'before' : 'after'
|
||||
handleDrop(tab.id, position)
|
||||
}}
|
||||
>
|
||||
{tab.hostId === null ? (
|
||||
<span
|
||||
className={`tab__stripe tab__stripe--local${
|
||||
tab.status === 'reconnecting' ? ' tab__stripe--reconnecting' : ''
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`tab__stripe${
|
||||
tab.status === 'closed'
|
||||
? ' tab__stripe--closed'
|
||||
: tab.status === 'reconnecting'
|
||||
? ' tab__stripe--reconnecting'
|
||||
: ''
|
||||
}`}
|
||||
style={{
|
||||
background:
|
||||
tab.status === 'connected'
|
||||
? `hsl(${hostHue(tab.hostId)} 70% 55%)`
|
||||
: undefined,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="tab__label">{label}</span>
|
||||
|
||||
{tab.pinned ? (
|
||||
<span className="tab__pin" title={t('session.pinned')} aria-hidden="true">
|
||||
<Pin size={10} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="tab__close"
|
||||
aria-label={t('tab.close')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTab(tab.id)
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{menu ? <ContextMenu x={menu.x} y={menu.y} items={openMenu()} onClose={() => setMenu(null)} /> : null}
|
||||
|
||||
<RenameDialog
|
||||
tab={renamingTab}
|
||||
onCancel={() => setRenaming(null)}
|
||||
onSubmit={(name) => {
|
||||
if (renamingTab) renameTab(renamingTab.id, name)
|
||||
setRenaming(null)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface RenameDialogProps {
|
||||
tab: { id: string; customLabel: string | null } | null
|
||||
onCancel: () => void
|
||||
onSubmit: (name: string) => void
|
||||
}
|
||||
|
||||
function RenameDialog({ tab, onCancel, onSubmit }: RenameDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
const tabId = tab?.id ?? null
|
||||
const currentName = tab?.customLabel ?? null
|
||||
|
||||
// Seeded from the tab each time the dialog opens, so a cancelled rename does
|
||||
// not bleed into the next one.
|
||||
useEffect(() => {
|
||||
if (tabId !== null) setValue(currentName ?? '')
|
||||
}, [tabId, currentName])
|
||||
|
||||
const submit = (): void => {
|
||||
if (value.trim()) onSubmit(value.trim())
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={tab !== null}
|
||||
title={t('tab.rename')}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" className="btn btn--primary" onClick={submit}>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="field">
|
||||
<input
|
||||
className="input"
|
||||
value={value}
|
||||
autoFocus
|
||||
placeholder={t('tab.namePlaceholder')}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="field__hint">{t('tab.renameHint')}</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import TerminalView from './TerminalView'
|
||||
|
||||
/**
|
||||
* Keeps one terminal alive per open session.
|
||||
*
|
||||
* Every pane stays mounted and is hidden with `display: none` rather than
|
||||
* unmounted. xterm holds the scrollback and the parser state, so tearing a
|
||||
* pane down on a tab switch would discard everything the user could still
|
||||
* scroll back through. The cost is memory — roughly 10k lines per session —
|
||||
* which is the trade the scrollback setting exists to control.
|
||||
*/
|
||||
export default function TerminalHost() {
|
||||
const { t } = useTranslation()
|
||||
const tabs = useSessionStore((state) => state.tabs)
|
||||
const activeId = useSessionStore((state) => state.activeId)
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<span className="empty__title">{t('terminal.placeholder')}</span>
|
||||
<span className="empty__detail">{t('terminal.placeholderDetail')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="terminal-host">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="terminal-slot"
|
||||
style={{ display: tab.id === activeId ? 'block' : 'none' }}
|
||||
>
|
||||
<TerminalView sessionId={tab.id} active={tab.id === activeId} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { TerminalHandle } from '../../lib/terminals'
|
||||
import { forgetSessionPort, onSessionPort } from '../../lib/sessionBridge'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
|
||||
interface TerminalViewProps {
|
||||
sessionId: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/** Resizing is bursty; refitting on every observation would thrash the parser. */
|
||||
const REFIT_DEBOUNCE_MS = 80
|
||||
|
||||
export default function TerminalView({ sessionId, active }: TerminalViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const handleRef = useRef<TerminalHandle | null>(null)
|
||||
|
||||
// Read by the ResizeObserver, which is created once and would otherwise close
|
||||
// over the `active` value from that first render forever.
|
||||
const activeRef = useRef(active)
|
||||
activeRef.current = active
|
||||
|
||||
const markClosed = useSessionStore((state) => state.markClosed)
|
||||
const markStatus = useSessionStore((state) => state.markStatus)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const handle = new TerminalHandle(container)
|
||||
handleRef.current = handle
|
||||
handle.onExit(() => markClosed(sessionId))
|
||||
handle.onStatus((status) => markStatus(sessionId, status))
|
||||
|
||||
// The port may already have arrived — the main process posts it as soon as
|
||||
// the shell opens, which can be before this component mounts.
|
||||
onSessionPort(sessionId, (port) => handle.attach(port))
|
||||
|
||||
let refitTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!activeRef.current) return
|
||||
if (refitTimer !== null) clearTimeout(refitTimer)
|
||||
refitTimer = setTimeout(() => handle.fitAndReport(), REFIT_DEBOUNCE_MS)
|
||||
})
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (refitTimer !== null) clearTimeout(refitTimer)
|
||||
forgetSessionPort(sessionId)
|
||||
handle.dispose()
|
||||
handleRef.current = null
|
||||
}
|
||||
}, [sessionId, markClosed])
|
||||
|
||||
useEffect(() => {
|
||||
const handle = handleRef.current
|
||||
if (!handle) return
|
||||
handle.setActive(active)
|
||||
if (active) handle.focus()
|
||||
}, [active])
|
||||
|
||||
return <div className="terminal-pane" ref={containerRef} />
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
|
||||
export interface MenuItem {
|
||||
key: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
/** Rendered in the danger colour — reserved for the destructive action. */
|
||||
danger?: boolean
|
||||
run: () => void
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
x: number
|
||||
y: number
|
||||
items: MenuItem[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A positioned menu, shared by the sidebar and the tab bar.
|
||||
*
|
||||
* Anchored to a point rather than to an element, because both callers open it
|
||||
* from a pointer event and neither has a trigger element to hang it off.
|
||||
*/
|
||||
export default function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (e: MouseEvent): void => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose()
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onPointerDown)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
// A menu opened near the right or bottom edge would otherwise hang off it.
|
||||
const maxX = Math.max(8, window.innerWidth - 200)
|
||||
const maxY = Math.max(8, window.innerHeight - items.length * 30 - 16)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="menu"
|
||||
style={{ left: Math.min(x, maxX), top: Math.min(y, maxY) }}
|
||||
role="menu"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`menu__item${item.danger ? ' menu__item--danger' : ''}`}
|
||||
onClick={() => {
|
||||
item.run()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{item.icon ? <span className="menu__icon">{item.icon}</span> : null}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Built on the native `<dialog>` element rather than a hand-rolled overlay.
|
||||
*
|
||||
* That buys focus trapping, Escape-to-close, inert background content, and
|
||||
* correct stacking against the top layer — all of which are easy to approximate
|
||||
* badly and tedious to get right. Chromium has supported it since well before
|
||||
* the version Electron ships here.
|
||||
*/
|
||||
export default function Modal({ open, title, onClose, children, footer }: ModalProps) {
|
||||
const ref = useRef<HTMLDialogElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current
|
||||
if (!dialog) return
|
||||
|
||||
// `showModal` throws if called on an already-open dialog, and `close` on a
|
||||
// closed one is harmless but noisy — so mirror the element's own state
|
||||
// rather than assuming the prop and the DOM are in step.
|
||||
if (open && !dialog.open) dialog.showModal()
|
||||
else if (!open && dialog.open) dialog.close()
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
className="modal"
|
||||
// Fires for Escape and for programmatic close alike, so the parent's
|
||||
// state stays the single source of truth for whether this is open.
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<form method="dialog" className="modal__form" onSubmit={(e) => e.preventDefault()}>
|
||||
<header className="modal__header">
|
||||
<h2 className="modal__title">{title}</h2>
|
||||
</header>
|
||||
|
||||
<div className="modal__body">{children}</div>
|
||||
|
||||
{footer ? <footer className="modal__footer">{footer}</footer> : null}
|
||||
</form>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { NexusTermApi } from '@shared/api'
|
||||
|
||||
/**
|
||||
* The contextBridge API exposed by the preload script.
|
||||
*
|
||||
* The shape lives in `@shared/api` and the preload is annotated with the same
|
||||
* interface, so the two cannot drift: a mismatch is a compile error on one side
|
||||
* or the other, rather than a runtime failure discovered by a user.
|
||||
*/
|
||||
declare global {
|
||||
interface Window {
|
||||
nexusterm: NexusTermApi
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "NexusTerm",
|
||||
"tagline": "SSH session manager",
|
||||
"switchLanguage": "Switch language"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"rename": "Rename",
|
||||
"dismiss": "Dismiss",
|
||||
"loading": "Loading…"
|
||||
},
|
||||
"security": {
|
||||
"heading": "Credential storage unavailable",
|
||||
"keyringOk": "Credentials protected by the system keyring ({{backend}})",
|
||||
"keyringMac": "Credentials protected by the system keyring",
|
||||
"reason": {
|
||||
"not-ready": "The system keyring is not ready yet — Electron has not finished starting up.",
|
||||
"unavailable": "No usable system keyring service was found.",
|
||||
"basic-text": "The system selected the basic_text backend. It \"encrypts\" with a hardcoded key, which is equivalent to storing plaintext — yet the database still looks like ciphertext.",
|
||||
"unknown-backend": "The keyring backend could not be determined; treating it as untrusted."
|
||||
},
|
||||
"vault": {
|
||||
"insecure-keyring": "The system keyring is not trustworthy, so no master key was created.",
|
||||
"seal-failed": "Could not wrap the master key with the system keyring: {{detail}}",
|
||||
"unseal-failed": "Could not unwrap the stored master key: {{detail}}. This usually means the keyring service changed, the machine was reinstalled, or a backup was restored — existing credentials cannot be decrypted."
|
||||
},
|
||||
"hint": "On Linux this usually means no keyring daemon (gnome-keyring or kwallet) is running. The app will not fall back to weak storage: it refuses to save rather than letting you believe a password is protected."
|
||||
},
|
||||
"sidebar": {
|
||||
"heading": "Connections",
|
||||
"newHost": "New host",
|
||||
"newFolder": "New folder",
|
||||
"newLocal": "New local shell",
|
||||
"local": "Local shell",
|
||||
"localHint": "A shell on this machine — double-click to open one",
|
||||
"empty": "No connections yet"
|
||||
},
|
||||
"session": {
|
||||
"pin": "Pin to top",
|
||||
"unpin": "Unpin",
|
||||
"pinned": "Pinned to the top of this host",
|
||||
"exited": "Session ended",
|
||||
"open": "Session running"
|
||||
},
|
||||
"tab": {
|
||||
"rename": "Rename tab",
|
||||
"namePlaceholder": "Tab name",
|
||||
"renameHint": "Names this session only — the host is unchanged, and the name goes away when the session closes.",
|
||||
"duplicate": "Duplicate tab",
|
||||
"openNew": "Open a new tab",
|
||||
"close": "Close tab"
|
||||
},
|
||||
"host": {
|
||||
"create": "New host",
|
||||
"edit": "Edit host",
|
||||
"label": "Name",
|
||||
"labelPlaceholder": "Production web server",
|
||||
"hostname": "Host",
|
||||
"port": "Port",
|
||||
"username": "Username",
|
||||
"authType": "Authentication",
|
||||
"keyPath": "Private key path",
|
||||
"passwordDeferred": "Asked for on first connect, with the option to save it to the system keyring. It travels a write-only channel — the renderer never gets plaintext back.",
|
||||
"auth": {
|
||||
"password": "Password",
|
||||
"key": "Private key",
|
||||
"agent": "SSH agent"
|
||||
},
|
||||
"error": {
|
||||
"label": "A name is required",
|
||||
"hostname": "A host is required",
|
||||
"username": "A username is required",
|
||||
"port": "Port must be an integer between 1 and 65535"
|
||||
}
|
||||
},
|
||||
"folder": {
|
||||
"create": "New folder",
|
||||
"rename": "Rename folder",
|
||||
"namePlaceholder": "Folder name"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Confirm deletion",
|
||||
"deleteFolder": "Delete the folder \"{{name}}\"? Every host and subfolder inside it goes too. This cannot be undone.",
|
||||
"deleteHost": "Delete the host \"{{name}}\"?"
|
||||
},
|
||||
"hostKey": {
|
||||
"title": "First connection to this host",
|
||||
"unknown": "No host key has been recorded for {{host}}:{{port}} yet.",
|
||||
"keyType": "Key type",
|
||||
"fingerprint": "Fingerprint",
|
||||
"accept": "Trust and connect",
|
||||
"explain": "Check the fingerprint above against the host's actual key — on the server, run ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub. It will be remembered; if it ever changes, the connection is refused rather than questioned."
|
||||
},
|
||||
"password": {
|
||||
"title": "Connect to {{host}}",
|
||||
"label": "Password for {{username}}",
|
||||
"connect": "Connect",
|
||||
"remember": "Save to the system keyring",
|
||||
"hint": "Stored encrypted under a master key held by the OS keyring. The renderer never gets the plaintext back.",
|
||||
"saveFailed": "The password was not saved: {{message}} — untick \"Save to the system keyring\" to connect without saving.",
|
||||
"retry": "Last attempt failed: {{message}}"
|
||||
},
|
||||
"tabs": {
|
||||
"empty": "No open sessions"
|
||||
},
|
||||
"terminal": {
|
||||
"placeholder": "No open sessions",
|
||||
"placeholderDetail": "Double-click a host on the left to connect, or the pinned Local shell at the top for a prompt on this machine. The first connection asks you to check its host key fingerprint.",
|
||||
"reconnecting": "--- connection lost; reconnecting (attempt {{attempt}}, in {{seconds}}s) ---",
|
||||
"reconnected": "--- reconnected on attempt {{attempt}} ---\n--- note: this is a new shell; the previous session ended and its working directory is not restored ---",
|
||||
"disconnected": "--- disconnected: {{message}} ---"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import zhCN from './zh-CN.json'
|
||||
import enUS from './en-US.json'
|
||||
|
||||
export const SUPPORTED_LOCALES = ['zh-CN', 'en-US'] as const
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number]
|
||||
|
||||
/**
|
||||
* i18n is wired up from the very first milestone rather than retrofitted.
|
||||
* Retrofitting means touching every component that ever hardcoded a string,
|
||||
* and the cost only grows — so the two dictionaries exist before there is
|
||||
* anything meaningful to put in them.
|
||||
*/
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-CN': { translation: zhCN },
|
||||
'en-US': { translation: enUS },
|
||||
},
|
||||
lng: 'zh-CN',
|
||||
fallbackLng: 'zh-CN',
|
||||
interpolation: {
|
||||
// React already escapes interpolated values.
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "NexusTerm",
|
||||
"tagline": "SSH 会话管理器",
|
||||
"switchLanguage": "切换语言"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"rename": "重命名",
|
||||
"dismiss": "关闭提示",
|
||||
"loading": "加载中…"
|
||||
},
|
||||
"security": {
|
||||
"heading": "凭据存储不可用",
|
||||
"keyringOk": "凭据将由系统钥匙串保护({{backend}})",
|
||||
"keyringMac": "凭据将由系统钥匙串保护",
|
||||
"reason": {
|
||||
"not-ready": "系统钥匙串尚未就绪,Electron 还未完成初始化。",
|
||||
"unavailable": "未找到可用的系统钥匙串服务。",
|
||||
"basic-text": "系统选择了 basic_text 后端:它使用硬编码密钥「加密」,等同于明文存储,但数据库看起来仍是密文。",
|
||||
"unknown-backend": "无法确定系统钥匙串后端,出于安全考虑按不可信处理。"
|
||||
},
|
||||
"vault": {
|
||||
"insecure-keyring": "系统钥匙串不可信,主密钥未被创建。",
|
||||
"seal-failed": "无法用系统钥匙串封装主密钥:{{detail}}",
|
||||
"unseal-failed": "无法解开已存储的主密钥:{{detail}}。通常是钥匙串服务变更、系统重装或从备份恢复所致 —— 已有凭据将无法解密。"
|
||||
},
|
||||
"hint": "在 Linux 上通常是因为没有运行 keyring 守护进程(gnome-keyring 或 kwallet)。本应用不会退回到弱加密存储:宁可拒绝保存,也不会让你误以为密码已被保护。"
|
||||
},
|
||||
"sidebar": {
|
||||
"heading": "连接",
|
||||
"newHost": "新建主机",
|
||||
"newFolder": "新建文件夹",
|
||||
"newLocal": "新建本地终端",
|
||||
"local": "本地终端",
|
||||
"localHint": "本机 shell —— 双击打开一个新会话",
|
||||
"empty": "还没有任何连接"
|
||||
},
|
||||
"session": {
|
||||
"pin": "置顶",
|
||||
"unpin": "取消置顶",
|
||||
"pinned": "已在本主机分组内置顶",
|
||||
"exited": "会话已结束",
|
||||
"open": "会话进行中"
|
||||
},
|
||||
"tab": {
|
||||
"rename": "重命名标签",
|
||||
"namePlaceholder": "标签名称",
|
||||
"renameHint": "只改这个会话显示的名字,不影响主机配置;会话关闭后失效。",
|
||||
"duplicate": "复制标签",
|
||||
"openNew": "打开新标签",
|
||||
"close": "关闭标签"
|
||||
},
|
||||
"host": {
|
||||
"create": "新建主机",
|
||||
"edit": "编辑主机",
|
||||
"label": "名称",
|
||||
"labelPlaceholder": "生产 Web 服务器",
|
||||
"hostname": "主机地址",
|
||||
"port": "端口",
|
||||
"username": "用户名",
|
||||
"authType": "认证方式",
|
||||
"keyPath": "私钥路径",
|
||||
"passwordDeferred": "密码在首次连接时询问,可选择保存到系统钥匙串。它走只写通道,渲染进程永远拿不回明文。",
|
||||
"auth": {
|
||||
"password": "密码",
|
||||
"key": "私钥",
|
||||
"agent": "SSH Agent"
|
||||
},
|
||||
"error": {
|
||||
"label": "请填写名称",
|
||||
"hostname": "请填写主机地址",
|
||||
"username": "请填写用户名",
|
||||
"port": "端口需为 1 到 65535 之间的整数"
|
||||
}
|
||||
},
|
||||
"folder": {
|
||||
"create": "新建文件夹",
|
||||
"rename": "重命名文件夹",
|
||||
"namePlaceholder": "文件夹名称"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "确认删除",
|
||||
"deleteFolder": "确定要删除文件夹「{{name}}」吗?它里面的所有主机和子文件夹都会一并删除,此操作不可撤销。",
|
||||
"deleteHost": "确定要删除主机「{{name}}」吗?"
|
||||
},
|
||||
"hostKey": {
|
||||
"title": "首次连接此主机",
|
||||
"unknown": "{{host}}:{{port}} 的主机密钥尚未记录。",
|
||||
"keyType": "密钥类型",
|
||||
"fingerprint": "指纹",
|
||||
"accept": "信任并连接",
|
||||
"explain": "请核对上面的指纹与该主机的实际密钥一致(可在服务器上运行 ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub 对比)。确认后本机会记住它;此后若密钥发生变化,将直接拒绝连接而不再询问。"
|
||||
},
|
||||
"password": {
|
||||
"title": "连接到 {{host}}",
|
||||
"label": "{{username}} 的密码",
|
||||
"connect": "连接",
|
||||
"remember": "保存密码到系统钥匙串",
|
||||
"hint": "密码经主密钥加密后存入本地数据库,主密钥由系统钥匙串保管。渲染进程永远拿不回明文。",
|
||||
"saveFailed": "密码未能保存:{{message}} —— 可取消勾选「保存密码」后直接连接。",
|
||||
"retry": "上次认证失败:{{message}}"
|
||||
},
|
||||
"tabs": {
|
||||
"empty": "暂无打开的会话"
|
||||
},
|
||||
"terminal": {
|
||||
"placeholder": "还没有打开的会话",
|
||||
"placeholderDetail": "双击左侧的主机即可连接,或双击顶部的「本地终端」开一个本机 shell。首次连接会要求你核对主机密钥指纹。",
|
||||
"reconnecting": "--- 连接断开,正在重连(第 {{attempt}} 次,{{seconds}} 秒后)---",
|
||||
"reconnected": "--- 已重连(第 {{attempt}} 次尝试成功)---\n--- 注意:这是一个新的 shell,上一个会话已结束,工作目录不会恢复 ---",
|
||||
"disconnected": "--- 连接已断开:{{message}} ---"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { SESSION_PORT_CHANNEL, type PortHandshake } from '@shared/session-protocol'
|
||||
|
||||
/**
|
||||
* Receives session ports relayed by the preload and hands them to whoever is
|
||||
* waiting for that session.
|
||||
*
|
||||
* Both orderings have to work: the port can arrive before the UI has created
|
||||
* the terminal for it (the tab is still being set up), or after. Rather than
|
||||
* making the caller care, whichever side arrives second completes the pair.
|
||||
*/
|
||||
const arrived = new Map<string, MessagePort>()
|
||||
const waiting = new Map<string, (port: MessagePort) => void>()
|
||||
|
||||
window.addEventListener('message', (event: MessageEvent) => {
|
||||
const data = event.data as PortHandshake & { type?: string }
|
||||
if (data?.type !== SESSION_PORT_CHANNEL || typeof data.sessionId !== 'string') return
|
||||
|
||||
const port = event.ports[0]
|
||||
if (!port) return
|
||||
|
||||
const handler = waiting.get(data.sessionId)
|
||||
if (handler) {
|
||||
waiting.delete(data.sessionId)
|
||||
handler(port)
|
||||
return
|
||||
}
|
||||
|
||||
arrived.set(data.sessionId, port)
|
||||
})
|
||||
|
||||
/** Invoke `handler` once the port for `sessionId` is available. */
|
||||
export function onSessionPort(sessionId: string, handler: (port: MessagePort) => void): void {
|
||||
const buffered = arrived.get(sessionId)
|
||||
if (buffered) {
|
||||
arrived.delete(sessionId)
|
||||
handler(buffered)
|
||||
return
|
||||
}
|
||||
waiting.set(sessionId, handler)
|
||||
}
|
||||
|
||||
/** Drop a session's port, if one is still waiting for a consumer. */
|
||||
export function forgetSessionPort(sessionId: string): void {
|
||||
waiting.delete(sessionId)
|
||||
const port = arrived.get(sessionId)
|
||||
if (port) {
|
||||
arrived.delete(sessionId)
|
||||
port.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import type { FolderNode, HostNode, MoveInput, TreeNode } from '@shared/ipc-schemas'
|
||||
|
||||
/**
|
||||
* A session as the sidebar sees it.
|
||||
*
|
||||
* Declared here rather than in the store so this module can build the row model
|
||||
* without importing from the store that imports it.
|
||||
*/
|
||||
export interface SidebarSession {
|
||||
id: string
|
||||
/** Null for a shell on this machine, which belongs to no host. */
|
||||
hostId: number | null
|
||||
/**
|
||||
* The base name: the host's label, or empty for a local shell (whose base is
|
||||
* a translated string the store has no business knowing).
|
||||
*/
|
||||
label: string
|
||||
/**
|
||||
* Which session this is for its host, counting from 1.
|
||||
*
|
||||
* Assigned at creation and stored, rather than derived from how many sessions
|
||||
* happen to be open. Derived numbering renames the survivors whenever one in
|
||||
* the middle closes — a session's own name would change under it.
|
||||
*/
|
||||
autoIndex: number
|
||||
/** Set by renaming the tab; overrides everything else until it closes. */
|
||||
customLabel: string | null
|
||||
/** `reconnecting` means the transport dropped and a retry is in flight. */
|
||||
status: 'connected' | 'closed' | 'reconnecting'
|
||||
/** Pinned within its own host's group — never across groups. */
|
||||
pinned: boolean
|
||||
/**
|
||||
* Position within its host group, not a count of anything.
|
||||
*
|
||||
* Fractional on purpose: a drag or a duplicate writes the midpoint between
|
||||
* two neighbours instead of renumbering the group, exactly as the connection
|
||||
* tree does with `sort_order`.
|
||||
*/
|
||||
seq: number
|
||||
/**
|
||||
* The host this session was opened against, kept so a duplicate or a new tab
|
||||
* can dial it again without the caller having the tree to hand.
|
||||
*/
|
||||
host: HostNode | null
|
||||
}
|
||||
|
||||
/**
|
||||
* What to show for a session.
|
||||
*
|
||||
* A custom name wins outright. Otherwise it is the base, with the count
|
||||
* appended once there is more than one session for that host — the first keeps
|
||||
* the bare name, so the common case stays uncluttered.
|
||||
*
|
||||
* `localFallback` is passed in rather than looked up because the base for a
|
||||
* local shell is translated, and this module has no business knowing a
|
||||
* language.
|
||||
*/
|
||||
export function sessionLabel(session: SidebarSession, localFallback: string): string {
|
||||
if (session.customLabel) return session.customLabel
|
||||
|
||||
const base = session.hostId === null ? localFallback : session.label
|
||||
return session.autoIndex > 1 ? `${base} ${session.autoIndex}` : base
|
||||
}
|
||||
|
||||
/** The number the next session for this host should carry. */
|
||||
export function nextAutoIndex(
|
||||
sessions: readonly SidebarSession[],
|
||||
hostId: number | null,
|
||||
): number {
|
||||
const indices = sessions.filter((s) => s.hostId === hostId).map((s) => s.autoIndex)
|
||||
return indices.length === 0 ? 1 : Math.max(...indices) + 1
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- row keys
|
||||
//
|
||||
// One string key space for the whole sidebar. Folders and hosts share an id
|
||||
// sequence, so they still need a kind prefix to stay distinct, and sessions
|
||||
// carry string ids of their own.
|
||||
|
||||
export const LOCAL_KEY = 'local'
|
||||
export const folderKey = (id: number): string => `f:${id}`
|
||||
export const hostKey = (id: number): string => `h:${id}`
|
||||
export const sessionKey = (id: string): string => `s:${id}`
|
||||
|
||||
export type SidebarRowKind = 'local' | 'folder' | 'host' | 'session'
|
||||
|
||||
export interface SidebarRow {
|
||||
key: string
|
||||
kind: SidebarRowKind
|
||||
depth: number
|
||||
|
||||
/** Present on folder and host rows. */
|
||||
node?: TreeNode
|
||||
/**
|
||||
* Present on folder and host rows: the layer this node sits in, including
|
||||
* itself. A drop needs it to find the neighbours it will land between.
|
||||
*/
|
||||
siblings?: TreeNode[]
|
||||
/** Present on folder and host rows: the containing folder, null at the root. */
|
||||
parentId?: number | null
|
||||
/** Present on session rows. */
|
||||
session?: SidebarSession
|
||||
/** Present on session rows: the host it hangs under, null for local. */
|
||||
hostId?: number | null
|
||||
|
||||
/** Containers only: whether there is anything to show underneath. */
|
||||
expandable: boolean
|
||||
expanded: boolean
|
||||
|
||||
/** Host and local rows: how many open sessions hang under them. */
|
||||
sessionCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinned first, then by the order they were opened.
|
||||
*
|
||||
* Pinning is scoped to one host's group on purpose: the sidebar exists to keep
|
||||
* a machine's sessions together, and a session that jumps to the top of the
|
||||
* whole list has left its group.
|
||||
*/
|
||||
export function orderSessions(sessions: readonly SidebarSession[]): SidebarSession[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
||||
return a.seq - b.seq
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Every open session, ordered the way the sidebar and the tab bar both show
|
||||
* them: the local shells first, then one contiguous block per host.
|
||||
*/
|
||||
export function orderAllSessions(sessions: readonly SidebarSession[]): SidebarSession[] {
|
||||
const local = sessions.filter((s) => s.hostId === null)
|
||||
|
||||
// Grouped by host, with the hosts in the order they were first opened.
|
||||
const groups = new Map<number, SidebarSession[]>()
|
||||
for (const session of sessions) {
|
||||
if (session.hostId === null) continue
|
||||
const group = groups.get(session.hostId)
|
||||
if (group) group.push(session)
|
||||
else groups.set(session.hostId, [session])
|
||||
}
|
||||
|
||||
const ordered = orderSessions(local)
|
||||
for (const group of groups.values()) ordered.push(...orderSessions(group))
|
||||
return ordered
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tab placement
|
||||
|
||||
/** The bucket a session sorts within: one host, one pin state. */
|
||||
function bucketOf(
|
||||
sessions: readonly SidebarSession[],
|
||||
hostId: number | null,
|
||||
pinned: boolean,
|
||||
): SidebarSession[] {
|
||||
return orderSessions(sessions.filter((s) => s.hostId === hostId && s.pinned === pinned))
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a new session goes when opened for a host: at the end of its group.
|
||||
*
|
||||
* Derived from the maximum rather than from the array length, because `seq` is
|
||||
* fractional once anything has been dragged.
|
||||
*/
|
||||
export function seqForNewSession(
|
||||
sessions: readonly SidebarSession[],
|
||||
hostId: number | null,
|
||||
): number {
|
||||
const group = sessions.filter((s) => s.hostId === hostId)
|
||||
if (group.length === 0) return 1
|
||||
return Math.max(...group.map((s) => s.seq)) + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a duplicate goes: immediately after the session it copies.
|
||||
*
|
||||
* A duplicate keeps its 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.
|
||||
*/
|
||||
export function seqAfterSession(
|
||||
sessions: readonly SidebarSession[],
|
||||
originalId: string,
|
||||
): number {
|
||||
const original = sessions.find((s) => s.id === originalId)
|
||||
if (!original) return seqForNewSession(sessions, null)
|
||||
|
||||
const bucket = bucketOf(sessions, original.hostId, original.pinned)
|
||||
const index = bucket.findIndex((s) => s.id === originalId)
|
||||
const next = bucket[index + 1]
|
||||
|
||||
return next ? (original.seq + next.seq) / 2 : original.seq + 1
|
||||
}
|
||||
|
||||
export type TabDropPosition = 'before' | 'after'
|
||||
|
||||
/**
|
||||
* Reorder a session within its own host group.
|
||||
*
|
||||
* Returns the `seq` the dragged session should take, or null when the move is
|
||||
* not expressible. Refused across groups — a session that left its host's block
|
||||
* would have no place in a sidebar that groups by host — and across pin states,
|
||||
* because the sort would put it back where it started regardless.
|
||||
*/
|
||||
export function computeTabMove(
|
||||
sessions: readonly SidebarSession[],
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: TabDropPosition,
|
||||
): { seq: number } | null {
|
||||
if (draggedId === targetId) return null
|
||||
|
||||
const dragged = sessions.find((s) => s.id === draggedId)
|
||||
const target = sessions.find((s) => s.id === targetId)
|
||||
if (!dragged || !target) return null
|
||||
if (dragged.hostId !== target.hostId) return null
|
||||
if (dragged.pinned !== target.pinned) return null
|
||||
|
||||
const bucket = bucketOf(sessions, dragged.hostId, dragged.pinned)
|
||||
const index = bucket.findIndex((s) => s.id === targetId)
|
||||
if (index === -1) return null
|
||||
|
||||
const insertAt = position === 'before' ? index : index + 1
|
||||
const prev = bucket[insertAt - 1] ?? null
|
||||
const next = bucket[insertAt] ?? null
|
||||
|
||||
// Landing back in the slot it already occupies is a no-op, not an error.
|
||||
if (prev?.id === draggedId || next?.id === draggedId) return null
|
||||
|
||||
if (prev && next) return { seq: (prev.seq + next.seq) / 2 }
|
||||
if (prev) return { seq: prev.seq + 1 }
|
||||
if (next) return { seq: next.seq - 1 }
|
||||
return { seq: 1 }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rows
|
||||
|
||||
function groupByHost(sessions: readonly SidebarSession[]): Map<number | null, SidebarSession[]> {
|
||||
const grouped = new Map<number | null, SidebarSession[]>()
|
||||
for (const session of sessions) {
|
||||
const bucket = grouped.get(session.hostId)
|
||||
if (bucket) bucket.push(session)
|
||||
else grouped.set(session.hostId, [session])
|
||||
}
|
||||
for (const [key, bucket] of grouped) grouped.set(key, orderSessions(bucket))
|
||||
return grouped
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the sidebar into the rows currently visible.
|
||||
*
|
||||
* Two sources are merged: the connection tree from the database, and the
|
||||
* sessions open right now, which live only in the renderer. A host becomes an
|
||||
* interior node for as long as it has sessions under it, and a leaf again once
|
||||
* they are all closed — which is why a host's expandability is derived from the
|
||||
* session list rather than stored.
|
||||
*/
|
||||
export function buildSidebarRows(
|
||||
tree: readonly TreeNode[],
|
||||
sessions: readonly SidebarSession[],
|
||||
expanded: ReadonlySet<string>,
|
||||
): SidebarRow[] {
|
||||
const byHost = groupByHost(sessions)
|
||||
const rows: SidebarRow[] = []
|
||||
|
||||
const pushSessions = (hostId: number | null, depth: number): void => {
|
||||
for (const session of byHost.get(hostId) ?? []) {
|
||||
rows.push({
|
||||
key: sessionKey(session.id),
|
||||
kind: 'session',
|
||||
depth,
|
||||
session,
|
||||
hostId,
|
||||
expandable: false,
|
||||
expanded: false,
|
||||
sessionCount: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The pinned local entry, always first and always present.
|
||||
const localSessions = byHost.get(null) ?? []
|
||||
const localExpanded = expanded.has(LOCAL_KEY)
|
||||
rows.push({
|
||||
key: LOCAL_KEY,
|
||||
kind: 'local',
|
||||
depth: 0,
|
||||
expandable: localSessions.length > 0,
|
||||
expanded: localExpanded,
|
||||
sessionCount: localSessions.length,
|
||||
})
|
||||
if (localExpanded) pushSessions(null, 1)
|
||||
|
||||
const walk = (
|
||||
layer: readonly TreeNode[],
|
||||
depth: number,
|
||||
parentId: number | null,
|
||||
): void => {
|
||||
for (const node of layer) {
|
||||
// `layer` is not narrowed to TreeNode[] by the readonly spread, but every
|
||||
// element in it is one.
|
||||
const siblings = layer as TreeNode[]
|
||||
|
||||
if (node.kind === 'folder') {
|
||||
const key = folderKey(node.id)
|
||||
const isExpanded = expanded.has(key)
|
||||
rows.push({
|
||||
key,
|
||||
kind: 'folder',
|
||||
depth,
|
||||
node,
|
||||
siblings,
|
||||
parentId,
|
||||
expandable: node.children.length > 0,
|
||||
expanded: isExpanded,
|
||||
sessionCount: 0,
|
||||
})
|
||||
if (isExpanded) walk(node.children, depth + 1, node.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const key = hostKey(node.id)
|
||||
const hostSessions = byHost.get(node.id) ?? []
|
||||
const isExpanded = expanded.has(key)
|
||||
|
||||
rows.push({
|
||||
key,
|
||||
kind: 'host',
|
||||
depth,
|
||||
node,
|
||||
siblings,
|
||||
parentId,
|
||||
expandable: hostSessions.length > 0,
|
||||
expanded: isExpanded,
|
||||
sessionCount: hostSessions.length,
|
||||
})
|
||||
if (isExpanded) pushSessions(node.id, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
walk(tree, 0, null)
|
||||
return rows
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- expansion
|
||||
|
||||
/** Every key that should be open for a folder's contents to be visible. */
|
||||
export function ancestorKeysOf(tree: readonly TreeNode[], nodeKey: string): string[] {
|
||||
const path: string[] = []
|
||||
|
||||
const walk = (layer: readonly TreeNode[], trail: string[]): boolean => {
|
||||
for (const node of layer) {
|
||||
const key = node.kind === 'folder' ? folderKey(node.id) : hostKey(node.id)
|
||||
if (key === nodeKey) {
|
||||
path.push(...trail)
|
||||
return true
|
||||
}
|
||||
if (node.kind === 'folder' && walk(node.children, [...trail, key])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
walk(tree, [])
|
||||
return path
|
||||
}
|
||||
|
||||
/** Folder keys only — "expand all" should reveal the tree, not every session. */
|
||||
export function allFolderKeys(tree: readonly TreeNode[]): string[] {
|
||||
const keys: string[] = []
|
||||
|
||||
const walk = (layer: readonly TreeNode[]): void => {
|
||||
for (const node of layer) {
|
||||
if (node.kind === 'folder') {
|
||||
keys.push(folderKey(node.id))
|
||||
walk(node.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(tree)
|
||||
return keys
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- dragging
|
||||
|
||||
export type DropPosition = 'before' | 'after' | 'inside'
|
||||
|
||||
/**
|
||||
* Which edge of a row the pointer is over.
|
||||
*
|
||||
* The middle band means "drop into" and only makes sense for a folder; on a
|
||||
* host it falls back to whichever half the pointer is in, so the row never
|
||||
* presents a drop target that cannot be honoured.
|
||||
*/
|
||||
export function dropPositionFor(
|
||||
rect: { top: number; height: number },
|
||||
pointerY: number,
|
||||
node: TreeNode,
|
||||
): DropPosition {
|
||||
const offset = (pointerY - rect.top) / rect.height
|
||||
|
||||
if (node.kind === 'folder') {
|
||||
if (offset < 0.25) return 'before'
|
||||
if (offset > 0.75) return 'after'
|
||||
return 'inside'
|
||||
}
|
||||
|
||||
return offset < 0.5 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
/** True when `candidate` sits anywhere inside `folder`'s subtree. */
|
||||
export function subtreeContains(folder: FolderNode, candidateId: number): boolean {
|
||||
for (const child of folder.children) {
|
||||
if (child.id === candidateId) return true
|
||||
if (child.kind === 'folder' && subtreeContains(child, candidateId)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate "drag A onto this edge of B" into the neighbour pair the main
|
||||
* process needs to compute a fractional sort order.
|
||||
*
|
||||
* Only hosts and folders are draggable — sessions are ordered by their own
|
||||
* rules and do not belong in the database.
|
||||
*/
|
||||
export function computeMove(
|
||||
draggedNode: TreeNode,
|
||||
target: { row: SidebarRow; position: DropPosition },
|
||||
): MoveInput | null {
|
||||
const { row, position } = target
|
||||
const node = row.node
|
||||
// Session rows are not drop targets: they are ordered by their own rules and
|
||||
// do not exist in the database at all.
|
||||
if (!node || row.kind === 'session') return null
|
||||
if (node.id === draggedNode.id) return null
|
||||
|
||||
if (position === 'inside') {
|
||||
if (node.kind !== 'folder') return null
|
||||
// Dropping a folder into its own subtree would detach it from every root.
|
||||
if (draggedNode.kind === 'folder' && subtreeContains(draggedNode, node.id)) return null
|
||||
|
||||
const last = node.children[node.children.length - 1]
|
||||
return {
|
||||
id: draggedNode.id,
|
||||
parentId: node.id,
|
||||
prevSiblingId: last?.id ?? null,
|
||||
nextSiblingId: null,
|
||||
}
|
||||
}
|
||||
|
||||
const layer = row.siblings
|
||||
if (!layer) return null
|
||||
|
||||
const index = layer.findIndex((n) => n.id === node.id)
|
||||
if (index === -1) return null
|
||||
|
||||
const insertAt = position === 'before' ? index : index + 1
|
||||
const prev = layer[insertAt - 1] ?? null
|
||||
const next = layer[insertAt] ?? null
|
||||
|
||||
// Landing back in the slot it already occupies is a no-op, not an error.
|
||||
if (prev?.id === draggedNode.id) return null
|
||||
if (next?.id === draggedNode.id) return null
|
||||
|
||||
return {
|
||||
id: draggedNode.id,
|
||||
parentId: row.parentId ?? null,
|
||||
prevSiblingId: prev?.id ?? null,
|
||||
nextSiblingId: next?.id ?? null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import i18n from '../i18n'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import type { ToMainFrame, ToRendererFrame } from '@shared/session-protocol'
|
||||
|
||||
const SCROLLBACK_LINES = 10_000
|
||||
/** Acks are batched: one message per chunk would flood the port. */
|
||||
const ACK_INTERVAL_MS = 4
|
||||
|
||||
/**
|
||||
* One terminal, its renderer addons, and its connection to the main process.
|
||||
*
|
||||
* Instances live for the whole session even while their tab is hidden — xterm
|
||||
* holds the scrollback and the parser state, so tearing one down on a tab
|
||||
* switch would throw away everything the user can still scroll back through.
|
||||
*/
|
||||
export class TerminalHandle {
|
||||
readonly terminal: Terminal
|
||||
|
||||
private readonly fit = new FitAddon()
|
||||
private readonly container: HTMLElement
|
||||
private port: MessagePort | null = null
|
||||
private webgl: WebglAddon | null = null
|
||||
private pendingAck = 0
|
||||
private ackTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private disposed = false
|
||||
private onExitCallback: ((code: number | null) => void) | null = null
|
||||
private onStatusCallback: ((status: 'connected' | 'reconnecting') => void) | null = null
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container
|
||||
|
||||
this.terminal = new Terminal({
|
||||
fontFamily:
|
||||
'ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, "Noto Sans Mono CJK SC", monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
// Without this addon CJK and emoji are measured as single cells and the
|
||||
// whole line drifts out of alignment.
|
||||
allowProposedApi: true,
|
||||
scrollback: SCROLLBACK_LINES,
|
||||
theme: {
|
||||
background: '#101216',
|
||||
foreground: '#d7dae0',
|
||||
cursor: '#4c8dff',
|
||||
selectionBackground: 'rgba(76, 141, 255, 0.3)',
|
||||
},
|
||||
})
|
||||
|
||||
this.terminal.loadAddon(this.fit)
|
||||
this.terminal.loadAddon(new Unicode11Addon())
|
||||
this.terminal.unicode.activeVersion = '11'
|
||||
this.terminal.loadAddon(new WebLinksAddon())
|
||||
|
||||
this.terminal.open(container)
|
||||
}
|
||||
|
||||
attach(port: MessagePort): void {
|
||||
if (this.disposed) {
|
||||
port.close()
|
||||
return
|
||||
}
|
||||
|
||||
this.port = port
|
||||
port.onmessage = (event: MessageEvent<ToRendererFrame>) => this.receive(event.data)
|
||||
|
||||
this.terminal.onData((data) => this.post({ type: 'input', data: new TextEncoder().encode(data) }))
|
||||
this.terminal.onResize(({ cols, rows }) => this.post({ type: 'resize', cols, rows }))
|
||||
|
||||
// Tells main to flush whatever the shell printed before this port existed.
|
||||
this.post({ type: 'ready' })
|
||||
this.fitAndReport()
|
||||
}
|
||||
|
||||
onExit(callback: (code: number | null) => void): void {
|
||||
this.onExitCallback = callback
|
||||
}
|
||||
|
||||
/** Reports whether the transport is up, so the tab can show it. */
|
||||
onStatus(callback: (status: 'connected' | 'reconnecting') => void): void {
|
||||
this.onStatusCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the WebGL renderer only for the visible tab.
|
||||
*
|
||||
* Chromium caps the number of live WebGL contexts (around 16) and silently
|
||||
* evicts the oldest past that, which shows up as a blank or frozen terminal
|
||||
* with no error anywhere. Releasing on deactivate keeps the count at one.
|
||||
*/
|
||||
setActive(active: boolean): void {
|
||||
if (this.disposed) return
|
||||
|
||||
if (active && !this.webgl) {
|
||||
try {
|
||||
const addon = new WebglAddon()
|
||||
addon.onContextLoss(() => {
|
||||
addon.dispose()
|
||||
if (this.webgl === addon) this.webgl = null
|
||||
})
|
||||
this.terminal.loadAddon(addon)
|
||||
this.webgl = addon
|
||||
} catch {
|
||||
// No WebGL available (software rendering, remote X). The DOM renderer
|
||||
// is already active and correct, just slower.
|
||||
}
|
||||
} else if (!active && this.webgl) {
|
||||
this.webgl.dispose()
|
||||
this.webgl = null
|
||||
}
|
||||
|
||||
if (active) this.fitAndReport()
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.terminal.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute cols/rows from the element's size and tell the remote.
|
||||
*
|
||||
* Refuses to measure a hidden element: `FitAddon` would read a zero-sized box
|
||||
* and send a nonsense size, which leaves the remote PTY the wrong shape and
|
||||
* mangles `vim` and `htop` until something resizes again.
|
||||
*/
|
||||
fitAndReport(): void {
|
||||
if (this.disposed) return
|
||||
if (this.container.clientWidth <= 0 || this.container.clientHeight <= 0) return
|
||||
|
||||
try {
|
||||
this.fit.fit()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
this.post({ type: 'resize', cols: this.terminal.cols, rows: this.terminal.rows })
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
|
||||
if (this.ackTimer !== null) clearTimeout(this.ackTimer)
|
||||
this.webgl?.dispose()
|
||||
this.webgl = null
|
||||
this.port?.close()
|
||||
this.port = null
|
||||
this.terminal.dispose()
|
||||
}
|
||||
|
||||
private receive(frame: ToRendererFrame): void {
|
||||
switch (frame.type) {
|
||||
case 'data': {
|
||||
// Bytes, not a string. A UTF-8 character split across two chunks
|
||||
// decodes to garbage if either half is converted on its own, and
|
||||
// xterm only decodes incrementally when handed a Uint8Array.
|
||||
const data: Uint8Array = frame.data
|
||||
this.terminal.write(data, () => {
|
||||
this.pendingAck += data.length
|
||||
this.scheduleAck()
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'exit':
|
||||
this.onExitCallback?.(frame.code)
|
||||
this.terminal.write(
|
||||
`\r\n\x1b[38;5;244m[process exited${frame.code === null ? '' : ` with code ${frame.code}`}]\x1b[0m\r\n`,
|
||||
)
|
||||
break
|
||||
|
||||
case 'error':
|
||||
this.terminal.write(`\r\n\x1b[31m${frame.message}\x1b[0m\r\n`)
|
||||
this.onExitCallback?.(null)
|
||||
break
|
||||
|
||||
// The three reconnect frames are written into the terminal rather than
|
||||
// shown as a banner, because they belong in the scrollback: someone
|
||||
// scrolling up later needs to see where the old shell ended and the new
|
||||
// one began.
|
||||
case 'reconnecting':
|
||||
this.onStatusCallback?.('reconnecting')
|
||||
this.terminal.write(
|
||||
`\r\n\x1b[33m${i18n.t('terminal.reconnecting', {
|
||||
attempt: frame.attempt,
|
||||
seconds: Math.round(frame.delayMs / 1000),
|
||||
})}\x1b[0m\r\n`,
|
||||
)
|
||||
break
|
||||
|
||||
case 'reconnected':
|
||||
this.onStatusCallback?.('connected')
|
||||
this.terminal.write(
|
||||
`\r\n\x1b[32m${i18n.t('terminal.reconnected', { attempt: frame.attempt })}\x1b[0m\r\n`,
|
||||
)
|
||||
break
|
||||
|
||||
case 'disconnected':
|
||||
this.onStatusCallback?.('connected')
|
||||
this.onExitCallback?.(null)
|
||||
this.terminal.write(
|
||||
`\r\n\x1b[31m${i18n.t('terminal.disconnected', { message: frame.message })}\x1b[0m\r\n`,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleAck(): void {
|
||||
if (this.ackTimer !== null) return
|
||||
this.ackTimer = setTimeout(() => {
|
||||
this.ackTimer = null
|
||||
if (this.pendingAck === 0) return
|
||||
const bytes = this.pendingAck
|
||||
this.pendingAck = 0
|
||||
this.post({ type: 'ack', bytes })
|
||||
}, ACK_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private post(frame: ToMainFrame): void {
|
||||
if (!this.port || this.disposed) return
|
||||
try {
|
||||
this.port.postMessage(frame)
|
||||
} catch {
|
||||
// The other end has gone; the exit frame will surface it.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
// xterm's own stylesheet, and it is not optional. Without it `.xterm-viewport`
|
||||
// collapses to zero height: the canvas still draws, so the terminal looks
|
||||
// alive, but the fit addon measures a degenerate box and the remote PTY is
|
||||
// told the wrong size.
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import './i18n'
|
||||
// After xterm's, so the app's overrides win.
|
||||
import './styles.css'
|
||||
import App from './App'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
if (!container) throw new Error('#root not found in index.html')
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
import { create } from 'zustand'
|
||||
import type { HostInput, MoveInput, TreeNode } from '@shared/ipc-schemas'
|
||||
import { allFolderKeys, ancestorKeysOf, folderKey } from '../lib/sidebar'
|
||||
|
||||
/**
|
||||
* Electron prefixes a rejected `invoke` with the channel name, producing
|
||||
* messages like `Error invoking remote method 'node:move': Error: cannot move
|
||||
* node 3 into its own descendant 4`. Users should see the domain error, not the
|
||||
* transport's wrapper around it.
|
||||
*/
|
||||
function ipcErrorMessage(cause: unknown): string {
|
||||
const raw = cause instanceof Error ? cause.message : String(cause)
|
||||
const marker = raw.lastIndexOf('Error: ')
|
||||
return marker >= 0 ? raw.slice(marker + 'Error: '.length) : raw
|
||||
}
|
||||
|
||||
interface ConnectionState {
|
||||
tree: TreeNode[]
|
||||
/**
|
||||
* Row keys that are open, covering folders, hosts and the pinned local entry
|
||||
* alike — one key space, so a single set drives the whole sidebar.
|
||||
*/
|
||||
expanded: Set<string>
|
||||
/** The row key of the selected row, whatever kind it is. */
|
||||
selected: string | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
|
||||
load: () => Promise<void>
|
||||
toggleExpanded: (key: string) => void
|
||||
/** Open a row, without closing it if it is already open. */
|
||||
expand: (key: string) => void
|
||||
expandAll: () => void
|
||||
collapseAll: () => void
|
||||
select: (key: string | null) => void
|
||||
clearError: () => void
|
||||
|
||||
createFolder: (parentId: number | null, name: string) => Promise<void>
|
||||
renameFolder: (id: number, name: string) => Promise<void>
|
||||
createHost: (input: HostInput) => Promise<void>
|
||||
updateHost: (id: number, input: HostInput) => Promise<void>
|
||||
/** Works for folders and hosts alike — they share one id space. */
|
||||
move: (input: MoveInput) => Promise<void>
|
||||
deleteNode: (id: number) => Promise<void>
|
||||
}
|
||||
|
||||
export const useConnectionStore = create<ConnectionState>((set, get) => {
|
||||
/**
|
||||
* Run a mutation and adopt whatever tree it returns.
|
||||
*
|
||||
* Every mutation answers with the whole tree, so there is no partial-update
|
||||
* path here that could drift from the database. Failures are surfaced rather
|
||||
* than swallowed — a refused move should tell the user why nothing happened.
|
||||
*/
|
||||
const mutate = async (run: () => Promise<TreeNode[]>): Promise<void> => {
|
||||
try {
|
||||
const tree = await run()
|
||||
set({ tree, error: null })
|
||||
} catch (cause) {
|
||||
set({ error: ipcErrorMessage(cause) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Expand a folder and everything above it, so a new child is not hidden. */
|
||||
const reveal = (id: number): void => {
|
||||
const key = folderKey(id)
|
||||
const next = new Set(get().expanded)
|
||||
next.add(key)
|
||||
for (const ancestor of ancestorKeysOf(get().tree, key)) next.add(ancestor)
|
||||
set({ expanded: next })
|
||||
}
|
||||
|
||||
return {
|
||||
tree: [],
|
||||
expanded: new Set<string>(),
|
||||
selected: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
load: async () => {
|
||||
set({ loading: true })
|
||||
try {
|
||||
const tree = await window.nexusterm.tree.list()
|
||||
// Folders start open. A connection inventory is small enough that
|
||||
// showing it whole beats making the user click to discover it. Hosts
|
||||
// start collapsed: their session lists are transient and would
|
||||
// otherwise push everything else off screen.
|
||||
set({
|
||||
tree,
|
||||
expanded: new Set(allFolderKeys(tree)),
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
} catch (cause) {
|
||||
set({ loading: false, error: ipcErrorMessage(cause) })
|
||||
}
|
||||
},
|
||||
|
||||
toggleExpanded: (key) => {
|
||||
const next = new Set(get().expanded)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
set({ expanded: next })
|
||||
},
|
||||
|
||||
expand: (key) => {
|
||||
if (get().expanded.has(key)) return
|
||||
const next = new Set(get().expanded)
|
||||
next.add(key)
|
||||
set({ expanded: next })
|
||||
},
|
||||
|
||||
expandAll: () => set({ expanded: new Set(allFolderKeys(get().tree)) }),
|
||||
collapseAll: () => set({ expanded: new Set<string>() }),
|
||||
select: (key) => set({ selected: key }),
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
createFolder: async (parentId, name) => {
|
||||
await mutate(() => window.nexusterm.tree.createFolder({ parentId, name }))
|
||||
if (parentId !== null) reveal(parentId)
|
||||
},
|
||||
|
||||
renameFolder: async (id, name) => {
|
||||
await mutate(() => window.nexusterm.tree.renameFolder({ id, name }))
|
||||
},
|
||||
|
||||
createHost: async (input) => {
|
||||
await mutate(() => window.nexusterm.tree.createHost(input))
|
||||
if (input.parentId !== null) reveal(input.parentId)
|
||||
},
|
||||
|
||||
updateHost: async (id, input) => {
|
||||
await mutate(() => window.nexusterm.tree.updateHost(id, input))
|
||||
},
|
||||
|
||||
move: async (input) => {
|
||||
await mutate(() => window.nexusterm.tree.moveNode(input))
|
||||
},
|
||||
|
||||
deleteNode: async (id) => {
|
||||
await mutate(() => window.nexusterm.tree.deleteNode(id))
|
||||
set({ selected: null })
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,300 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ConnectResult, HostNode } from '@shared/ipc-schemas'
|
||||
import {
|
||||
computeTabMove,
|
||||
nextAutoIndex,
|
||||
seqAfterSession,
|
||||
seqForNewSession,
|
||||
type SidebarSession,
|
||||
type TabDropPosition,
|
||||
} from '../lib/sidebar'
|
||||
|
||||
/** The sidebar's view of a session is the only view there is. */
|
||||
export type SessionTab = SidebarSession
|
||||
|
||||
/**
|
||||
* A question the main process cannot answer on its own.
|
||||
*
|
||||
* Each one corresponds to a connect result that requires a human decision —
|
||||
* and none of them can be resolved by guessing.
|
||||
*/
|
||||
export type PendingPrompt =
|
||||
| {
|
||||
kind: 'host-key'
|
||||
host: HostNode
|
||||
fingerprint: string
|
||||
keyType: string
|
||||
}
|
||||
| {
|
||||
kind: 'password'
|
||||
host: HostNode
|
||||
/** Present after authentication failed, so the dialog can explain why. */
|
||||
failure: string | null
|
||||
}
|
||||
| null
|
||||
|
||||
interface SessionState {
|
||||
tabs: SessionTab[]
|
||||
activeId: string | null
|
||||
prompt: PendingPrompt
|
||||
error: string | null
|
||||
connectingHostId: number | null
|
||||
|
||||
/**
|
||||
* Open a session: `null` for a shell on this machine, a host otherwise.
|
||||
*
|
||||
* With `after`, the new session lands next to that one instead of at the end
|
||||
* of its group — which is the only thing that distinguishes duplicating a tab
|
||||
* from opening a fresh one.
|
||||
*/
|
||||
openSession: (host: HostNode | null, options?: { after?: string }) => Promise<void>
|
||||
answerPrompt: (value: string) => Promise<void>
|
||||
dismissPrompt: () => void
|
||||
|
||||
closeTab: (id: string) => void
|
||||
renameTab: (id: string, name: string) => void
|
||||
togglePinned: (id: string) => void
|
||||
moveTab: (draggedId: string, targetId: string, position: TabDropPosition) => void
|
||||
|
||||
markClosed: (id: string) => void
|
||||
/** Reflect a transport drop or a successful reconnect in the tab. */
|
||||
markStatus: (id: string, status: SessionTab['status']) => void
|
||||
setActive: (id: string | null) => void
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
function describe(cause: unknown): string {
|
||||
const raw = cause instanceof Error ? cause.message : String(cause)
|
||||
const marker = raw.lastIndexOf('Error: ')
|
||||
return marker >= 0 ? raw.slice(marker + 'Error: '.length) : raw
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>((set, get) => {
|
||||
const add = (session: SessionTab): void => {
|
||||
set({ tabs: [...get().tabs, session], activeId: session.id })
|
||||
}
|
||||
|
||||
/** Turn a connect result into state. Shared by the first attempt and retries. */
|
||||
const apply = (host: HostNode | null, result: ConnectResult): void => {
|
||||
switch (result.status) {
|
||||
case 'connected': {
|
||||
const hostId = host?.id ?? null
|
||||
add({
|
||||
id: result.sessionId,
|
||||
hostId,
|
||||
label: host?.label ?? '',
|
||||
// Counted per host, so the second shell on a machine reads as "n5095 2"
|
||||
// and the first keeps the bare name.
|
||||
autoIndex: nextAutoIndex(get().tabs, hostId),
|
||||
customLabel: null,
|
||||
status: 'connected',
|
||||
pinned: false,
|
||||
seq: seqForNewSession(get().tabs, hostId),
|
||||
host,
|
||||
})
|
||||
set({ prompt: null, error: null })
|
||||
return
|
||||
}
|
||||
|
||||
case 'unknown-host-key':
|
||||
if (!host) {
|
||||
set({ error: 'the server asked for host key confirmation with no host to attach it to' })
|
||||
return
|
||||
}
|
||||
set({
|
||||
prompt: {
|
||||
kind: 'host-key',
|
||||
host,
|
||||
fingerprint: result.fingerprint,
|
||||
keyType: result.keyType,
|
||||
},
|
||||
})
|
||||
return
|
||||
|
||||
case 'host-key-mismatch': {
|
||||
// Deliberately not a prompt with a "continue anyway" button. The key
|
||||
// changed, which is either a rebuilt server or an attack, and there is
|
||||
// no way to tell from here. Recovery is an explicit action elsewhere.
|
||||
const where = host ? `${host.hostname}:${host.port}` : result.host
|
||||
set({
|
||||
prompt: null,
|
||||
error:
|
||||
`${where} presented a different host key than the one recorded.\n` +
|
||||
`expected ${result.expected}\n` +
|
||||
`received ${result.actual}\n` +
|
||||
'This can mean the server was rebuilt, or that the connection is being intercepted.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case 'need-password':
|
||||
if (!host) return
|
||||
set({ prompt: { kind: 'password', host, failure: null } })
|
||||
return
|
||||
|
||||
case 'auth-failed':
|
||||
// Offer a password only where one could help. Retrying key auth with a
|
||||
// password field would just fail again.
|
||||
if (host && host.authType === 'password') {
|
||||
set({ prompt: { kind: 'password', host, failure: result.message } })
|
||||
} else {
|
||||
set({ error: result.message })
|
||||
}
|
||||
return
|
||||
|
||||
case 'error':
|
||||
set({ error: result.message })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const run = async (
|
||||
host: HostNode | null,
|
||||
auth: { password?: string; trustFingerprint?: string } = {},
|
||||
): Promise<void> => {
|
||||
set({ connectingHostId: host?.id ?? null, error: null })
|
||||
|
||||
try {
|
||||
if (!host) {
|
||||
// A local shell answers with a different shape; normalise it so
|
||||
// everything downstream sees one kind of result.
|
||||
const local = await window.nexusterm.session.openLocal()
|
||||
apply(
|
||||
null,
|
||||
local.ok
|
||||
? { status: 'connected', sessionId: local.sessionId, fingerprint: '' }
|
||||
: { status: 'error', message: local.message },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
apply(
|
||||
host,
|
||||
await window.nexusterm.session.open({
|
||||
hostId: host.id,
|
||||
password: auth.password ?? null,
|
||||
trustFingerprint: auth.trustFingerprint ?? null,
|
||||
}),
|
||||
)
|
||||
} catch (cause) {
|
||||
set({ error: describe(cause) })
|
||||
} finally {
|
||||
set({ connectingHostId: null })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: [],
|
||||
activeId: null,
|
||||
prompt: null,
|
||||
error: null,
|
||||
connectingHostId: null,
|
||||
|
||||
openSession: async (host, options = {}) => {
|
||||
const before = new Set(get().tabs.map((t) => t.id))
|
||||
await run(host)
|
||||
|
||||
// Placement is applied afterwards because only the main process knows the
|
||||
// new session's id. Without `after` this is a fresh tab and the append
|
||||
// position `apply` chose is already right.
|
||||
if (!options.after) return
|
||||
|
||||
const original = get().tabs.find((t) => t.id === options.after)
|
||||
const created = get().tabs.find((t) => !before.has(t.id))
|
||||
if (!original || !created) return
|
||||
|
||||
// The created tab is excluded from the calculation: it is sitting at the
|
||||
// end of the group at the moment, and including it would make the
|
||||
// midpoint depend on where it is about to stop being.
|
||||
const others = get().tabs.filter((t) => t.id !== created.id)
|
||||
|
||||
set({
|
||||
tabs: get().tabs.map((tab) =>
|
||||
tab.id === created.id
|
||||
? {
|
||||
...tab,
|
||||
// A duplicate lands next to its original, which requires
|
||||
// sharing its pin state — the sort would otherwise drop the
|
||||
// copy at the far end of the other bucket.
|
||||
pinned: original.pinned,
|
||||
seq: seqAfterSession(others, original.id),
|
||||
}
|
||||
: tab,
|
||||
),
|
||||
})
|
||||
},
|
||||
|
||||
answerPrompt: async (value) => {
|
||||
const { prompt } = get()
|
||||
if (!prompt) return
|
||||
|
||||
set({ prompt: null })
|
||||
// Only this exact fingerprint gets trusted, and only because the user was
|
||||
// shown it.
|
||||
await run(
|
||||
prompt.host,
|
||||
prompt.kind === 'host-key' ? { trustFingerprint: value } : { password: value },
|
||||
)
|
||||
},
|
||||
|
||||
dismissPrompt: () => set({ prompt: null }),
|
||||
|
||||
closeTab: (id) => {
|
||||
const tab = get().tabs.find((t) => t.id === id)
|
||||
if (!tab) return
|
||||
|
||||
void window.nexusterm.session.close(id)
|
||||
const tabs = get().tabs.filter((t) => t.id !== id)
|
||||
|
||||
// Falling back to the next session of the same host keeps the user near
|
||||
// where they were; failing that, to whatever is left.
|
||||
const activeId =
|
||||
get().activeId === id
|
||||
? (tabs.find((t) => t.hostId === tab.hostId)?.id ?? tabs[tabs.length - 1]?.id ?? null)
|
||||
: get().activeId
|
||||
|
||||
set({ tabs, activeId })
|
||||
},
|
||||
|
||||
renameTab: (id, name) =>
|
||||
set({
|
||||
tabs: get().tabs.map((tab) =>
|
||||
tab.id === id ? { ...tab, customLabel: name.trim() || null } : tab,
|
||||
),
|
||||
}),
|
||||
|
||||
togglePinned: (id) =>
|
||||
set({
|
||||
tabs: get().tabs.map((tab) =>
|
||||
tab.id === id ? { ...tab, pinned: !tab.pinned } : tab,
|
||||
),
|
||||
}),
|
||||
|
||||
moveTab: (draggedId, targetId, position) => {
|
||||
const move = computeTabMove(get().tabs, draggedId, targetId, position)
|
||||
// Null means the drop is not expressible — across host groups, across pin
|
||||
// states, or back into the slot it came from. The indicator already
|
||||
// refused to appear.
|
||||
if (!move) return
|
||||
|
||||
set({
|
||||
tabs: get().tabs.map((tab) =>
|
||||
tab.id === draggedId ? { ...tab, seq: move.seq } : tab,
|
||||
),
|
||||
})
|
||||
},
|
||||
|
||||
markClosed: (id) =>
|
||||
set({
|
||||
tabs: get().tabs.map((tab) => (tab.id === id ? { ...tab, status: 'closed' } : tab)),
|
||||
}),
|
||||
|
||||
markStatus: (id, status) =>
|
||||
set({
|
||||
tabs: get().tabs.map((tab) => (tab.id === id ? { ...tab, status } : tab)),
|
||||
}),
|
||||
|
||||
setActive: (id) => set({ activeId: id }),
|
||||
clearError: () => set({ error: null }),
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,917 @@
|
||||
:root {
|
||||
--bg: #14161a;
|
||||
--bg-raised: #1a1d23;
|
||||
--bg-inset: #101216;
|
||||
--border: #272b33;
|
||||
--border-strong: #343945;
|
||||
--text: #d7dae0;
|
||||
--text-dim: #8b929e;
|
||||
--text-faint: #5c636e;
|
||||
--accent: #4c8dff;
|
||||
--ok: #3fb950;
|
||||
--warn: #d29922;
|
||||
--danger: #f85149;
|
||||
--danger-text: #ffb4ae;
|
||||
|
||||
/* Wide enough for a host name plus a `user@host:port` string without either
|
||||
being squeezed. The rows carry both deliberately — the name alone does not
|
||||
disambiguate four hosts that all sit behind the same bastion. */
|
||||
--sidebar-width: 320px;
|
||||
--header-height: 40px;
|
||||
--tabbar-height: 36px;
|
||||
--statusbar-height: 24px;
|
||||
--row-height: 24px;
|
||||
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo,
|
||||
Consolas, monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", "Noto Sans CJK SC",
|
||||
"Source Han Sans SC", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* A terminal app should not feel like a web page. Text selection stays
|
||||
available where it matters (terminal output, form fields) via the
|
||||
component-level overrides below. */
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- app shell ---------- */
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-rows: var(--header-height) 1fr var(--statusbar-height);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header__mark {
|
||||
color: var(--accent);
|
||||
font-family: var(--mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header__name {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.header__tagline {
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---------- security banner ---------- */
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.banner--ok {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.banner--danger {
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
border-color: rgba(248, 81, 73, 0.4);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
/* ---------- sidebar ---------- */
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-inset);
|
||||
border-right: 1px solid var(--border);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar__heading {
|
||||
padding: 10px 12px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ---------- connection tree ---------- */
|
||||
|
||||
.tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tree__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px 6px;
|
||||
}
|
||||
|
||||
.tree__toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tree__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
/* Room at the bottom to drop a node back out to the root layer. Without it
|
||||
a deeply nested host has nowhere to be dropped. */
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.tree__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 8px 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 5px;
|
||||
background: rgba(248, 81, 73, 0.12);
|
||||
border: 1px solid rgba(248, 81, 73, 0.35);
|
||||
color: var(--danger-text);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tree__error-dismiss {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- tree row ---------- */
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: var(--row-height);
|
||||
padding-right: 8px;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
|
||||
.row:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.row--selected {
|
||||
background: rgba(76, 141, 255, 0.18);
|
||||
}
|
||||
|
||||
.row--selected:hover {
|
||||
background: rgba(76, 141, 255, 0.24);
|
||||
}
|
||||
|
||||
.row--dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.row__chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
flex: none;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.row__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.row__icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.row__label {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.row__meta {
|
||||
margin-left: auto;
|
||||
/* Shrinks twenty times faster than the label. With equal flex-shrink both
|
||||
compete on content length, and the connection string — which is longer but
|
||||
far less important — wins the space and truncates the host's own name. */
|
||||
flex: 0 20 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Drop indicators. A line for "insert between", a fill for "drop inside" —
|
||||
the two outcomes need to look different, because only one of them nests. */
|
||||
.row--drop-before::before,
|
||||
.row--drop-after::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 1px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.row--drop-before::before {
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.row--drop-after::after {
|
||||
bottom: -1px;
|
||||
}
|
||||
|
||||
.row--drop-inside {
|
||||
background: rgba(76, 141, 255, 0.22);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
/* The pinned local entry. Tinted rather than separated by a rule: a border
|
||||
would sit between it and its own sessions once they are expanded. */
|
||||
.row--local {
|
||||
background: rgba(76, 141, 255, 0.07);
|
||||
}
|
||||
|
||||
.row--local .row__icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* A session hanging under its host. */
|
||||
.row--session {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.row--session .row__label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* The session whose terminal is on screen. */
|
||||
.row--active {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.row--active .row__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.row__dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
}
|
||||
|
||||
.row__dot--connected {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.row__dot--closed {
|
||||
background: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Amber, and pulsing: a reconnect is in progress and the user should be able
|
||||
to tell that from both "up" and "gone" without reading anything. */
|
||||
.row__dot--reconnecting {
|
||||
background: var(--warn);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* Session count on a host or the local entry. */
|
||||
.row__count {
|
||||
flex: none;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-faint);
|
||||
font-size: 10px;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row__badge {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Session counts and pin badges take precedence over the connection string,
|
||||
which truncates first when space runs out. */
|
||||
.row--host .row__meta {
|
||||
flex: 0 20 auto;
|
||||
}
|
||||
|
||||
/* ---------- main pane ---------- */
|
||||
|
||||
/* Flex rather than a fixed-row grid: the session error banner is conditional,
|
||||
and a grid with two declared rows would misplace the panes whenever it is
|
||||
absent. */
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tabbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: none;
|
||||
height: var(--tabbar-height);
|
||||
padding: 0 8px;
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tabbar__empty {
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.terminal-area {
|
||||
flex: 1;
|
||||
background: var(--bg-inset);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- tabs ---------- */
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
max-width: 200px;
|
||||
height: 26px;
|
||||
padding-right: 5px;
|
||||
border-radius: 5px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
cursor: default;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.tab--active {
|
||||
background: var(--bg-inset);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab:focus-visible {
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
/* A stable colour per host, so tabs for the same machine read as a group. */
|
||||
.tab__stripe {
|
||||
width: 3px;
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
.tab__stripe--closed {
|
||||
background: var(--text-faint);
|
||||
}
|
||||
|
||||
.tab__stripe--local {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.tab__stripe--reconnecting {
|
||||
background: var(--warn);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tab__pin {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- tab dragging ---------- */
|
||||
|
||||
.tab--dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* A vertical rule on the edge the tab would land against. Tabs are laid out
|
||||
horizontally, so this is the mirror of the sidebar's horizontal rule. */
|
||||
.tab--drop-before {
|
||||
box-shadow: inset 2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.tab--drop-after {
|
||||
box-shadow: inset -2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
/* The pinned local shell: no stripe (it belongs to no host), slightly narrower,
|
||||
and set apart from the sessions that come and go. */
|
||||
.tab--pinned {
|
||||
max-width: 160px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 20px;
|
||||
align-self: stretch;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tab__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: default;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tab:hover .tab__close,
|
||||
.tab--active .tab__close {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.tab__close:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---------- terminal ---------- */
|
||||
|
||||
.terminal-host {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-slot {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-pane {
|
||||
height: 100%;
|
||||
padding: 4px 0 0 6px;
|
||||
/* The terminal is the one place text selection must work normally, and the
|
||||
app-wide `user-select: none` would otherwise win. */
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.terminal-pane .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---------- session error ---------- */
|
||||
|
||||
.session-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 8px 10px;
|
||||
background: rgba(248, 81, 73, 0.12);
|
||||
border-bottom: 1px solid rgba(248, 81, 73, 0.35);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.session-error__body {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.session-error__dismiss {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- host key dialog ---------- */
|
||||
|
||||
.hostkey__banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(210, 153, 34, 0.35);
|
||||
border-radius: 5px;
|
||||
background: rgba(210, 153, 34, 0.12);
|
||||
color: #e8c46a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hostkey__facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 14px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hostkey__facts dt {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.hostkey__facts dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.hostkey__fingerprint {
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ---------- checkbox ---------- */
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- empty states ---------- */
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.tree__scroll > .empty {
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
.terminal-area > .empty {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty__title {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty__detail {
|
||||
font-size: 12px;
|
||||
max-width: 42ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---------- context menu ---------- */
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
min-width: 168px;
|
||||
padding: 4px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.menu__item:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.menu__item--danger {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.menu__icon {
|
||||
display: inline-flex;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ---------- modal ---------- */
|
||||
|
||||
.modal {
|
||||
min-width: 440px;
|
||||
max-width: 560px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-raised);
|
||||
color: var(--text);
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.modal::backdrop {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.modal__form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.modal__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0 16px 14px;
|
||||
}
|
||||
|
||||
.confirm__body {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ---------- form controls ---------- */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field--grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field--port {
|
||||
width: 92px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.field__label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.field__hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.field__error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
/* Form fields are the one place typing and selection must work normally. */
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
select.input {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--text-faint);
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
filter: brightness(1.12);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn--danger:hover {
|
||||
filter: brightness(1.12);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn--small {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: none;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---------- status bar ---------- */
|
||||
|
||||
.statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-raised);
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-faint);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.statusbar__item {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.statusbar__label {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.statusbar__value {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
AppInfo,
|
||||
ConnectInput,
|
||||
ConnectResult,
|
||||
CredentialDeleteInput,
|
||||
CredentialKind,
|
||||
CredentialSetInput,
|
||||
FolderCreateInput,
|
||||
FolderRenameInput,
|
||||
HostInput,
|
||||
LocalSessionResult,
|
||||
MoveInput,
|
||||
TreeNode,
|
||||
} from './ipc-schemas'
|
||||
|
||||
/**
|
||||
* The surface the renderer can reach, defined once.
|
||||
*
|
||||
* The preload implements this interface and the renderer's `window.nexusterm`
|
||||
* is declared as it, so there is a single source of truth. Writing the shape out
|
||||
* separately in each of the three places is how an API and its type quietly
|
||||
* drift apart until something fails at runtime.
|
||||
*
|
||||
* Two rules hold as this grows:
|
||||
*
|
||||
* 1. Never expose `ipcRenderer`, or a generic `invoke(channel, ...args)`.
|
||||
* Either would hand the renderer the whole main-process API and make this
|
||||
* interface decorative.
|
||||
*
|
||||
* 2. Never expose a way to read a stored secret. The renderer will eventually
|
||||
* get `connect(hostId)` — it asks for a connection, not for a password. A
|
||||
* `getSecret()`-style call would let any renderer-side compromise (XSS, a
|
||||
* poisoned dependency) exfiltrate every saved credential at once.
|
||||
*/
|
||||
export interface NexusTermApi {
|
||||
app: {
|
||||
info(): Promise<AppInfo>
|
||||
}
|
||||
|
||||
tree: {
|
||||
list(): Promise<TreeNode[]>
|
||||
|
||||
createFolder(input: FolderCreateInput): Promise<TreeNode[]>
|
||||
renameFolder(input: FolderRenameInput): Promise<TreeNode[]>
|
||||
|
||||
createHost(input: HostInput): Promise<TreeNode[]>
|
||||
updateHost(id: number, input: HostInput): Promise<TreeNode[]>
|
||||
|
||||
/** Works for folders and hosts alike — they share one id space. */
|
||||
moveNode(input: MoveInput): Promise<TreeNode[]>
|
||||
deleteNode(id: number): Promise<TreeNode[]>
|
||||
}
|
||||
|
||||
session: {
|
||||
/**
|
||||
* Dial a host and open a shell.
|
||||
*
|
||||
* First call returns `unknown-host-key` for a host never seen before; the
|
||||
* caller shows the fingerprint, and the retry passes it back as
|
||||
* `trustFingerprint`. A changed fingerprint returns `host-key-mismatch` and
|
||||
* cannot be bypassed this way — that is the point.
|
||||
*
|
||||
* On success the terminal port arrives separately, via a window message.
|
||||
* See `shared/session-protocol.ts`.
|
||||
*/
|
||||
open(input: ConnectInput): Promise<ConnectResult>
|
||||
/** A shell on this machine. Pinned: the renderer offers no way to close it. */
|
||||
openLocal(): Promise<LocalSessionResult>
|
||||
close(sessionId: string): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Credentials are write-only here.
|
||||
*
|
||||
* `has` answers whether a secret exists without revealing it, so the UI can
|
||||
* offer "use saved password" without the value ever crossing the process
|
||||
* boundary. A read method would let any renderer-side compromise — XSS, a
|
||||
* poisoned dependency — drain every saved credential at once.
|
||||
*/
|
||||
credentials: {
|
||||
set(input: CredentialSetInput): Promise<void>
|
||||
remove(input: CredentialDeleteInput): Promise<void>
|
||||
has(hostId: number, kind: CredentialKind): Promise<boolean>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Channel names for the control plane (request/response over ipcMain.handle).
|
||||
*
|
||||
* This module is deliberately dependency-free: the preload script imports it,
|
||||
* and a sandboxed preload should stay as small as possible. Zod schemas live in
|
||||
* `ipc-schemas.ts` and are imported only by the main process.
|
||||
*/
|
||||
export const CH = {
|
||||
APP_INFO: 'app:info',
|
||||
|
||||
// Every tree mutation returns the full refreshed tree rather than the single
|
||||
// changed row. A move rewrites sort orders, and returning the tree makes it
|
||||
// impossible for the renderer's copy to drift out of sync with the database
|
||||
// — a class of bug that is tedious to reproduce and trivial to avoid at this
|
||||
// scale (a connection inventory is hundreds of rows, not millions).
|
||||
TREE_LIST: 'tree:list',
|
||||
|
||||
FOLDER_CREATE: 'folder:create',
|
||||
FOLDER_RENAME: 'folder:rename',
|
||||
HOST_CREATE: 'host:create',
|
||||
HOST_UPDATE: 'host:update',
|
||||
|
||||
// Folders and hosts share one id space and one ordering, so moving and
|
||||
// deleting are single operations rather than one channel per kind.
|
||||
NODE_MOVE: 'node:move',
|
||||
NODE_DELETE: 'node:delete',
|
||||
|
||||
// Sessions. Terminal bytes do NOT travel on these — they go over a
|
||||
// per-session MessagePort. See `session-protocol.ts`.
|
||||
SESSION_OPEN: 'session:open',
|
||||
SESSION_OPEN_LOCAL: 'session:openLocal',
|
||||
SESSION_CLOSE: 'session:close',
|
||||
|
||||
// Credentials are write-only from the renderer's side. There is deliberately
|
||||
// no channel that returns a stored secret; see `shared/api.ts`.
|
||||
CREDENTIAL_SET: 'credential:set',
|
||||
CREDENTIAL_DELETE: 'credential:delete',
|
||||
CREDENTIAL_HAS: 'credential:has',
|
||||
} as const
|
||||
|
||||
export type Channel = (typeof CH)[keyof typeof CH]
|
||||
@@ -0,0 +1,227 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Zod schemas for every IPC payload.
|
||||
*
|
||||
* The renderer is treated as untrusted input: if it is ever compromised, the
|
||||
* main process must not act on malformed or unexpected messages. Every handler
|
||||
* validates its arguments against a schema from this module before doing work.
|
||||
*
|
||||
* Types are derived from the schemas so the schema stays the single source of
|
||||
* truth. Renderer and preload import these with `import type`, which erases at
|
||||
* compile time and keeps zod out of their bundles.
|
||||
*/
|
||||
|
||||
/** How the OS-level keyring is holding up. See `main/security/safeStorageStatus.ts`. */
|
||||
export const safeStorageStatusSchema = z.object({
|
||||
/** `safeStorage.isEncryptionAvailable()` — the OS keyring responded. */
|
||||
available: z.boolean(),
|
||||
/**
|
||||
* Linux only: which backend Electron actually selected. `null` on macOS and
|
||||
* Windows, where the API does not exist.
|
||||
*/
|
||||
backend: z.string().nullable(),
|
||||
/**
|
||||
* The value that actually matters. On Linux, Electron silently falls back to
|
||||
* a `basic_text` backend that "encrypts" with a hardcoded key — the database
|
||||
* looks encrypted and is not. Anything less than a real keyring is not secure.
|
||||
*/
|
||||
secure: z.boolean(),
|
||||
/** Human-readable explanation when `secure` is false. Translated in the UI. */
|
||||
reason: z
|
||||
.enum(['ok', 'not-ready', 'unavailable', 'basic-text', 'unknown-backend'])
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
/** Whether secrets can actually be stored and read on this machine. */
|
||||
export const vaultStatusSchema = z.discriminatedUnion('state', [
|
||||
z.object({
|
||||
state: z.literal('unlocked'),
|
||||
backend: z.string().nullable(),
|
||||
/** True on the run that generated the master key — i.e. a fresh install. */
|
||||
createdNow: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
state: z.literal('unavailable'),
|
||||
reason: z.enum(['insecure-keyring', 'seal-failed', 'unseal-failed']),
|
||||
/** Diagnosis, not a user-facing message. Never contains key material. */
|
||||
detail: z.string(),
|
||||
}),
|
||||
])
|
||||
|
||||
export const appInfoSchema = z.object({
|
||||
appVersion: z.string(),
|
||||
electron: z.string(),
|
||||
chrome: z.string(),
|
||||
node: z.string(),
|
||||
platform: z.string(),
|
||||
safeStorage: safeStorageStatusSchema,
|
||||
vault: vaultStatusSchema,
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------ the tree
|
||||
|
||||
export const authTypeSchema = z.enum(['password', 'key', 'agent'])
|
||||
|
||||
const idSchema = z.number().int().positive()
|
||||
const nullableIdSchema = z.number().int().positive().nullable()
|
||||
|
||||
/**
|
||||
* Host fields as the UI submits them.
|
||||
*
|
||||
* Bounds are enforced here rather than only in the UI, because the renderer is
|
||||
* not the only possible caller and a 4GB hostname should not reach SQLite.
|
||||
*/
|
||||
export const hostInputSchema = z.object({
|
||||
parentId: nullableIdSchema,
|
||||
label: z.string().trim().min(1).max(200),
|
||||
hostname: z.string().trim().min(1).max(255),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
username: z.string().trim().min(1).max(200),
|
||||
authType: authTypeSchema,
|
||||
keyPath: z.string().max(4096).nullable(),
|
||||
color: z.string().max(32).nullable(),
|
||||
})
|
||||
|
||||
export const folderCreateInputSchema = z.object({
|
||||
parentId: nullableIdSchema,
|
||||
name: z.string().trim().min(1).max(200),
|
||||
})
|
||||
|
||||
export const folderRenameInputSchema = z.object({
|
||||
id: idSchema,
|
||||
name: z.string().trim().min(1).max(200),
|
||||
})
|
||||
|
||||
/**
|
||||
* A move expressed as "place X between these two siblings".
|
||||
*
|
||||
* Sibling ids rather than a target index: the fractional `sort_order` scheme
|
||||
* needs the neighbours' actual orders, and the renderer already knows which
|
||||
* rows it drew above and below the drop point. Sending an index would require
|
||||
* the main process to re-derive the same information from a possibly stale
|
||||
* ordering.
|
||||
*/
|
||||
export const moveInputSchema = z.object({
|
||||
id: idSchema,
|
||||
/** Destination folder, or null for the root layer. */
|
||||
parentId: nullableIdSchema,
|
||||
prevSiblingId: nullableIdSchema,
|
||||
nextSiblingId: nullableIdSchema,
|
||||
})
|
||||
|
||||
export const idInputSchema = z.object({ id: idSchema })
|
||||
|
||||
export const hostUpdateInputSchema = z.object({
|
||||
id: idSchema,
|
||||
input: hostInputSchema,
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------ read model
|
||||
|
||||
// ---------------------------------------------------------------- sessions
|
||||
|
||||
export const connectInputSchema = z.object({
|
||||
hostId: idSchema,
|
||||
/**
|
||||
* Set on the retry after the user has been shown a fingerprint and accepted
|
||||
* it. The first attempt never carries it, so an unrecognised host key can
|
||||
* never be trusted implicitly.
|
||||
*/
|
||||
trustFingerprint: z.string().max(200).nullish(),
|
||||
/** Supplied only when the user types one; otherwise the vault is consulted. */
|
||||
password: z.string().max(4096).nullish(),
|
||||
})
|
||||
|
||||
export const connectResultSchema = z.discriminatedUnion('status', [
|
||||
z.object({
|
||||
status: z.literal('connected'),
|
||||
sessionId: z.string(),
|
||||
fingerprint: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal('unknown-host-key'),
|
||||
fingerprint: z.string(),
|
||||
keyType: z.string(),
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal('host-key-mismatch'),
|
||||
expected: z.string(),
|
||||
actual: z.string(),
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
}),
|
||||
z.object({ status: z.literal('need-password') }),
|
||||
z.object({ status: z.literal('auth-failed'), message: z.string() }),
|
||||
z.object({ status: z.literal('error'), message: z.string() }),
|
||||
])
|
||||
|
||||
export const credentialKindSchema = z.enum(['password', 'passphrase'])
|
||||
|
||||
/** Write-only. There is deliberately no counterpart that reads a secret back. */
|
||||
export const credentialSetInputSchema = z.object({
|
||||
hostId: idSchema,
|
||||
kind: credentialKindSchema,
|
||||
secret: z.string().min(1).max(4096),
|
||||
})
|
||||
|
||||
/** Identifies a credential without carrying one. */
|
||||
export const credentialRefInputSchema = z.object({
|
||||
hostId: idSchema,
|
||||
kind: credentialKindSchema,
|
||||
})
|
||||
|
||||
export const credentialDeleteInputSchema = credentialRefInputSchema
|
||||
|
||||
export const sessionCloseInputSchema = z.object({
|
||||
sessionId: z.string().min(1).max(80),
|
||||
})
|
||||
|
||||
/** A local shell either starts or it does not; there is no prompt to answer. */
|
||||
export const localSessionResultSchema = z.discriminatedUnion('ok', [
|
||||
z.object({ ok: z.literal(true), sessionId: z.string() }),
|
||||
z.object({ ok: z.literal(false), message: z.string() }),
|
||||
])
|
||||
|
||||
export type LocalSessionResult = z.infer<typeof localSessionResultSchema>
|
||||
|
||||
export type ConnectInput = z.infer<typeof connectInputSchema>
|
||||
export type ConnectResult = z.infer<typeof connectResultSchema>
|
||||
export type CredentialKind = z.infer<typeof credentialKindSchema>
|
||||
export type CredentialSetInput = z.infer<typeof credentialSetInputSchema>
|
||||
export type CredentialDeleteInput = z.infer<typeof credentialDeleteInputSchema>
|
||||
|
||||
export interface FolderNode {
|
||||
kind: 'folder'
|
||||
id: number
|
||||
parentId: number | null
|
||||
name: string
|
||||
sortOrder: number
|
||||
children: TreeNode[]
|
||||
}
|
||||
|
||||
export interface HostNode {
|
||||
kind: 'host'
|
||||
id: number
|
||||
parentId: number | null
|
||||
label: string
|
||||
hostname: string
|
||||
port: number
|
||||
username: string
|
||||
authType: z.infer<typeof authTypeSchema>
|
||||
color: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type TreeNode = FolderNode | HostNode
|
||||
|
||||
export type SafeStorageStatus = z.infer<typeof safeStorageStatusSchema>
|
||||
export type VaultStatus = z.infer<typeof vaultStatusSchema>
|
||||
export type AppInfo = z.infer<typeof appInfoSchema>
|
||||
export type HostInput = z.infer<typeof hostInputSchema>
|
||||
export type FolderCreateInput = z.infer<typeof folderCreateInputSchema>
|
||||
export type FolderRenameInput = z.infer<typeof folderRenameInputSchema>
|
||||
export type MoveInput = z.infer<typeof moveInputSchema>
|
||||
export type AuthType = z.infer<typeof authTypeSchema>
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Frames exchanged over a session's MessagePort.
|
||||
*
|
||||
* Terminal bytes travel on a port dedicated to one session rather than the
|
||||
* shared IPC channel. On a shared channel every session's output is serialised
|
||||
* through the same queue, so one session producing a torrent — `cat` of a large
|
||||
* file, `yes` — delays input and output for all the others. A port per session
|
||||
* removes that coupling entirely.
|
||||
*/
|
||||
export type ToMainFrame =
|
||||
/** Keystrokes from the terminal. */
|
||||
| { type: 'input'; data: Uint8Array }
|
||||
/** The viewport changed size; the remote PTY must be told. */
|
||||
| { type: 'resize'; cols: number; rows: number }
|
||||
/** The renderer has drawn N bytes and can accept more. */
|
||||
| { type: 'ack'; bytes: number }
|
||||
/** The port is attached and the buffered banner may be flushed. */
|
||||
| { type: 'ready' }
|
||||
|
||||
export type ToRendererFrame =
|
||||
| { type: 'data'; data: Uint8Array }
|
||||
| { type: 'exit'; code: number | null; signal: string | null }
|
||||
| { type: 'error'; message: string }
|
||||
/** The transport dropped on its own. A retry is scheduled. */
|
||||
| { type: 'reconnecting'; attempt: number; delayMs: number }
|
||||
/**
|
||||
* A new transport is up. The old shell is gone — a fresh one is starting, and
|
||||
* the accumulated scrollback above this line belongs to the previous one.
|
||||
*/
|
||||
| { type: 'reconnected'; attempt: number }
|
||||
/** Retries are exhausted, or the failure is not one a retry can fix. */
|
||||
| { type: 'disconnected'; message: string }
|
||||
|
||||
/**
|
||||
* Credit-based flow control.
|
||||
*
|
||||
* Without it, `cat` of a 500MB file queues the whole thing in the renderer and
|
||||
* the UI dies — this is a certainty, not a risk. The remote stream is paused
|
||||
* once this much output is outstanding and resumed when the renderer catches
|
||||
* up.
|
||||
*
|
||||
* The gap between the two marks is deliberate: resuming the instant the
|
||||
* watermark is touched produces a pause/resume oscillation on every chunk.
|
||||
*/
|
||||
export const FLOW_CONTROL = {
|
||||
/** Pause the remote stream above this many unacknowledged bytes. */
|
||||
highWater: 1 << 20, // 1 MiB
|
||||
/** Resume once outstanding bytes fall back below this. */
|
||||
lowWater: 1 << 18, // 256 KiB
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Channel used twice during the port handshake: the main process posts to it,
|
||||
* the preload listens on it, and the preload then reposts to the main world
|
||||
* under the same name.
|
||||
*
|
||||
* The relay is necessary because `contextBridge` cannot carry a `MessagePort`
|
||||
* (electron/electron#27024) — a port passed through it arrives unusable. The
|
||||
* documented way around it is `window.postMessage(..., ports)`, which does
|
||||
* transfer properly.
|
||||
*/
|
||||
/**
|
||||
* Reconnect backoff.
|
||||
*
|
||||
* Starts short so a momentary blip is invisible, and climbs to a ceiling so a
|
||||
* machine that is genuinely gone does not get hammered — or worse, keep a
|
||||
* laptop's radio awake. The jitter matters because this is precisely the case
|
||||
* where several sessions drop at once: without it they would all retry in
|
||||
* lockstep, and the server would see a synchronised stampede each round.
|
||||
*/
|
||||
export const RECONNECT = {
|
||||
initialDelayMs: 1_000,
|
||||
maxDelayMs: 30_000,
|
||||
/** Attempts before giving up and reporting the session as lost. */
|
||||
maxAttempts: 10,
|
||||
/** Fraction of the delay randomised, either side of the nominal. */
|
||||
jitter: 0.25,
|
||||
} as const
|
||||
|
||||
/** The delay before attempt `n` (1-based), with jitter applied. */
|
||||
export function reconnectDelay(attempt: number): number {
|
||||
const base = Math.min(
|
||||
RECONNECT.maxDelayMs,
|
||||
RECONNECT.initialDelayMs * 2 ** Math.max(0, attempt - 1),
|
||||
)
|
||||
const spread = base * RECONNECT.jitter
|
||||
return Math.round(base - spread + Math.random() * spread * 2)
|
||||
}
|
||||
|
||||
export const SESSION_PORT_CHANNEL = 'nexusterm:session-port' as const
|
||||
|
||||
export interface PortHandshake {
|
||||
sessionId: string
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createFolder,
|
||||
createHost,
|
||||
deleteNode,
|
||||
getConnectionTree,
|
||||
getHost,
|
||||
isDescendantOf,
|
||||
listHosts,
|
||||
moveNode,
|
||||
renameFolder,
|
||||
updateHost,
|
||||
} from '../src/main/db/repositories/connections'
|
||||
import type { HostInput } from '../src/shared/ipc-schemas'
|
||||
import { createTempDb, type TempDb } from './helpers'
|
||||
|
||||
let temp: TempDb
|
||||
|
||||
beforeEach(() => {
|
||||
temp = createTempDb()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
temp.cleanup()
|
||||
})
|
||||
|
||||
function hostInput(overrides: Partial<HostInput> = {}): HostInput {
|
||||
return {
|
||||
parentId: null,
|
||||
label: 'web-01',
|
||||
hostname: '10.0.1.5',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
keyPath: null,
|
||||
color: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function addHost(parentId: number | null, label: string): number {
|
||||
return createHost(temp.db, hostInput({ parentId, label }))
|
||||
}
|
||||
|
||||
/** Labels of a layer, in tree order, for compact assertions. */
|
||||
function layerNames(nodes: ReturnType<typeof getConnectionTree>): string[] {
|
||||
return nodes.map((n) => (n.kind === 'folder' ? n.name : n.label))
|
||||
}
|
||||
|
||||
describe('creating nodes', () => {
|
||||
it('creates top-level folders and nests them', () => {
|
||||
const prod = createFolder(temp.db, null, 'prod')
|
||||
const web = createFolder(temp.db, prod, 'web')
|
||||
|
||||
const tree = getConnectionTree(temp.db)
|
||||
expect(layerNames(tree)).toEqual(['prod'])
|
||||
|
||||
const root = tree[0]
|
||||
expect(root.kind).toBe('folder')
|
||||
if (root.kind === 'folder') {
|
||||
expect(root.id).toBe(prod)
|
||||
expect(layerNames(root.children)).toEqual(['web'])
|
||||
expect(root.children[0].id).toBe(web)
|
||||
}
|
||||
})
|
||||
|
||||
it('gives folders and hosts ids from one shared sequence', () => {
|
||||
// The point of the interleaved tree: a folder and a host can never collide
|
||||
// on an id, so a node id is a complete identity.
|
||||
const folder = createFolder(temp.db, null, 'f')
|
||||
const host = addHost(null, 'h')
|
||||
|
||||
expect(folder).not.toBe(host)
|
||||
expect(new Set([folder, host]).size).toBe(2)
|
||||
})
|
||||
|
||||
it('stores host attributes', () => {
|
||||
const id = createHost(
|
||||
temp.db,
|
||||
hostInput({ label: 'db', hostname: '10.0.2.10', port: 5432, username: 'postgres' }),
|
||||
)
|
||||
|
||||
const host = getHost(temp.db, id)
|
||||
expect(host).toMatchObject({
|
||||
label: 'db',
|
||||
hostname: '10.0.2.10',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
parentId: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('appends new nodes to the end of their layer', () => {
|
||||
const first = addHost(null, 'first')
|
||||
const second = addHost(null, 'second')
|
||||
|
||||
expect(layerNames(getConnectionTree(temp.db))).toEqual(['first', 'second'])
|
||||
expect(getHost(temp.db, second)?.sortOrder).toBeGreaterThan(
|
||||
getHost(temp.db, first)?.sortOrder ?? 0,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('interleaving folders and hosts', () => {
|
||||
it('renders them in one order rather than grouping by kind', () => {
|
||||
// The whole point of the schema change. Before it, every folder came before
|
||||
// every host, because the two sort_order sequences were not comparable.
|
||||
const folder = createFolder(temp.db, null, 'folder')
|
||||
const host = addHost(null, 'host')
|
||||
|
||||
// Drop the host ahead of the folder.
|
||||
moveNode(temp.db, host, null, null, folder)
|
||||
|
||||
expect(layerNames(getConnectionTree(temp.db))).toEqual(['host', 'folder'])
|
||||
})
|
||||
|
||||
it('allows a host between two folders', () => {
|
||||
// Not expressible at all before the interleaving — the two sequences had no
|
||||
// common scale.
|
||||
const a = createFolder(temp.db, null, 'a')
|
||||
const b = createFolder(temp.db, null, 'b')
|
||||
const host = addHost(null, 'middle')
|
||||
|
||||
moveNode(temp.db, host, null, a, b)
|
||||
|
||||
expect(layerNames(getConnectionTree(temp.db))).toEqual(['a', 'middle', 'b'])
|
||||
})
|
||||
|
||||
it('allows a folder between two hosts', () => {
|
||||
const h1 = addHost(null, 'h1')
|
||||
const h2 = addHost(null, 'h2')
|
||||
const folder = createFolder(temp.db, null, 'middle')
|
||||
|
||||
moveNode(temp.db, folder, null, h1, h2)
|
||||
|
||||
expect(layerNames(getConnectionTree(temp.db))).toEqual(['h1', 'middle', 'h2'])
|
||||
})
|
||||
|
||||
it('keeps a layer strictly ordered through many midpoint inserts', () => {
|
||||
const anchorLeft = createFolder(temp.db, null, 'left')
|
||||
const anchorRight = createFolder(temp.db, null, 'right')
|
||||
|
||||
// Each insert goes into the gap immediately right of the previous one, so
|
||||
// the interval halves every time — the precision-exhaustion scenario the
|
||||
// REAL ordering trades for never renumbering siblings.
|
||||
let previous = anchorLeft
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const id = addHost(null, `mid-${i}`)
|
||||
moveNode(temp.db, id, null, previous, anchorRight)
|
||||
previous = id
|
||||
}
|
||||
|
||||
const orders = getConnectionTree(temp.db).map((n) => n.sortOrder)
|
||||
expect(orders).toHaveLength(32)
|
||||
expect(new Set(orders).size).toBe(orders.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moving nodes', () => {
|
||||
it('reparents a host into a folder', () => {
|
||||
const folder = createFolder(temp.db, null, 'group')
|
||||
const host = addHost(null, 'roamer')
|
||||
|
||||
moveNode(temp.db, host, folder)
|
||||
|
||||
const tree = getConnectionTree(temp.db)
|
||||
expect(tree).toHaveLength(1)
|
||||
if (tree[0].kind === 'folder') {
|
||||
expect(layerNames(tree[0].children)).toEqual(['roamer'])
|
||||
}
|
||||
})
|
||||
|
||||
it('moves a node back out to the root layer', () => {
|
||||
const folder = createFolder(temp.db, null, 'group')
|
||||
const host = createHost(temp.db, hostInput({ parentId: folder, label: 'inside' }))
|
||||
|
||||
moveNode(temp.db, host, null)
|
||||
|
||||
expect(layerNames(getConnectionTree(temp.db))).toEqual(['group', 'inside'])
|
||||
})
|
||||
|
||||
it('refuses to make a node its own parent', () => {
|
||||
const folder = createFolder(temp.db, null, 'f')
|
||||
expect(() => moveNode(temp.db, folder, folder)).toThrow(/own parent/)
|
||||
})
|
||||
|
||||
it('refuses to move a folder into its own descendant', () => {
|
||||
// Without this the subtree detaches from every root and disappears from the
|
||||
// UI while its rows remain.
|
||||
const a = createFolder(temp.db, null, 'a')
|
||||
const b = createFolder(temp.db, a, 'b')
|
||||
const c = createFolder(temp.db, b, 'c')
|
||||
|
||||
expect(() => moveNode(temp.db, a, b)).toThrow(/descendant/)
|
||||
expect(() => moveNode(temp.db, a, c)).toThrow(/descendant/)
|
||||
})
|
||||
|
||||
it('identifies descendants correctly', () => {
|
||||
const a = createFolder(temp.db, null, 'a')
|
||||
const b = createFolder(temp.db, a, 'b')
|
||||
const unrelated = createFolder(temp.db, null, 'unrelated')
|
||||
|
||||
expect(isDescendantOf(temp.db, b, a)).toBe(true)
|
||||
expect(isDescendantOf(temp.db, a, b)).toBe(false)
|
||||
expect(isDescendantOf(temp.db, unrelated, a)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updating and deleting', () => {
|
||||
it('renames a folder', () => {
|
||||
const id = createFolder(temp.db, null, 'old')
|
||||
renameFolder(temp.db, id, 'new')
|
||||
|
||||
expect(layerNames(getConnectionTree(temp.db))).toEqual(['new'])
|
||||
})
|
||||
|
||||
it('updates a host without moving it', () => {
|
||||
const folder = createFolder(temp.db, null, 'group')
|
||||
const host = createHost(temp.db, hostInput({ parentId: folder, label: 'before' }))
|
||||
const sortBefore = getHost(temp.db, host)?.sortOrder
|
||||
|
||||
updateHost(temp.db, host, hostInput({ parentId: folder, label: 'after', port: 2222 }))
|
||||
|
||||
const record = getHost(temp.db, host)
|
||||
expect(record?.label).toBe('after')
|
||||
expect(record?.port).toBe(2222)
|
||||
// An edit must not disturb the node's position in the tree.
|
||||
expect(record?.sortOrder).toBe(sortBefore)
|
||||
})
|
||||
|
||||
it('deletes a folder along with everything beneath it', () => {
|
||||
const a = createFolder(temp.db, null, 'a')
|
||||
const b = createFolder(temp.db, a, 'b')
|
||||
createHost(temp.db, hostInput({ parentId: b, label: 'deep' }))
|
||||
addHost(null, 'unrelated')
|
||||
|
||||
deleteNode(temp.db, a)
|
||||
|
||||
expect(layerNames(getConnectionTree(temp.db))).toEqual(['unrelated'])
|
||||
expect(listHosts(temp.db)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('deletes a host on its own', () => {
|
||||
const folder = createFolder(temp.db, null, 'group')
|
||||
const host = createHost(temp.db, hostInput({ parentId: folder, label: 'gone' }))
|
||||
|
||||
deleteNode(temp.db, host)
|
||||
|
||||
const tree = getConnectionTree(temp.db)
|
||||
expect(tree).toHaveLength(1)
|
||||
if (tree[0].kind === 'folder') {
|
||||
expect(tree[0].children).toHaveLength(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* SQLite reports an UPDATE or DELETE that matched no rows as a successful
|
||||
* operation. Left unchecked, that turns every one of these into a silent
|
||||
* no-op — and the UI, which trusts the promise, reports success for a change
|
||||
* that never happened.
|
||||
*/
|
||||
describe('guards against missing rows', () => {
|
||||
const MISSING = 999_999
|
||||
|
||||
it('renameFolder throws for an unknown id', () => {
|
||||
expect(() => renameFolder(temp.db, MISSING, 'x')).toThrow(/not found/)
|
||||
})
|
||||
|
||||
it('moveNode throws for an unknown id', () => {
|
||||
expect(() => moveNode(temp.db, MISSING, null)).toThrow(/not found/)
|
||||
})
|
||||
|
||||
it('deleteNode throws for an unknown id', () => {
|
||||
expect(() => deleteNode(temp.db, MISSING)).toThrow(/not found/)
|
||||
})
|
||||
|
||||
it('updateHost throws for an unknown id', () => {
|
||||
expect(() => updateHost(temp.db, MISSING, hostInput())).toThrow(/not found/)
|
||||
})
|
||||
|
||||
it('updateHost throws when handed a folder id', () => {
|
||||
// With one id space, a folder id is a syntactically valid node id — the
|
||||
// separate `hosts` table is what makes it fail rather than silently
|
||||
// editing nothing.
|
||||
const folder = createFolder(temp.db, null, 'not-a-host')
|
||||
expect(() => updateHost(temp.db, folder, hostInput())).toThrow(/not found/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reading', () => {
|
||||
it('returns an empty tree for an empty inventory', () => {
|
||||
expect(getConnectionTree(temp.db)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns null for a folder id passed to getHost', () => {
|
||||
const folder = createFolder(temp.db, null, 'f')
|
||||
expect(getHost(temp.db, folder)).toBeNull()
|
||||
})
|
||||
|
||||
it('lists every host with its parent', () => {
|
||||
const folder = createFolder(temp.db, null, 'group')
|
||||
createHost(temp.db, hostInput({ parentId: folder, label: 'nested' }))
|
||||
addHost(null, 'rooted')
|
||||
|
||||
const hosts = listHosts(temp.db)
|
||||
expect(hosts).toHaveLength(2)
|
||||
expect(hosts.find((h) => h.label === 'nested')?.parentId).toBe(folder)
|
||||
expect(hosts.find((h) => h.label === 'rooted')?.parentId).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { Db } from '../src/main/db/database'
|
||||
import {
|
||||
deleteCredential,
|
||||
getCredential,
|
||||
hasCredential,
|
||||
setCredential,
|
||||
} from '../src/main/db/repositories/credentials'
|
||||
import { createHost } from '../src/main/db/repositories/connections'
|
||||
import { Vault } from '../src/main/security/vault'
|
||||
import { createTempDb, fakeKeyring, insecureKeyring, type TempDb } from './helpers'
|
||||
|
||||
let temp: TempDb
|
||||
let vault: Vault
|
||||
let hostId: number
|
||||
|
||||
beforeEach(() => {
|
||||
temp = createTempDb()
|
||||
vault = new Vault(temp.db, fakeKeyring())
|
||||
vault.initialize()
|
||||
hostId = createHost(temp.db, {
|
||||
parentId: null,
|
||||
label: 'web-01',
|
||||
hostname: '10.0.1.5',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
keyPath: null,
|
||||
color: null,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
temp.cleanup()
|
||||
})
|
||||
|
||||
describe('credentials repository', () => {
|
||||
it('round-trips a password', () => {
|
||||
setCredential(temp.db, vault, hostId, 'password', 'hunter2')
|
||||
expect(getCredential(temp.db, vault, hostId, 'password')).toBe('hunter2')
|
||||
})
|
||||
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(getCredential(temp.db, vault, hostId, 'password')).toBeNull()
|
||||
expect(hasCredential(temp.db, hostId, 'password')).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces rather than duplicates on a second write', () => {
|
||||
setCredential(temp.db, vault, hostId, 'password', 'first')
|
||||
setCredential(temp.db, vault, hostId, 'password', 'second')
|
||||
|
||||
const count = temp.db
|
||||
.prepare('SELECT COUNT(*) AS n FROM credentials WHERE host_id = ?')
|
||||
.get(hostId) as { n: number }
|
||||
|
||||
expect(count.n).toBe(1)
|
||||
expect(getCredential(temp.db, vault, hostId, 'password')).toBe('second')
|
||||
})
|
||||
|
||||
it('keeps password and passphrase independent', () => {
|
||||
setCredential(temp.db, vault, hostId, 'password', 'login-pw')
|
||||
setCredential(temp.db, vault, hostId, 'passphrase', 'key-phrase')
|
||||
|
||||
expect(getCredential(temp.db, vault, hostId, 'password')).toBe('login-pw')
|
||||
expect(getCredential(temp.db, vault, hostId, 'passphrase')).toBe('key-phrase')
|
||||
})
|
||||
|
||||
it('deletes a credential and leaves no row', () => {
|
||||
setCredential(temp.db, vault, hostId, 'password', 'hunter2')
|
||||
deleteCredential(temp.db, hostId, 'password')
|
||||
|
||||
expect(hasCredential(temp.db, hostId, 'password')).toBe(false)
|
||||
expect(getCredential(temp.db, vault, hostId, 'password')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes credentials when the host is deleted', () => {
|
||||
setCredential(temp.db, vault, hostId, 'password', 'hunter2')
|
||||
temp.db.prepare('DELETE FROM hosts WHERE id = ?').run(hostId)
|
||||
|
||||
const count = temp.db.prepare('SELECT COUNT(*) AS n FROM credentials').get() as { n: number }
|
||||
expect(count.n).toBe(0)
|
||||
})
|
||||
|
||||
it('throws instead of storing plaintext when the vault is locked', () => {
|
||||
vault.lock()
|
||||
|
||||
// A caller that believes it saved a password must not be told otherwise.
|
||||
expect(() => setCredential(temp.db, vault, hostId, 'password', 'hunter2')).toThrow()
|
||||
})
|
||||
|
||||
it('stores no plaintext anywhere in the database file', () => {
|
||||
// The assertion that matters. Checking the column would only prove the
|
||||
// column is bytes; reading the file proves the secret did not land in a WAL
|
||||
// frame, an index, or anywhere else SQLite decided to put it.
|
||||
const secret = 'correct-horse-battery-staple'
|
||||
setCredential(temp.db, vault, hostId, 'password', secret)
|
||||
|
||||
// Fold the WAL back into the main file so a single read is conclusive.
|
||||
temp.db.pragma('wal_checkpoint(TRUNCATE)')
|
||||
|
||||
const bytes = readFileSync(temp.path)
|
||||
expect(bytes.includes(Buffer.from(secret, 'utf8'))).toBe(false)
|
||||
// Guard against the assertion passing for the wrong reason — e.g. the write
|
||||
// silently not happening at all.
|
||||
expect(bytes.includes(Buffer.from('credentials', 'utf8'))).toBe(true)
|
||||
})
|
||||
|
||||
it('stores no plaintext for non-ASCII secrets either', () => {
|
||||
const secret = '密码-🔐-pass'
|
||||
setCredential(temp.db, vault, hostId, 'password', secret)
|
||||
temp.db.pragma('wal_checkpoint(TRUNCATE)')
|
||||
|
||||
const bytes = readFileSync(temp.path)
|
||||
expect(bytes.includes(Buffer.from(secret, 'utf8'))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to read a credential written under a different master key', () => {
|
||||
setCredential(temp.db, vault, hostId, 'password', 'hunter2')
|
||||
|
||||
// Simulates the keyring having changed: a new vault generates a new key,
|
||||
// and the old ciphertext must fail authentication rather than decrypt to
|
||||
// garbage that gets sent to a server as a password.
|
||||
const otherVault = new Vault(temp.db, insecureKeyring())
|
||||
expect(otherVault.isUnlocked).toBe(false)
|
||||
expect(() => otherVault.initialize()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('credentials with an insecure keyring', () => {
|
||||
it('cannot be written at all', () => {
|
||||
const lockedDb: Db = createTempDb().db
|
||||
const insecureVault = new Vault(lockedDb, insecureKeyring())
|
||||
insecureVault.initialize()
|
||||
|
||||
const id = createHost(lockedDb, {
|
||||
parentId: null,
|
||||
label: 'web-02',
|
||||
hostname: '10.0.1.6',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
keyPath: null,
|
||||
color: null,
|
||||
})
|
||||
|
||||
expect(() => setCredential(lockedDb, insecureVault, id, 'password', 'hunter2')).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { openDatabase, closeDatabase } from '../src/main/db/database'
|
||||
import { MIGRATIONS } from '../src/main/db/migrations'
|
||||
import { createFolder, createHost } from '../src/main/db/repositories/connections'
|
||||
import { createTempDb, type TempDb } from './helpers'
|
||||
|
||||
let temp: TempDb | null = null
|
||||
|
||||
afterEach(() => {
|
||||
temp?.cleanup()
|
||||
temp = null
|
||||
})
|
||||
|
||||
const HIGHEST_VERSION = Math.max(...MIGRATIONS.map((m) => m.version))
|
||||
|
||||
describe('database', () => {
|
||||
it('applies every migration on first open', () => {
|
||||
temp = createTempDb()
|
||||
expect(temp.db.pragma('user_version', { simple: true })).toBe(HIGHEST_VERSION)
|
||||
})
|
||||
|
||||
it('creates the expected tables', () => {
|
||||
temp = createTempDb()
|
||||
const rows = temp.db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.all() as { name: string }[]
|
||||
const tables = rows.map((r) => r.name)
|
||||
|
||||
for (const table of [
|
||||
'credentials',
|
||||
'folders',
|
||||
'history',
|
||||
'hosts',
|
||||
'known_hosts',
|
||||
'master_key_meta',
|
||||
'nodes',
|
||||
'port_forwards',
|
||||
'private_keys',
|
||||
'settings',
|
||||
]) {
|
||||
expect(tables).toContain(table)
|
||||
}
|
||||
})
|
||||
|
||||
it('is idempotent across reopens', () => {
|
||||
const dir = createTempDb()
|
||||
const { path } = dir
|
||||
closeDatabase(dir.db)
|
||||
|
||||
// Reopening must not attempt to re-run migrations; a second CREATE TABLE
|
||||
// would throw and the user would lose access to their data on every launch.
|
||||
const reopened = openDatabase(path)
|
||||
expect(reopened.pragma('user_version', { simple: true })).toBe(HIGHEST_VERSION)
|
||||
closeDatabase(reopened)
|
||||
|
||||
temp = { ...dir, db: reopened }
|
||||
})
|
||||
|
||||
it('enables foreign keys, without which cascades silently do nothing', () => {
|
||||
temp = createTempDb()
|
||||
expect(temp.db.pragma('foreign_keys', { simple: true })).toBe(1)
|
||||
})
|
||||
|
||||
it('cascades folder deletion to nested folders and their hosts', () => {
|
||||
temp = createTempDb()
|
||||
|
||||
const parent = createFolder(temp.db, null, 'parent')
|
||||
const child = createFolder(temp.db, parent, 'child')
|
||||
createHost(temp.db, {
|
||||
parentId: child,
|
||||
label: 'web',
|
||||
hostname: 'example.com',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
keyPath: null,
|
||||
color: null,
|
||||
})
|
||||
|
||||
temp.db.prepare('DELETE FROM nodes WHERE id = ?').run(parent)
|
||||
|
||||
const nodes = temp.db.prepare('SELECT COUNT(*) AS n FROM nodes').get() as { n: number }
|
||||
const hosts = temp.db.prepare('SELECT COUNT(*) AS n FROM hosts').get() as { n: number }
|
||||
const folders = temp.db.prepare('SELECT COUNT(*) AS n FROM folders').get() as { n: number }
|
||||
|
||||
expect(nodes.n).toBe(0)
|
||||
expect(folders.n).toBe(0)
|
||||
expect(hosts.n).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a credential attached to a folder id', () => {
|
||||
// The point of splitting attributes out of `nodes`: a `kind` discriminator
|
||||
// alone could not stop this, but a foreign key to `hosts(id)` can.
|
||||
temp = createTempDb()
|
||||
const folder = createFolder(temp.db, null, 'not-a-host')
|
||||
|
||||
expect(() =>
|
||||
temp?.db
|
||||
.prepare(
|
||||
`INSERT INTO credentials (host_id, kind, ciphertext, nonce, salt, created_at)
|
||||
VALUES (?, 'password', X'01', X'02', X'03', ?)`,
|
||||
)
|
||||
.run(folder, Date.now()),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('enforces the single-row constraint on master_key_meta', () => {
|
||||
temp = createTempDb()
|
||||
const insert = (): void => {
|
||||
temp?.db
|
||||
.prepare(
|
||||
'INSERT INTO master_key_meta (id, ciphertext, backend, created_at) VALUES (?, ?, ?, ?)',
|
||||
)
|
||||
.run(1, Buffer.from('x'), 'test', Date.now())
|
||||
}
|
||||
|
||||
insert()
|
||||
// A second row would mean two master keys, and therefore secrets that
|
||||
// silently become undecryptable depending on which row is read first.
|
||||
expect(insert).toThrow()
|
||||
})
|
||||
|
||||
it('rejects malformed port forwards via the CHECK constraint', () => {
|
||||
temp = createTempDb()
|
||||
const now = Date.now()
|
||||
const host = createHost(temp.db, {
|
||||
parentId: null,
|
||||
label: 'web',
|
||||
hostname: 'example.com',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
keyPath: null,
|
||||
color: null,
|
||||
})
|
||||
|
||||
// A dynamic (SOCKS) forward has no fixed destination.
|
||||
expect(() =>
|
||||
temp?.db
|
||||
.prepare(
|
||||
`INSERT INTO port_forwards (host_id, type, bind_port, dest_host, dest_port, created_at)
|
||||
VALUES (?, 'dynamic', 1080, 'example.com', 80, ?)`,
|
||||
)
|
||||
.run(host, now),
|
||||
).toThrow()
|
||||
|
||||
// A local forward must have one.
|
||||
expect(() =>
|
||||
temp?.db
|
||||
.prepare(
|
||||
`INSERT INTO port_forwards (host_id, type, bind_port, dest_host, dest_port, created_at)
|
||||
VALUES (?, 'local', 8080, NULL, NULL, ?)`,
|
||||
)
|
||||
.run(host, now),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { userInfo } from 'node:os'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { Client } from 'ssh2'
|
||||
import { dial, type DialResult } from '../src/main/session/dial'
|
||||
import { findKnownHost, recordKnownHost } from '../src/main/db/repositories/knownHosts'
|
||||
import type { HostRecord } from '../src/main/db/repositories/connections'
|
||||
import { createTempDb, type TempDb } from './helpers'
|
||||
|
||||
/**
|
||||
* Integration tests against a real OpenSSH server, not a mock and not ssh2's
|
||||
* own server implementation. The trust-on-first-use state machine is the most
|
||||
* security-sensitive logic in the app, and testing it against itself would
|
||||
* prove only that it is self-consistent.
|
||||
*
|
||||
* Start the server first:
|
||||
*
|
||||
* mkdir -p /tmp/testsshd && ssh-keygen -t ed25519 -f /tmp/testsshd/host_key -N '' -q
|
||||
* ssh-keygen -t ed25519 -f /tmp/testsshd/user_key -N '' -q
|
||||
* cp /tmp/testsshd/user_key.pub /tmp/testsshd/authorized_keys
|
||||
* printf 'Port 2222\nListenAddress 127.0.0.1\nHostKey /tmp/testsshd/host_key\n' \
|
||||
* 'AuthorizedKeysFile /tmp/testsshd/authorized_keys\nUsePAM no\nStrictModes no\n' \
|
||||
* > /tmp/testsshd/sshd_config
|
||||
* sshd -f /tmp/testsshd/sshd_config -D -e &
|
||||
*
|
||||
* The suite skips when that server is absent rather than failing, so the unit
|
||||
* tests stay runnable anywhere.
|
||||
*/
|
||||
const TEST_PORT = 2222
|
||||
const TEST_USER_KEY = '/tmp/testsshd/user_key'
|
||||
const EXPECTED_FINGERPRINT = 'SHA256:ASmbFHszdZLp5YkSiJnqmGfAhpZKOZwQJnk3rLUo4Xo'
|
||||
|
||||
const prerequisitesPresent = existsSync(TEST_USER_KEY)
|
||||
|
||||
let temp: TempDb
|
||||
const openClients: Client[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
temp = createTempDb()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const client of openClients.splice(0)) {
|
||||
try {
|
||||
client.end()
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
temp.cleanup()
|
||||
})
|
||||
|
||||
function testHost(overrides: Partial<HostRecord> = {}): HostRecord {
|
||||
return {
|
||||
id: 1,
|
||||
parentId: null,
|
||||
label: 'test-server',
|
||||
hostname: '127.0.0.1',
|
||||
port: TEST_PORT,
|
||||
username: userInfo().username,
|
||||
authType: 'key',
|
||||
keyPath: TEST_USER_KEY,
|
||||
color: null,
|
||||
sortOrder: 1024,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function attempt(overrides: Partial<HostRecord> = {}, trustFingerprint: string | null = null, password: string | null = null): Promise<DialResult> {
|
||||
const result = await dial({
|
||||
db: temp.db,
|
||||
host: testHost(overrides),
|
||||
password,
|
||||
passphrase: null,
|
||||
trustFingerprint,
|
||||
readyTimeoutMs: 8_000,
|
||||
})
|
||||
if (result.ok) openClients.push(result.client)
|
||||
return result
|
||||
}
|
||||
|
||||
describe.runIf(prerequisitesPresent)('dial — trust on first use', () => {
|
||||
it('refuses an unrecognised host key rather than connecting', async () => {
|
||||
const result = await attempt()
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.failure.reason).toBe('unknown-host-key')
|
||||
if (result.failure.reason !== 'unknown-host-key') return
|
||||
|
||||
// The fingerprint shown must be the real one, or the user cannot check it.
|
||||
expect(result.failure.fingerprint).toBe(EXPECTED_FINGERPRINT)
|
||||
expect(result.failure.keyType).toBe('ssh-ed25519')
|
||||
})
|
||||
|
||||
it('records nothing when the key was not trusted', async () => {
|
||||
// A refused connection must leave no trace, or the next attempt would be
|
||||
// silently accepted without the user ever seeing the fingerprint.
|
||||
await attempt()
|
||||
|
||||
expect(findKnownHost(temp.db, '127.0.0.1', TEST_PORT, 'ssh-ed25519')).toBeNull()
|
||||
})
|
||||
|
||||
it('connects when the presented fingerprint is trusted', async () => {
|
||||
const result = await attempt({}, EXPECTED_FINGERPRINT)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.fingerprint).toBe(EXPECTED_FINGERPRINT)
|
||||
})
|
||||
|
||||
it('remembers the key once trusted', async () => {
|
||||
await attempt({}, EXPECTED_FINGERPRINT)
|
||||
|
||||
const known = findKnownHost(temp.db, '127.0.0.1', TEST_PORT, 'ssh-ed25519')
|
||||
expect(known?.fingerprint).toBe(EXPECTED_FINGERPRINT)
|
||||
})
|
||||
|
||||
it('connects without a prompt once the key is recorded', async () => {
|
||||
await attempt({}, EXPECTED_FINGERPRINT)
|
||||
|
||||
const second = await attempt()
|
||||
|
||||
expect(second.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a trust token that does not match the key presented', async () => {
|
||||
// The user accepted one fingerprint and a different key turned up — the
|
||||
// exact shape of an interception. It must not be waved through.
|
||||
const result = await attempt({}, 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.failure.reason).toBe('host-key-mismatch')
|
||||
})
|
||||
|
||||
it('refuses a changed key even though an earlier one was trusted', async () => {
|
||||
recordKnownHost(temp.db, '127.0.0.1', TEST_PORT, 'ssh-ed25519', 'SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB')
|
||||
|
||||
const result = await attempt()
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.failure.reason).toBe('host-key-mismatch')
|
||||
if (result.failure.reason !== 'host-key-mismatch') return
|
||||
expect(result.failure.expected).toBe('SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB')
|
||||
expect(result.failure.actual).toBe(EXPECTED_FINGERPRINT)
|
||||
})
|
||||
|
||||
it('reports a readable failure for an unreadable key file', async () => {
|
||||
const result = await attempt({ keyPath: '/nonexistent/id_ed25519' })
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.failure.reason).toBe('error')
|
||||
if (result.failure.reason === 'error') {
|
||||
expect(result.failure.message).toMatch(/private key/)
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies the host key before attempting authentication', async () => {
|
||||
// Ordering matters and is not obvious: an untrusted key stops the attempt
|
||||
// before a password is ever offered to the server. Getting this backwards
|
||||
// would leak credentials to whatever is on the other end.
|
||||
const result = await attempt({ authType: 'password' }, null, 'definitely-not-the-password')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.failure.reason).toBe('unknown-host-key')
|
||||
})
|
||||
|
||||
it('reports auth failure distinctly from a transport error', async () => {
|
||||
// The distinction matters: auth failure means "offer a password prompt",
|
||||
// a transport error does not. The key has to be trusted first, or this
|
||||
// never reaches authentication.
|
||||
const result = await attempt(
|
||||
{ authType: 'password' },
|
||||
EXPECTED_FINGERPRINT,
|
||||
'definitely-not-the-password',
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.failure.reason).toBe('auth-failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe.runIf(!prerequisitesPresent)('dial — integration suite skipped', () => {
|
||||
it('reports why', () => {
|
||||
// Deliberately visible: a silently absent integration suite reads as
|
||||
// passing coverage when it is nothing of the sort.
|
||||
expect(prerequisitesPresent).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { openDatabase, closeDatabase, type Db } from '../src/main/db/database'
|
||||
import type { Keyring } from '../src/main/security/keyring'
|
||||
import type { SafeStorageStatus } from '../src/shared/ipc-schemas'
|
||||
|
||||
export interface TempDb {
|
||||
db: Db
|
||||
path: string
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A real file-backed database in a temp directory, not `:memory:`.
|
||||
*
|
||||
* Tests that assert a secret never reaches disk need an actual file to inspect,
|
||||
* and WAL mode does not apply to in-memory databases — so the in-memory
|
||||
* shortcut would quietly skip the behaviour most worth checking.
|
||||
*/
|
||||
export function createTempDb(): TempDb {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'nexusterm-test-'))
|
||||
const path = join(dir, 'test.db')
|
||||
const db = openDatabase(path)
|
||||
|
||||
return {
|
||||
db,
|
||||
path,
|
||||
cleanup: () => {
|
||||
closeDatabase(db)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in keyring.
|
||||
*
|
||||
* `wrap` is a reversible transform rather than real encryption, and that is
|
||||
* deliberate: it is not trying to be secure, it is trying to be *observable*.
|
||||
* The point is that the bytes handed to SQLite are recognisably different from
|
||||
* the plaintext, so tests can prove the vault actually called through to the
|
||||
* keyring instead of passing a secret straight to storage.
|
||||
*/
|
||||
export function fakeKeyring(
|
||||
status: SafeStorageStatus = {
|
||||
available: true,
|
||||
backend: 'test-keyring',
|
||||
secure: true,
|
||||
reason: 'ok',
|
||||
},
|
||||
): Keyring {
|
||||
const PREFIX = 'WRAPPED:'
|
||||
|
||||
return {
|
||||
status: () => status,
|
||||
wrap: (plaintext) => Buffer.from(PREFIX + Buffer.from(plaintext, 'utf8').toString('hex')),
|
||||
unwrap: (sealed) => {
|
||||
const text = sealed.toString('utf8')
|
||||
if (!text.startsWith(PREFIX)) {
|
||||
throw new Error('blob was not wrapped by this keyring')
|
||||
}
|
||||
return Buffer.from(text.slice(PREFIX.length), 'hex').toString('utf8')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A keyring that reports the Linux `basic_text` condition: the API claims
|
||||
* encryption is available, but the backend uses a hardcoded key. Both `wrap`
|
||||
* and `unwrap` throw, so any code path that fails to check `secure` first will
|
||||
* fail loudly rather than storing something weak.
|
||||
*/
|
||||
export function insecureKeyring(): Keyring {
|
||||
const refuse = (): never => {
|
||||
throw new Error('insecure keyring must not be used')
|
||||
}
|
||||
|
||||
return {
|
||||
status: () => ({
|
||||
available: true,
|
||||
backend: 'basic_text',
|
||||
secure: false,
|
||||
reason: 'basic-text',
|
||||
}),
|
||||
wrap: refuse,
|
||||
unwrap: refuse,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fingerprintOf, keyTypeOf } from '../src/main/session/hostKey'
|
||||
|
||||
/**
|
||||
* A real ed25519 host key, with the fingerprint `ssh-keygen -lf` prints for it:
|
||||
*
|
||||
* $ ssh-keygen -lf host_key.pub
|
||||
* 256 SHA256:ASmbFHszdZLp5YkSiJnqmGfAhpZKOZwQJnk3rLUo4Xo (ED25519)
|
||||
*
|
||||
* The expected value is pinned rather than computed so the test proves the
|
||||
* *format*, not merely that the function is self-consistent. A fingerprint that
|
||||
* does not match what ssh-keygen shows is one the user cannot check against
|
||||
* anything, which defeats the point of showing it at all.
|
||||
*/
|
||||
const ED25519_PUBLIC_KEY =
|
||||
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKzewpDb5LqzDdJeN9GQVNBzIVE3ZPZ9cGWFpurXr1o8 test@host'
|
||||
const ED25519_FINGERPRINT = 'SHA256:ASmbFHszdZLp5YkSiJnqmGfAhpZKOZwQJnk3rLUo4Xo'
|
||||
|
||||
function blobOf(publicKeyLine: string): Buffer {
|
||||
const base64 = publicKeyLine.trim().split(/\s+/)[1]
|
||||
if (!base64) throw new Error('not a public key line')
|
||||
return Buffer.from(base64, 'base64')
|
||||
}
|
||||
|
||||
describe('keyTypeOf', () => {
|
||||
it('reads the algorithm name out of the wire format', () => {
|
||||
expect(keyTypeOf(blobOf(ED25519_PUBLIC_KEY))).toBe('ssh-ed25519')
|
||||
})
|
||||
|
||||
it('returns unknown rather than throwing on a malformed blob', () => {
|
||||
// A short or bogus blob must not take down a connection attempt — it just
|
||||
// cannot be identified, and the fingerprint check will still be shown.
|
||||
expect(keyTypeOf(Buffer.alloc(0))).toBe('unknown')
|
||||
expect(keyTypeOf(Buffer.from([0, 0]))).toBe('unknown')
|
||||
// Declares a 4GB name.
|
||||
expect(keyTypeOf(Buffer.from([0xff, 0xff, 0xff, 0xff, 1, 2, 3]))).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fingerprintOf', () => {
|
||||
it('matches the OpenSSH SHA256 fingerprint exactly', () => {
|
||||
expect(fingerprintOf(blobOf(ED25519_PUBLIC_KEY))).toBe(ED25519_FINGERPRINT)
|
||||
})
|
||||
|
||||
it('strips base64 padding, as OpenSSH does', () => {
|
||||
expect(fingerprintOf(blobOf(ED25519_PUBLIC_KEY))).not.toContain('=')
|
||||
})
|
||||
|
||||
it('is stable for the same key', () => {
|
||||
const blob = blobOf(ED25519_PUBLIC_KEY)
|
||||
expect(fingerprintOf(blob)).toBe(fingerprintOf(blob))
|
||||
})
|
||||
|
||||
it('differs for a different key', () => {
|
||||
const a = blobOf(ED25519_PUBLIC_KEY)
|
||||
const b = Buffer.from(a)
|
||||
b[b.length - 1] ^= 0x01
|
||||
|
||||
expect(fingerprintOf(b)).not.toBe(fingerprintOf(a))
|
||||
})
|
||||
|
||||
it('is a SHA256 digest, not a truncated or padded one', () => {
|
||||
// 32 bytes base64-encodes to 44 characters, 43 once padding is stripped.
|
||||
const digest = fingerprintOf(blobOf(ED25519_PUBLIC_KEY)).replace('SHA256:', '')
|
||||
expect(digest).toHaveLength(43)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import Database from 'better-sqlite3'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import init001 from '../src/main/db/migrations/001_init.sql?raw'
|
||||
import { migrate } from '../src/main/db/database'
|
||||
import { getConnectionTree, getHost } from '../src/main/db/repositories/connections'
|
||||
|
||||
/**
|
||||
* Migration 002 rebuilds the tree into an interleaved structure, remapping ids
|
||||
* as it goes. It is the most intricate SQL in the project and the failure mode
|
||||
* is silent data loss, so it is tested against a database built in the *old*
|
||||
* shape and filled with old-format rows — not against a fresh one, where every
|
||||
* code path would be trivially empty.
|
||||
*/
|
||||
|
||||
let db: Database.Database
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'nexusterm-migration-'))
|
||||
db = new Database(join(dir, 'v1.db'))
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
// Build the pre-migration schema exactly as it shipped, then declare it
|
||||
// version 1 so `migrate` sees migration 2 as pending.
|
||||
db.exec(init001)
|
||||
db.pragma('user_version = 1')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function seedFolder(parentId: number | null, name: string, sortOrder: number): number {
|
||||
const now = Date.now()
|
||||
return Number(
|
||||
db
|
||||
.prepare(
|
||||
'INSERT INTO folders (parent_id, name, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
|
||||
)
|
||||
.run(parentId, name, sortOrder, now, now).lastInsertRowid,
|
||||
)
|
||||
}
|
||||
|
||||
function seedHost(
|
||||
folderId: number | null,
|
||||
label: string,
|
||||
sortOrder: number,
|
||||
overrides: { hostname?: string; port?: number } = {},
|
||||
): number {
|
||||
const now = Date.now()
|
||||
return Number(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO hosts (folder_id, label, hostname, port, username, auth_type, key_path, color, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'root', 'password', NULL, NULL, ?, ?, ?)`,
|
||||
)
|
||||
.run(folderId, label, overrides.hostname ?? '10.0.1.5', overrides.port ?? 22, sortOrder, now, now)
|
||||
.lastInsertRowid,
|
||||
)
|
||||
}
|
||||
|
||||
/** A v1 database with two root folders, a nested host set, and a root host. */
|
||||
function seedV1(): { prod: number; staging: number; web: number; database: number; rooted: number } {
|
||||
const prod = seedFolder(null, 'prod', 1024)
|
||||
const staging = seedFolder(null, 'staging', 2048)
|
||||
const web = seedHost(prod, 'web-01', 1024, { hostname: '10.0.1.5' })
|
||||
const database = seedHost(prod, 'db-01', 2048, { hostname: '10.0.2.10', port: 5432 })
|
||||
const rooted = seedHost(null, 'rooted', 1024, { hostname: '203.0.113.7' })
|
||||
|
||||
return { prod, staging, web, database, rooted }
|
||||
}
|
||||
|
||||
function names(nodes: ReturnType<typeof getConnectionTree>): string[] {
|
||||
return nodes.map((n) => (n.kind === 'folder' ? n.name : n.label))
|
||||
}
|
||||
|
||||
describe('migration 002: interleaving folders and hosts', () => {
|
||||
it('advances the schema version', () => {
|
||||
seedV1()
|
||||
migrate(db)
|
||||
|
||||
expect(db.pragma('user_version', { simple: true })).toBe(2)
|
||||
})
|
||||
|
||||
it('leaves no dangling references', () => {
|
||||
seedV1()
|
||||
migrate(db)
|
||||
|
||||
// The runner already asserts this and rolls back on failure; checking again
|
||||
// here documents that the guarantee is deliberate.
|
||||
expect(db.pragma('foreign_key_check')).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves every folder and host', () => {
|
||||
seedV1()
|
||||
migrate(db)
|
||||
|
||||
const folders = db.prepare('SELECT COUNT(*) AS n FROM folders').get() as { n: number }
|
||||
const hosts = db.prepare('SELECT COUNT(*) AS n FROM hosts').get() as { n: number }
|
||||
const nodes = db.prepare('SELECT COUNT(*) AS n FROM nodes').get() as { n: number }
|
||||
|
||||
expect(folders.n).toBe(2)
|
||||
expect(hosts.n).toBe(3)
|
||||
expect(nodes.n).toBe(5)
|
||||
})
|
||||
|
||||
it('preserves the nesting', () => {
|
||||
const ids = seedV1()
|
||||
migrate(db)
|
||||
|
||||
const tree = getConnectionTree(db)
|
||||
expect(names(tree)).toEqual(['prod', 'staging', 'rooted'])
|
||||
|
||||
const prod = tree.find((n) => n.kind === 'folder' && n.name === 'prod')
|
||||
expect(prod?.kind).toBe('folder')
|
||||
if (prod?.kind === 'folder') {
|
||||
expect(names(prod.children)).toEqual(['web-01', 'db-01'])
|
||||
expect(prod.children.map((c) => c.id)).not.toContain(ids.web)
|
||||
}
|
||||
})
|
||||
|
||||
it('reproduces the order the old tree displayed, folders first', () => {
|
||||
// The two old sequences collided head-on — folder 1024 against host 1024.
|
||||
// Renumbering each layer resolves the ties into the order the UI already
|
||||
// showed, so nothing visibly jumps after upgrading.
|
||||
seedV1()
|
||||
migrate(db)
|
||||
|
||||
expect(names(getConnectionTree(db))).toEqual(['prod', 'staging', 'rooted'])
|
||||
})
|
||||
|
||||
it('preserves the relative order of hosts within a layer', () => {
|
||||
// A real inventory contains hosts whose sort_order is a midpoint, left
|
||||
// there by an earlier drag — the seeded tree this migration first ran
|
||||
// against had exactly that shape. The renumbering must carry the order
|
||||
// across, not merely the membership.
|
||||
const prod = seedFolder(null, 'prod', 1024)
|
||||
seedHost(prod, 'first', 1024)
|
||||
seedHost(prod, 'dropped-in', 1536)
|
||||
seedHost(prod, 'last', 2048)
|
||||
|
||||
migrate(db)
|
||||
|
||||
const prodNode = getConnectionTree(db).find((n) => n.kind === 'folder')
|
||||
expect(prodNode?.kind).toBe('folder')
|
||||
if (prodNode?.kind === 'folder') {
|
||||
expect(names(prodNode.children)).toEqual(['first', 'dropped-in', 'last'])
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves host attributes through the id remap', () => {
|
||||
const ids = seedV1()
|
||||
migrate(db)
|
||||
|
||||
const all = db.prepare('SELECT id, label, hostname, port FROM hosts').all() as {
|
||||
id: number
|
||||
label: string
|
||||
hostname: string
|
||||
port: number
|
||||
}[]
|
||||
|
||||
const db01 = all.find((h) => h.label === 'db-01')
|
||||
expect(db01).toMatchObject({ hostname: '10.0.2.10', port: 5432 })
|
||||
// Ids are reassigned by the rebuild, so the old id must not be assumed.
|
||||
expect(db01?.id).not.toBe(ids.database)
|
||||
})
|
||||
|
||||
it('remaps credentials onto the rebuilt host rows', () => {
|
||||
// The tables are empty in practice at this point, but a migration that
|
||||
// silently orphans saved credentials is exactly the bug that surfaces
|
||||
// months later — so it carries the data across.
|
||||
const ids = seedV1()
|
||||
db.prepare(
|
||||
`INSERT INTO credentials (host_id, kind, ciphertext, nonce, salt, created_at)
|
||||
VALUES (?, 'password', X'01', X'02', X'03', ?)`,
|
||||
).run(ids.web, Date.now())
|
||||
|
||||
migrate(db)
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT c.host_id, h.label FROM credentials c JOIN hosts h ON h.id = c.host_id')
|
||||
.get() as { host_id: number; label: string } | undefined
|
||||
|
||||
expect(row?.label).toBe('web-01')
|
||||
expect(row?.host_id).not.toBe(ids.web)
|
||||
})
|
||||
|
||||
it('remaps port forwards onto the rebuilt host rows', () => {
|
||||
const ids = seedV1()
|
||||
db.prepare(
|
||||
`INSERT INTO port_forwards (host_id, type, bind_host, bind_port, dest_host, dest_port, auto_start, created_at)
|
||||
VALUES (?, 'local', '127.0.0.1', 8080, 'localhost', 80, 0, ?)`,
|
||||
).run(ids.database, Date.now())
|
||||
|
||||
migrate(db)
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT f.host_id, h.label FROM port_forwards f JOIN hosts h ON h.id = f.host_id')
|
||||
.get() as { host_id: number; label: string } | undefined
|
||||
|
||||
expect(row?.label).toBe('db-01')
|
||||
})
|
||||
|
||||
it('removes the temporary legacy_id column', () => {
|
||||
seedV1()
|
||||
migrate(db)
|
||||
|
||||
const columns = db.pragma('table_info(nodes)') as { name: string }[]
|
||||
expect(columns.map((c) => c.name)).not.toContain('legacy_id')
|
||||
})
|
||||
|
||||
it('leaves foreign keys enforced again afterwards', () => {
|
||||
seedV1()
|
||||
migrate(db)
|
||||
|
||||
expect(db.pragma('foreign_keys', { simple: true })).toBe(1)
|
||||
|
||||
// And the enforcement is real, not just the flag.
|
||||
const bad = db.prepare('INSERT INTO hosts (id, label, hostname, port, username, auth_type) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
expect(() => bad.run(999_999, 'ghost', 'h', 22, 'u', 'password')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('migration 002 on an empty database', () => {
|
||||
it('succeeds without any rows to carry across', () => {
|
||||
migrate(db)
|
||||
|
||||
expect(db.pragma('user_version', { simple: true })).toBe(2)
|
||||
expect(getConnectionTree(db)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('migration 002 is idempotent', () => {
|
||||
it('does nothing on a second run', () => {
|
||||
seedV1()
|
||||
migrate(db)
|
||||
const before = getHost(db, (db.prepare("SELECT id FROM hosts WHERE label = 'web-01'").get() as { id: number }).id)
|
||||
|
||||
const applied = migrate(db)
|
||||
|
||||
expect(applied).toBe(0)
|
||||
const after = getHost(db, (db.prepare("SELECT id FROM hosts WHERE label = 'web-01'").get() as { id: number }).id)
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MIN_GAP,
|
||||
SORT_GAP,
|
||||
needsRebalance,
|
||||
rebalancedOrders,
|
||||
sortOrderBetween,
|
||||
} from '../src/main/db/repositories/ordering'
|
||||
|
||||
describe('ordering', () => {
|
||||
it('starts a fresh layer at the base gap', () => {
|
||||
expect(sortOrderBetween(null, null)).toBe(SORT_GAP)
|
||||
})
|
||||
|
||||
it('appends past the last item', () => {
|
||||
expect(sortOrderBetween(1024, null)).toBe(1024 + SORT_GAP)
|
||||
})
|
||||
|
||||
it('prepends before the first item', () => {
|
||||
expect(sortOrderBetween(null, 1024)).toBe(1024 - SORT_GAP / 2)
|
||||
})
|
||||
|
||||
it('lands strictly between two neighbours', () => {
|
||||
const mid = sortOrderBetween(1024, 2048)
|
||||
expect(mid).toBeGreaterThan(1024)
|
||||
expect(mid).toBeLessThan(2048)
|
||||
})
|
||||
|
||||
it('does not flag a healthy gap as needing rebalancing', () => {
|
||||
expect(needsRebalance(1024, 2048)).toBe(false)
|
||||
// A missing neighbour means "open end", not "collapsed".
|
||||
expect(needsRebalance(null, 1024)).toBe(false)
|
||||
expect(needsRebalance(1024, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('flags a gap too small to split again', () => {
|
||||
expect(needsRebalance(1, 1 + MIN_GAP / 2)).toBe(true)
|
||||
expect(needsRebalance(1, 1 + MIN_GAP * 2)).toBe(false)
|
||||
})
|
||||
|
||||
it('exhausts precision after a bounded number of splits, then says so', () => {
|
||||
// The documented cost of fractional ordering. Repeatedly splitting the same
|
||||
// gap halves the interval each time, and a double cannot halve forever.
|
||||
// This test pins down that the limit is reached and, crucially, that it is
|
||||
// *detected* — an undetected collapse would silently make item order
|
||||
// arbitrary rather than raising an error.
|
||||
let current = 1024
|
||||
const next = 2048
|
||||
let splits = 0
|
||||
|
||||
while (!needsRebalance(current, next) && splits < 500) {
|
||||
current = sortOrderBetween(current, next)
|
||||
splits++
|
||||
}
|
||||
|
||||
expect(splits).toBeLessThan(500)
|
||||
expect(needsRebalance(current, next)).toBe(true)
|
||||
// Comfortably beyond any human drag-and-drop session, which is why the
|
||||
// tradeoff is worth taking.
|
||||
expect(splits).toBeGreaterThan(20)
|
||||
})
|
||||
|
||||
it('produces evenly spaced orders when rebalancing', () => {
|
||||
const orders = rebalancedOrders(4)
|
||||
expect(orders).toEqual([1024, 2048, 3072, 4096])
|
||||
|
||||
const gaps = orders.slice(1).map((v, i) => v - orders[i])
|
||||
expect(new Set(gaps).size).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RECONNECT, reconnectDelay } from '../src/shared/session-protocol'
|
||||
|
||||
/** The nominal delay before jitter, for a given 1-based attempt. */
|
||||
function nominal(attempt: number): number {
|
||||
return Math.min(
|
||||
RECONNECT.maxDelayMs,
|
||||
RECONNECT.initialDelayMs * 2 ** Math.max(0, attempt - 1),
|
||||
)
|
||||
}
|
||||
|
||||
describe('reconnectDelay', () => {
|
||||
it('stays within the jitter band on every attempt', () => {
|
||||
for (let attempt = 1; attempt <= RECONNECT.maxAttempts + 5; attempt++) {
|
||||
const delay = reconnectDelay(attempt)
|
||||
const base = nominal(attempt)
|
||||
expect(delay).toBeGreaterThanOrEqual(Math.round(base * (1 - RECONNECT.jitter)) - 1)
|
||||
expect(delay).toBeLessThanOrEqual(Math.round(base * (1 + RECONNECT.jitter)) + 1)
|
||||
}
|
||||
})
|
||||
|
||||
it('starts short enough that a blip is barely noticed', () => {
|
||||
// A first retry the user has to wait through feels like a broken app.
|
||||
expect(reconnectDelay(1)).toBeLessThanOrEqual(1_500)
|
||||
})
|
||||
|
||||
it('climbs, and does not exceed the ceiling', () => {
|
||||
expect(nominal(2)).toBeGreaterThan(nominal(1))
|
||||
for (let attempt = 1; attempt <= 30; attempt++) {
|
||||
expect(reconnectDelay(attempt)).toBeLessThanOrEqual(
|
||||
Math.round(RECONNECT.maxDelayMs * (1 + RECONNECT.jitter)) + 1,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('is never zero or negative', () => {
|
||||
// A zero delay would turn the retry loop into a busy loop against a server
|
||||
// that is already having a bad day.
|
||||
for (let attempt = 1; attempt <= 30; attempt++) {
|
||||
expect(reconnectDelay(attempt)).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('varies between calls, so sessions that drop together do not retry in lockstep', () => {
|
||||
// This is the whole reason for the jitter: a network failure takes every
|
||||
// session down at once, and identical delays would have them all hit the
|
||||
// server on the same tick.
|
||||
const samples = new Set(Array.from({ length: 40 }, () => reconnectDelay(4)))
|
||||
expect(samples.size).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('handles a non-positive attempt without producing nonsense', () => {
|
||||
expect(reconnectDelay(0)).toBeGreaterThan(0)
|
||||
expect(reconnectDelay(-3)).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RECONNECT limits', () => {
|
||||
it('gives up eventually rather than retrying forever', () => {
|
||||
expect(RECONNECT.maxAttempts).toBeGreaterThan(0)
|
||||
expect(RECONNECT.maxAttempts).toBeLessThanOrEqual(50)
|
||||
})
|
||||
|
||||
it('spans roughly five minutes of trying', () => {
|
||||
// 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 retry loop.
|
||||
let total = 0
|
||||
for (let attempt = 1; attempt <= RECONNECT.maxAttempts; attempt++) {
|
||||
total += nominal(attempt)
|
||||
}
|
||||
expect(total).toBeGreaterThan(60_000)
|
||||
expect(total).toBeLessThan(15 * 60_000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveKey, seal, unseal, SECRET_CONTEXT } from '../src/main/security/secretBox'
|
||||
|
||||
const MASTER = randomBytes(32)
|
||||
const CTX = SECRET_CONTEXT.credential
|
||||
|
||||
describe('secretBox', () => {
|
||||
it('round-trips a secret', () => {
|
||||
const sealed = seal(MASTER, 'hunter2', CTX)
|
||||
expect(unseal(MASTER, sealed, CTX)).toBe('hunter2')
|
||||
})
|
||||
|
||||
it('round-trips non-ASCII and empty strings intact', () => {
|
||||
// Byte-length assumptions are the classic failure here: these strings are
|
||||
// longer in bytes than in characters, and a naive length check or a
|
||||
// truncating transform mangles them.
|
||||
for (const secret of ['密码', 'emoji-🔐-ok', 'ünïcödé', '', 'a'.repeat(4096)]) {
|
||||
const sealed = seal(MASTER, secret, CTX)
|
||||
expect(unseal(MASTER, sealed, CTX)).toBe(secret)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not leave the plaintext recoverable from the ciphertext', () => {
|
||||
const sealed = seal(MASTER, 'hunter2', CTX)
|
||||
expect(sealed.ciphertext.toString('latin1')).not.toContain('hunter2')
|
||||
})
|
||||
|
||||
it('produces different ciphertext each time the same secret is sealed', () => {
|
||||
// A fresh nonce and salt per call. Identical ciphertext would leak that two
|
||||
// hosts share a password.
|
||||
const a = seal(MASTER, 'same-secret', CTX)
|
||||
const b = seal(MASTER, 'same-secret', CTX)
|
||||
|
||||
expect(a.ciphertext.equals(b.ciphertext)).toBe(false)
|
||||
expect(a.nonce.equals(b.nonce)).toBe(false)
|
||||
expect(a.salt.equals(b.salt)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a tampered ciphertext', () => {
|
||||
const sealed = seal(MASTER, 'hunter2', CTX)
|
||||
sealed.ciphertext[0] ^= 0x01
|
||||
|
||||
expect(() => unseal(MASTER, sealed, CTX)).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a tampered auth tag', () => {
|
||||
const sealed = seal(MASTER, 'hunter2', CTX)
|
||||
const last = sealed.ciphertext.length - 1
|
||||
sealed.ciphertext[last] ^= 0x01
|
||||
|
||||
expect(() => unseal(MASTER, sealed, CTX)).toThrow()
|
||||
})
|
||||
|
||||
it('rejects the wrong master key', () => {
|
||||
const sealed = seal(MASTER, 'hunter2', CTX)
|
||||
expect(() => unseal(randomBytes(32), sealed, CTX)).toThrow()
|
||||
})
|
||||
|
||||
it('rejects the wrong context', () => {
|
||||
// Context separation: a credential blob must not decrypt as a private key
|
||||
// even when both are sealed under the same master key.
|
||||
const sealed = seal(MASTER, 'hunter2', CTX)
|
||||
expect(() => unseal(MASTER, sealed, SECRET_CONTEXT.privateKey)).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a truncated ciphertext rather than returning a short secret', () => {
|
||||
const sealed = seal(MASTER, 'hunter2', CTX)
|
||||
sealed.ciphertext = sealed.ciphertext.subarray(0, 4)
|
||||
|
||||
expect(() => unseal(MASTER, sealed, CTX)).toThrow()
|
||||
})
|
||||
|
||||
it('derives a stable key for identical inputs and a distinct one per salt', () => {
|
||||
const salt = randomBytes(16)
|
||||
|
||||
expect(deriveKey(MASTER, salt, CTX).equals(deriveKey(MASTER, salt, CTX))).toBe(true)
|
||||
expect(deriveKey(MASTER, randomBytes(16), CTX).equals(deriveKey(MASTER, salt, CTX))).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,440 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FolderNode, HostNode, TreeNode } from '../src/shared/ipc-schemas'
|
||||
import {
|
||||
allFolderKeys,
|
||||
ancestorKeysOf,
|
||||
buildSidebarRows,
|
||||
computeMove,
|
||||
computeTabMove,
|
||||
dropPositionFor,
|
||||
folderKey,
|
||||
hostKey,
|
||||
LOCAL_KEY,
|
||||
orderAllSessions,
|
||||
nextAutoIndex,
|
||||
orderSessions,
|
||||
seqAfterSession,
|
||||
seqForNewSession,
|
||||
sessionKey,
|
||||
sessionLabel,
|
||||
type SidebarRow,
|
||||
type SidebarSession,
|
||||
} from '../src/renderer/src/lib/sidebar'
|
||||
|
||||
// ---------------------------------------------------------------- fixtures
|
||||
|
||||
function folder(id: number, name: string, children: TreeNode[] = []): FolderNode {
|
||||
return { kind: 'folder', id, parentId: null, name, sortOrder: id * 1024, children }
|
||||
}
|
||||
|
||||
function host(id: number, label: string): HostNode {
|
||||
return {
|
||||
kind: 'host',
|
||||
id,
|
||||
parentId: null,
|
||||
label,
|
||||
hostname: '10.0.1.5',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
color: null,
|
||||
sortOrder: id * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
function session(
|
||||
id: string,
|
||||
hostId: number | null,
|
||||
seq: number,
|
||||
pinned = false,
|
||||
autoIndex = 1,
|
||||
): SidebarSession {
|
||||
return {
|
||||
id,
|
||||
hostId,
|
||||
label: 'session',
|
||||
autoIndex,
|
||||
customLabel: null,
|
||||
status: 'connected',
|
||||
pinned,
|
||||
seq,
|
||||
host: null,
|
||||
}
|
||||
}
|
||||
|
||||
function rowOf(rows: SidebarRow[], key: string): SidebarRow {
|
||||
const row = rows.find((r) => r.key === key)
|
||||
if (!row) throw new Error(`no row for ${key}`)
|
||||
return row
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ordering
|
||||
|
||||
describe('orderSessions', () => {
|
||||
it('puts pinned sessions first, then open order', () => {
|
||||
const ordered = orderSessions([
|
||||
session('c', 1, 3),
|
||||
session('a', 1, 1),
|
||||
session('b', 1, 2, true),
|
||||
])
|
||||
expect(ordered.map((s) => s.id)).toEqual(['b', 'a', 'c'])
|
||||
})
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const input = [session('b', 1, 2), session('a', 1, 1)]
|
||||
orderSessions(input)
|
||||
expect(input.map((s) => s.id)).toEqual(['b', 'a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderAllSessions', () => {
|
||||
it('puts local shells first, then one contiguous block per host', () => {
|
||||
// A session opened for host 2 after host 1 must not land between host 1's
|
||||
// sessions — same-host tabs stay together, which is the whole point of the
|
||||
// grouping.
|
||||
const ordered = orderAllSessions([
|
||||
session('a', 1, 1),
|
||||
session('b', 2, 2),
|
||||
session('c', 1, 3),
|
||||
session('local', null, 4),
|
||||
])
|
||||
|
||||
expect(ordered.map((s) => s.id)).toEqual(['local', 'a', 'c', 'b'])
|
||||
})
|
||||
|
||||
it('keeps hosts in the order they were first opened', () => {
|
||||
const ordered = orderAllSessions([session('a', 7, 1), session('b', 3, 2), session('c', 7, 3)])
|
||||
expect(ordered.map((s) => s.id)).toEqual(['a', 'c', 'b'])
|
||||
})
|
||||
|
||||
it('applies pinning within each host group, not across groups', () => {
|
||||
const ordered = orderAllSessions([
|
||||
session('a', 1, 1),
|
||||
session('b', 2, 2),
|
||||
session('c', 2, 3, true),
|
||||
])
|
||||
// c moves to the front of host 2's block, but does not jump above host 1.
|
||||
expect(ordered.map((s) => s.id)).toEqual(['a', 'c', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- numbering
|
||||
|
||||
describe('nextAutoIndex', () => {
|
||||
it('starts at 1 for a host with no sessions', () => {
|
||||
expect(nextAutoIndex([], 7)).toBe(1)
|
||||
})
|
||||
|
||||
it('counts per host, not globally', () => {
|
||||
expect(nextAutoIndex([session('a', 7, 1, false, 5)], 8)).toBe(1)
|
||||
})
|
||||
|
||||
it('treats local shells as their own group', () => {
|
||||
expect(nextAutoIndex([session('a', 7, 1, false, 3)], null)).toBe(1)
|
||||
expect(nextAutoIndex([session('a', null, 1, false, 2)], null)).toBe(3)
|
||||
})
|
||||
|
||||
it('increments past the highest index in use, not past the count', () => {
|
||||
// Three opened, the middle one closed: the next must not collide with the
|
||||
// surviving "3". Counting open sessions would hand out 3 a second time.
|
||||
const sessions = [session('a', 7, 1, false, 1), session('c', 7, 3, false, 3)]
|
||||
expect(nextAutoIndex(sessions, 7)).toBe(4)
|
||||
})
|
||||
|
||||
it('restarts at 1 once a host has no sessions left', () => {
|
||||
expect(nextAutoIndex([session('a', 9, 1, false, 4)], 7)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionLabel', () => {
|
||||
it('leaves the first session for a host unnumbered', () => {
|
||||
expect(sessionLabel(session('a', 7, 1, false, 1), 'local')).toBe('session')
|
||||
})
|
||||
|
||||
it('appends the index from the second onward', () => {
|
||||
expect(sessionLabel(session('a', 7, 1, false, 2), 'local')).toBe('session 2')
|
||||
expect(sessionLabel(session('a', 7, 1, false, 11), 'local')).toBe('session 11')
|
||||
})
|
||||
|
||||
it('uses the translated base for a local shell', () => {
|
||||
// The base has to come from the caller: this module has no business
|
||||
// knowing a language.
|
||||
expect(sessionLabel(session('a', null, 1, false, 1), '本地终端')).toBe('本地终端')
|
||||
expect(sessionLabel(session('a', null, 1, false, 3), '本地终端')).toBe('本地终端 3')
|
||||
})
|
||||
|
||||
it('lets a custom name override the numbering entirely', () => {
|
||||
const renamed = { ...session('a', 7, 1, false, 4), customLabel: 'prod db' }
|
||||
expect(sessionLabel(renamed, 'local')).toBe('prod db')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- tab placement
|
||||
|
||||
describe('seqForNewSession', () => {
|
||||
it('starts a host group at 1', () => {
|
||||
expect(seqForNewSession([], 7)).toBe(1)
|
||||
})
|
||||
|
||||
it('appends past the maximum, not past the array length', () => {
|
||||
// `seq` is fractional once anything has been dragged, so counting entries
|
||||
// would collide with an existing position.
|
||||
const sessions = [session('a', 7, 1), session('b', 7, 1.5), session('c', 7, 9)]
|
||||
expect(seqForNewSession(sessions, 7)).toBe(10)
|
||||
})
|
||||
|
||||
it('ignores other hosts', () => {
|
||||
expect(seqForNewSession([session('a', 7, 100)], 8)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('seqAfterSession', () => {
|
||||
it('lands between the original and the next one in its bucket', () => {
|
||||
const sessions = [session('a', 7, 1), session('b', 7, 3)]
|
||||
expect(seqAfterSession(sessions, 'a')).toBe(2)
|
||||
})
|
||||
|
||||
it('lands just past the original when it is last', () => {
|
||||
const sessions = [session('a', 7, 1), session('b', 7, 3)]
|
||||
expect(seqAfterSession(sessions, 'b')).toBe(4)
|
||||
})
|
||||
|
||||
it('does not see past its own pin bucket', () => {
|
||||
// A pinned neighbour is not "next" for an unpinned original.
|
||||
const sessions = [session('a', 7, 1), session('b', 7, 2, true), session('c', 7, 3)]
|
||||
expect(seqAfterSession(sessions, 'a')).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTabMove', () => {
|
||||
const sessions = [session('a', 7, 1), session('b', 7, 2), session('c', 7, 3)]
|
||||
|
||||
it('reorders within a host group', () => {
|
||||
expect(computeTabMove(sessions, 'c', 'a', 'before')).toEqual({ seq: 0 })
|
||||
expect(computeTabMove(sessions, 'a', 'c', 'after')).toEqual({ seq: 4 })
|
||||
expect(computeTabMove(sessions, 'a', 'b', 'after')).toEqual({ seq: 2.5 })
|
||||
})
|
||||
|
||||
it('refuses to move a tab into another host group', () => {
|
||||
// A session that left its host's block would have no place in a sidebar
|
||||
// that groups by host.
|
||||
const mixed = [...sessions, session('d', 9, 1)]
|
||||
expect(computeTabMove(mixed, 'a', 'd', 'before')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses to move across pin states', () => {
|
||||
// The sort would put it straight back where it started, so the drop is
|
||||
// better refused than silently undone.
|
||||
const mixed = [...sessions, session('p', 7, 4, true)]
|
||||
expect(computeTabMove(mixed, 'a', 'p', 'before')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a drop onto itself', () => {
|
||||
expect(computeTabMove(sessions, 'a', 'a', 'before')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses landing back in the slot it came from', () => {
|
||||
expect(computeTabMove(sessions, 'a', 'b', 'before')).toBeNull()
|
||||
expect(computeTabMove(sessions, 'b', 'a', 'after')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- rows
|
||||
|
||||
describe('buildSidebarRows', () => {
|
||||
const tree = [folder(1, 'prod', [host(10, 'web'), host(11, 'db')]), host(12, 'rooted')]
|
||||
|
||||
it('always shows the local entry, even with nothing open', () => {
|
||||
const rows = buildSidebarRows(tree, [], new Set())
|
||||
expect(rows[0].key).toBe(LOCAL_KEY)
|
||||
expect(rows[0].kind).toBe('local')
|
||||
expect(rows[0].sessionCount).toBe(0)
|
||||
// Nothing under it to expand.
|
||||
expect(rows[0].expandable).toBe(false)
|
||||
})
|
||||
|
||||
it('omits children of collapsed folders', () => {
|
||||
const rows = buildSidebarRows(tree, [], new Set())
|
||||
expect(rows.map((r) => r.key)).toEqual([LOCAL_KEY, folderKey(1), hostKey(12)])
|
||||
})
|
||||
|
||||
it('includes children of expanded folders', () => {
|
||||
const rows = buildSidebarRows(tree, [], new Set([folderKey(1)]))
|
||||
expect(rows.map((r) => r.key)).toEqual([
|
||||
LOCAL_KEY,
|
||||
folderKey(1),
|
||||
hostKey(10),
|
||||
hostKey(11),
|
||||
hostKey(12),
|
||||
])
|
||||
})
|
||||
|
||||
it('nests sessions under their host, once that host is expanded', () => {
|
||||
const sessions = [session('s1', 10, 1), session('s2', 10, 2)]
|
||||
|
||||
const collapsed = buildSidebarRows(tree, sessions, new Set([folderKey(1)]))
|
||||
expect(collapsed.map((r) => r.key)).not.toContain(sessionKey('s1'))
|
||||
expect(rowOf(collapsed, hostKey(10)).sessionCount).toBe(2)
|
||||
expect(rowOf(collapsed, hostKey(10)).expandable).toBe(true)
|
||||
|
||||
const expanded = buildSidebarRows(tree, sessions, new Set([folderKey(1), hostKey(10)]))
|
||||
expect(expanded.map((r) => r.key)).toEqual([
|
||||
LOCAL_KEY,
|
||||
folderKey(1),
|
||||
hostKey(10),
|
||||
sessionKey('s1'),
|
||||
sessionKey('s2'),
|
||||
hostKey(11),
|
||||
hostKey(12),
|
||||
])
|
||||
})
|
||||
|
||||
it('nests local shells under the local entry', () => {
|
||||
const sessions = [session('l1', null, 1), session('l2', null, 2)]
|
||||
const rows = buildSidebarRows(tree, sessions, new Set([LOCAL_KEY]))
|
||||
|
||||
expect(rows.map((r) => r.key)).toEqual([
|
||||
LOCAL_KEY,
|
||||
sessionKey('l1'),
|
||||
sessionKey('l2'),
|
||||
folderKey(1),
|
||||
hostKey(12),
|
||||
])
|
||||
expect(rows[0].sessionCount).toBe(2)
|
||||
})
|
||||
|
||||
it('orders sessions within a host by pin state, not by array position', () => {
|
||||
const sessions = [session('a', 10, 1), session('b', 10, 2), session('c', 10, 3, true)]
|
||||
const rows = buildSidebarRows(tree, sessions, new Set([folderKey(1), hostKey(10)]))
|
||||
const sessionRows = rows.filter((r) => r.kind === 'session').map((r) => r.session?.id)
|
||||
|
||||
expect(sessionRows).toEqual(['c', 'a', 'b'])
|
||||
})
|
||||
|
||||
it('indents sessions one level below their host', () => {
|
||||
const rows = buildSidebarRows(
|
||||
tree,
|
||||
[session('s1', 10, 1), session('l1', null, 2)],
|
||||
new Set([folderKey(1), hostKey(10), LOCAL_KEY]),
|
||||
)
|
||||
|
||||
expect(rowOf(rows, LOCAL_KEY).depth).toBe(0)
|
||||
expect(rowOf(rows, sessionKey('l1')).depth).toBe(1)
|
||||
expect(rowOf(rows, folderKey(1)).depth).toBe(0)
|
||||
expect(rowOf(rows, hostKey(10)).depth).toBe(1)
|
||||
expect(rowOf(rows, sessionKey('s1')).depth).toBe(2)
|
||||
})
|
||||
|
||||
it('makes a host a leaf again once its last session closes', () => {
|
||||
const withSession = buildSidebarRows(tree, [session('s1', 10, 1)], new Set([folderKey(1)]))
|
||||
expect(rowOf(withSession, hostKey(10)).expandable).toBe(true)
|
||||
|
||||
const without = buildSidebarRows(tree, [], new Set([folderKey(1)]))
|
||||
expect(rowOf(without, hostKey(10)).expandable).toBe(false)
|
||||
})
|
||||
|
||||
it('carries the sibling layer a drop needs', () => {
|
||||
const rows = buildSidebarRows(tree, [], new Set([folderKey(1)]))
|
||||
expect(rowOf(rows, hostKey(10)).siblings?.map((n) => n.id)).toEqual([10, 11])
|
||||
expect(rowOf(rows, hostKey(10)).parentId).toBe(1)
|
||||
expect(rowOf(rows, hostKey(12)).parentId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- drop position
|
||||
|
||||
describe('dropPositionFor', () => {
|
||||
const rect = { top: 100, height: 24 }
|
||||
|
||||
it('offers three zones on a folder, the middle meaning "nest inside"', () => {
|
||||
expect(dropPositionFor(rect, 102, folder(1, 'f'))).toBe('before')
|
||||
expect(dropPositionFor(rect, 112, folder(1, 'f'))).toBe('inside')
|
||||
expect(dropPositionFor(rect, 122, folder(1, 'f'))).toBe('after')
|
||||
})
|
||||
|
||||
it('never offers "inside" on a host, which holds sessions but not nodes', () => {
|
||||
expect(dropPositionFor(rect, 102, host(1, 'h'))).toBe('before')
|
||||
expect(dropPositionFor(rect, 112, host(1, 'h'))).toBe('after')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- computeMove
|
||||
|
||||
describe('computeMove', () => {
|
||||
const tree = [
|
||||
folder(1, 'prod', [host(10, 'web'), host(11, 'db'), folder(3, 'inner', [host(13, 'deep')])]),
|
||||
folder(2, 'staging'),
|
||||
host(12, 'rooted'),
|
||||
]
|
||||
const rows = buildSidebarRows(tree, [session('s1', 10, 1)], new Set([folderKey(1), hostKey(10)]))
|
||||
|
||||
it('expresses dropping between two nodes', () => {
|
||||
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, hostKey(11)), position: 'before' }))
|
||||
.toEqual({ id: 12, parentId: 1, prevSiblingId: 10, nextSiblingId: 11 })
|
||||
})
|
||||
|
||||
it('expresses dropping between two folders', () => {
|
||||
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, folderKey(2)), position: 'before' }))
|
||||
.toEqual({ id: 12, parentId: null, prevSiblingId: 1, nextSiblingId: 2 })
|
||||
})
|
||||
|
||||
it('expresses dropping into a folder, appended after its children', () => {
|
||||
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, folderKey(2)), position: 'inside' }))
|
||||
.toEqual({ id: 12, parentId: 2, prevSiblingId: null, nextSiblingId: null })
|
||||
})
|
||||
|
||||
it('refuses to drop a node on itself', () => {
|
||||
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, hostKey(12)), position: 'before' }))
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
it('refuses to drop a folder into its own descendant', () => {
|
||||
// Without this the subtree detaches from every root and disappears from the
|
||||
// UI while its rows remain. The main process guards it too; catching it
|
||||
// here means the drag simply refuses rather than erroring after the fact.
|
||||
//
|
||||
// The dragged node has to be the one from the tree — the check walks its
|
||||
// children, and a freshly built FolderNode with none has no subtree to
|
||||
// protect.
|
||||
const prod = tree[0]
|
||||
expect(computeMove(prod, { row: rowOf(rows, folderKey(3)), position: 'inside' })).toBeNull()
|
||||
})
|
||||
|
||||
it('allows dropping a folder into an unrelated folder', () => {
|
||||
expect(computeMove(folder(3, 'inner'), { row: rowOf(rows, folderKey(2)), position: 'inside' }))
|
||||
.toMatchObject({ id: 3, parentId: 2 })
|
||||
})
|
||||
|
||||
it('refuses a session row as a drop target', () => {
|
||||
// Sessions are ordered by their own rules and are not in the database at
|
||||
// all, so there is no move to express.
|
||||
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, sessionKey('s1')), position: 'before' }))
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
it('treats landing back in the current slot as a no-op', () => {
|
||||
expect(computeMove(host(10, 'web'), { row: rowOf(rows, hostKey(11)), position: 'before' }))
|
||||
.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
describe('ancestorKeysOf', () => {
|
||||
const tree = [folder(1, 'a', [folder(2, 'b', [host(10, 'deep')])])]
|
||||
|
||||
it('lists the folders above a node, outermost first', () => {
|
||||
expect(ancestorKeysOf(tree, hostKey(10))).toEqual([folderKey(1), folderKey(2)])
|
||||
})
|
||||
|
||||
it('returns nothing for a root-level node', () => {
|
||||
expect(ancestorKeysOf(tree, folderKey(1))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('allFolderKeys', () => {
|
||||
it('collects folder keys only, so expand-all does not open every session list', () => {
|
||||
const tree = [folder(1, 'a', [folder(2, 'b'), host(10, 'x')]), host(11, 'y')]
|
||||
expect(allFolderKeys(tree).sort()).toEqual([folderKey(1), folderKey(2)])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { closeDatabase, openDatabase } from '../src/main/db/database'
|
||||
import { Vault } from '../src/main/security/vault'
|
||||
import { SECRET_CONTEXT } from '../src/main/security/secretBox'
|
||||
import { createTempDb, fakeKeyring, insecureKeyring, type TempDb } from './helpers'
|
||||
|
||||
let temp: TempDb | null = null
|
||||
|
||||
afterEach(() => {
|
||||
temp?.cleanup()
|
||||
temp = null
|
||||
})
|
||||
|
||||
describe('vault', () => {
|
||||
it('generates a master key on first run', () => {
|
||||
temp = createTempDb()
|
||||
const vault = new Vault(temp.db, fakeKeyring())
|
||||
|
||||
const status = vault.initialize()
|
||||
|
||||
expect(status).toMatchObject({ state: 'unlocked', createdNow: true })
|
||||
expect(vault.isUnlocked).toBe(true)
|
||||
})
|
||||
|
||||
it('reloads the existing master key on later runs', () => {
|
||||
temp = createTempDb()
|
||||
const first = new Vault(temp.db, fakeKeyring())
|
||||
first.initialize()
|
||||
const sealed = first.sealSecret('persisted-secret', SECRET_CONTEXT.credential)
|
||||
|
||||
// A second vault over the same database must recover the same key, or every
|
||||
// stored credential becomes undecryptable on restart.
|
||||
const second = new Vault(temp.db, fakeKeyring())
|
||||
const status = second.initialize()
|
||||
|
||||
expect(status).toMatchObject({ state: 'unlocked', createdNow: false })
|
||||
expect(second.unsealSecret(sealed, SECRET_CONTEXT.credential)).toBe('persisted-secret')
|
||||
})
|
||||
|
||||
it('stores the master key wrapped, never in the clear', () => {
|
||||
temp = createTempDb()
|
||||
const vault = new Vault(temp.db, fakeKeyring())
|
||||
vault.initialize()
|
||||
|
||||
const row = temp.db
|
||||
.prepare('SELECT ciphertext FROM master_key_meta WHERE id = 1')
|
||||
.get() as { ciphertext: Buffer }
|
||||
|
||||
// The keyring prefix proves wrap() was actually called rather than the raw
|
||||
// key being written straight to the row.
|
||||
expect(row.ciphertext.toString('utf8').startsWith('WRAPPED:')).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses to operate when the keyring is not trustworthy', () => {
|
||||
temp = createTempDb()
|
||||
const vault = new Vault(temp.db, insecureKeyring())
|
||||
|
||||
const status = vault.initialize()
|
||||
|
||||
expect(status).toMatchObject({ state: 'unavailable', reason: 'insecure-keyring' })
|
||||
expect(vault.isUnlocked).toBe(false)
|
||||
|
||||
// The hard rule: no silent degradation to weak storage. Storing a password
|
||||
// under a hardcoded key is worse than refusing, because the user believes
|
||||
// it is protected.
|
||||
expect(() => vault.sealSecret('hunter2', SECRET_CONTEXT.credential)).toThrow()
|
||||
})
|
||||
|
||||
it('writes no master key row at all when the keyring is insecure', () => {
|
||||
temp = createTempDb()
|
||||
new Vault(temp.db, insecureKeyring()).initialize()
|
||||
|
||||
const count = temp.db
|
||||
.prepare('SELECT COUNT(*) AS n FROM master_key_meta')
|
||||
.get() as { n: number }
|
||||
|
||||
expect(count.n).toBe(0)
|
||||
})
|
||||
|
||||
it('reports a usable failure when the stored key cannot be unwrapped', () => {
|
||||
temp = createTempDb()
|
||||
const first = new Vault(temp.db, fakeKeyring())
|
||||
first.initialize()
|
||||
|
||||
// Simulates the keyring changing underneath us — a different daemon, a
|
||||
// restored backup, a reimaged machine.
|
||||
const brokenKeyring = { ...fakeKeyring(), unwrap: (): string => {
|
||||
throw new Error('no keyring entry found')
|
||||
} }
|
||||
|
||||
const second = new Vault(temp.db, brokenKeyring)
|
||||
const status = second.initialize()
|
||||
|
||||
expect(status).toMatchObject({ state: 'unavailable', reason: 'unseal-failed' })
|
||||
// The message must name the backend that sealed it, so the condition is
|
||||
// diagnosable rather than an opaque decrypt failure.
|
||||
if (status.state === 'unavailable') {
|
||||
expect(status.detail).toContain('test-keyring')
|
||||
}
|
||||
})
|
||||
|
||||
it('locks, after which secrets can no longer be sealed', () => {
|
||||
temp = createTempDb()
|
||||
const vault = new Vault(temp.db, fakeKeyring())
|
||||
vault.initialize()
|
||||
|
||||
vault.lock()
|
||||
|
||||
expect(vault.isUnlocked).toBe(false)
|
||||
expect(() => vault.sealSecret('hunter2', SECRET_CONTEXT.credential)).toThrow(/locked/)
|
||||
})
|
||||
|
||||
it('rejects a master key of the wrong length', () => {
|
||||
temp = createTempDb()
|
||||
new Vault(temp.db, fakeKeyring()).initialize()
|
||||
|
||||
// A short key would silently weaken AES-256 or make ciphertext
|
||||
// unrecoverable depending on how the crypto layer reacted.
|
||||
const shortKeyring = {
|
||||
...fakeKeyring(),
|
||||
unwrap: (): string => Buffer.from('too-short').toString('base64'),
|
||||
}
|
||||
|
||||
const vault = new Vault(temp.db, shortKeyring)
|
||||
const status = vault.initialize()
|
||||
|
||||
expect(status).toMatchObject({ state: 'unavailable', reason: 'unseal-failed' })
|
||||
})
|
||||
|
||||
it('survives a restart with a fresh Vault over the same file', () => {
|
||||
const db = createTempDb()
|
||||
const path = db.path
|
||||
|
||||
const vault = new Vault(db.db, fakeKeyring())
|
||||
vault.initialize()
|
||||
const sealed = vault.sealSecret('across-restart', SECRET_CONTEXT.credential)
|
||||
closeDatabase(db.db)
|
||||
|
||||
const reopened = openDatabase(path)
|
||||
const afterRestart = new Vault(reopened, fakeKeyring())
|
||||
afterRestart.initialize()
|
||||
|
||||
expect(afterRestart.unsealSecret(sealed, SECRET_CONTEXT.credential)).toBe('across-restart')
|
||||
|
||||
closeDatabase(reopened)
|
||||
temp = { ...db, db: reopened }
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@shared/*": ["./src/shared/*"],
|
||||
"@renderer/*": ["./src/renderer/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "electron.vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@shared/*": ["./src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*",
|
||||
"src/shared/**/*",
|
||||
"tests/**/*",
|
||||
"electron.vite.config.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
// Only Vite's client types — deliberately not @types/node. The renderer has
|
||||
// no Node access at runtime, so letting `process`/`Buffer` typecheck here
|
||||
// would hide a real bug until it crashed.
|
||||
"types": ["vite/client"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@shared/*": ["./src/shared/*"],
|
||||
"@renderer/*": ["./src/renderer/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/renderer/src/**/*", "src/shared/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
// Everything under test is Node-side. The vault takes a Keyring interface
|
||||
// rather than importing Electron's safeStorage, so no Electron process is
|
||||
// needed to exercise the security-critical paths.
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||