docs: rewrite for Nexus Terminal

This commit is contained in:
Andy Dixon
2026-10-11 17:52:35 +01:00
parent cc42a9086d
commit 8c646e7176
5 changed files with 613 additions and 505 deletions
+64 -87
View File
@@ -1,109 +1,86 @@
# Copilot Instructions — Alchementary Link
# Copilot Instructions — Nexus Terminal
Nexus Terminal is a native desktop client (SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub, local shell) written in Rust with an eframe/egui UI. All the code lives in the Cargo workspace under `rust/`.
## Build & Test
```bash
make deps # go mod download + npm install
make build # production build → build/bin/alchementary-link.app
make dev # wails dev with hot reload
make test # go test ./... -v -count=1
make lint # golangci-lint run ./...
cd rust
cargo run --release # the app (binary `nexus-terminal`)
cargo test --workspace # unit and integration tests
cargo clippy --workspace --all-targets -- -D warnings # must be clean
cargo fmt --all --check # must be clean
# Single package test
go test ./internal/storage/ -v -count=1 -run TestProfileCRUD
# Single test
cargo test -p nexus-core --lib storage::
cargo test -p nexus-core --test ssh_integration
```
The frontend has no separate build step — `npm run build` (called by Wails) copies xterm vendor libs from `node_modules` into `frontend/src/js/lib/` and `frontend/src/css/lib/`. All frontend assets are embedded at compile time via `frontend/embed.go`.
CI (`.github/workflows/ci.yml`) runs `cargo test --locked`, `cargo fmt --check` and `cargo clippy --all-targets -D warnings` on Linux, macOS and Windows. The toolchain is pinned in `rust/rust-toolchain.toml`. Platform prerequisites and release packaging are in `docs/build-and-release.md`.
## Architecture
Wails v2 desktop app: Go backend + HTML/CSS/JS frontend rendered in a native webview (WKWebView on macOS).
The project is engine-first: a UI-free core crate, and the egui app is one client of it.
**Go backend** (`internal/`):
- `app/` — lifecycle bootstrap: opens SQLite, inits secret stores, wires everything together
- `api/` — single `Service` struct bound to Wails; every public method is callable from JS as `window.go.api.Service.MethodName()`
- `sessions/` — manages concurrent protocol sessions; owns SSH/Telnet/RAW/GitHub client lifecycles; handles auto-reconnect, login scripts, broadcast
- `storage/` — SQLite repository (WAL mode) with embedded sequential migrations in `storage/migrations/`
- `secrets/` — two-tier credential storage: OS keychain (preferred) → AES-256-GCM encrypted file vault (fallback)
- `models/` — shared domain types and defaults; protocol configs are JSON-encoded in the DB
- `sshclient/` — SSH client with agent, TOTP/2FA, and SOCKS5/HTTP proxy support
- `telnetclient/`, `rawclient/`, `githubclient/` — protocol implementations
- `sftpclient/` — SFTP file operations (browse, upload, download, rename, delete)
- `sshconfig/` — OpenSSH `~/.ssh/config` parser and importer
- `recording/` — asciicast v2 session recording and playback
- `exportimport/` — encrypted profile bundles (.link format)
- `updater/` — GitHub Releases update checker
- `knownhosts/` — SSH host key management
- `validation/` — profile and protocol validation
- `audit/` — timestamped audit logging
```
rust/
├── core/ nexus-core: storage, secrets, models, protocol clients, session Manager, Service. No egui.
│ ├── migrations/ numbered SQL migrations, embedded with include_str!
│ └── tests/ sessions.rs, ssh_integration.rs, fixtures/
└── app/ nexus: the egui shell (binary `nexus-terminal`)
```
**Frontend** (`frontend/`):
- Plain HTML/CSS/JS — no framework, no bundler, no ES modules
- `index.html` is the app shell; loads CSS then JS in strict order
- `src/js/` — 18 modules, each IIFE-wrapped, exporting to `window.AlchementaryLink`
- `src/css/` — modular stylesheets; `tokens.css` defines all design tokens
- Vendor libs (xterm.js, addons) live in `src/js/lib/` and `src/css/lib/` — auto-copied, gitignored
**Core (`rust/core/src/`):**
- `service.rs` — `Service`, the app-facing API: profiles, connect/reconnect, credentials, known hosts, preferences, snippets, import/export, SSH keys, SFTP, updates. File dialogs live in the UI; methods here take paths.
- `sessions.rs` — `Manager`: concurrent sessions, auto-reconnect with backoff, login scripts, broadcast writes, recording hooks, secret resolution (saved / session-only / ask every time), host-key checks. It reports through one `Event` enum (`Data`, `State`, `Closed`, `RawData`, `Irc`, `Github*`, `Forward*`, `Recording*`, `SftpProgress`, `SecretPrompt`, `HostKeyVerify`, `Vnc*`).
- `clients/` — one module per protocol (`ssh`, `sftp`, `telnet`, `raw`, `serial`, `local`, `irc`, `github`, `vnc`). Read the conventions at the top of `clients/mod.rs` before touching one: `connect()` blocks its (worker) thread and returns once usable; the client then owns its reader thread(s) and reports through `Callbacks` (data, fatal error, close exactly once); `write`/`resize`/`close` are callable from any thread and `close` is idempotent. russh-based clients use the shared tokio runtime from `clients::rt()`; the rest use plain threads.
- `storage.rs` (SQLite via rusqlite, WAL), `secrets.rs` (keychain + vault), `config.rs` (data directories and Alchementary Link migration), `models.rs`, `validation.rs`, `exportimport.rs`, `recording.rs` (asciicast v2), `sshconfig.rs`, `updater.rs`, `ircformat.rs`, `terminalenv.rs`, `keyfiles.rs`.
**Go ↔ JS communication:**
- **Synchronous:** Frontend calls `window.go.api.Service.*()` (returns Promise)
- **Asynchronous:** Backend pushes events via `wailsRuntime.EventsEmit()`; frontend listens with `window.runtime.EventsOn()`
- **Bidirectional events** for secret prompts and host key verification: backend emits a request event with a `requestID`, frontend shows a modal, then emits a response event with the same ID. The Go side blocks on a channel keyed by that ID.
**App (`rust/app/src/`):**
- `main.rs` parses flags, opens the `Service` and starts eframe. `app.rs` owns `App`: it installs an event sink that forwards `Event`s over a channel and calls `request_repaint`, drains them each frame (`on_event`), handles `Request`s, the keyboard shortcuts (`shortcuts()`), and lays out the frame.
- `state.rs` — `Shared` (service handle, cached profiles and preferences, tabs, panes, toasts) and the `Request` queue features use to ask the main window to act.
- `views.rs` — `View::{Terminal, Raw, Irc, Github, Vnc}`, one per pane.
- `terminal.rs` (`TermView`: alacritty_terminal drawn with egui's painter), `tabs.rs` (tab strip, workspace, find bar, overlays), `splits.rs` (split tree), `sidebar.rs`, `chrome.rs` (title bar, status bar, command palette `Cmd` enum, prompts, dialogs, toasts).
- Feature windows: `profile_editor.rs`, `quick_connect.rs`, `settings.rs` (including the Help topics), `ssh_keys.rs`, `transfer.rs` (import/export), `sftp.rs`, `forwards.rs`, `snippets.rs`, `triggers.rs`, `recordings.rs`.
- Protocol views: `raw_view.rs`, `irc_view.rs`, `github_view.rs`, `vnc_view.rs`.
Key events: `session:data`, `session:state`, `session:closed`, `session:raw:data`, `session:github:repos`, `session:secret:prompt`/`session:secret:response`, `session:hostkey:verify`/`session:hostkey:response`.
## WKWebView Constraints (Critical)
The macOS webview silently blocks or breaks several standard web APIs. These constraints affect every frontend change:
1. **No `window.confirm()`, `window.prompt()`, `window.alert()`** — they either return false/null silently or do nothing. All user prompts must use the custom modal system (DOM-based backdrop + modal with `is-visible` class toggling).
2. **No ES modules** — `<script type="module">` fails silently. All JS files use plain `<script>` tags and IIFE wrappers: `(function() { ... })();`
3. **Click swallowing on text-selectable elements** — WKWebView initiates a text selection gesture on `mousedown` for `<div>` elements, which swallows the first `click` event (appears to require double-click). Fix: add `mousedown` handler with `e.preventDefault()` on any clickable non-input element (section headers, list items, etc.).
4. **Modal visibility pattern** — both `.modal-backdrop` AND `.modal` elements start with `opacity: 0; visibility: hidden` in CSS. You must add the `is-visible` class to **both** elements, not just the backdrop.
5. **Wails runtime injection** — `window.go` and `window.runtime` are injected by Wails before scripts execute. Do NOT add explicit script tags for Wails scripts. The frontend polls for their availability at startup (max 5s).
**Flow:** UI → `Service` / `Manager` method (plain Rust call) → protocol client on a worker thread → `Callbacks` → `Manager` emits `Event` → app channel → `App::on_event` on the next frame. Credential and host-key questions are `Event::SecretPrompt` / `Event::HostKeyVerify` carrying an `mpsc::Sender` reply; the connecting thread blocks on the receiver, and dropping the sender (closing the prompt or the session) cancels it.
## Conventions
**Language:** All code comments in British English (colour, initialise, behaviour, etc.).
- **British English** in comments, docs and UI strings (colour, initialise, behaviour, favourite).
- **Never block the UI thread.** Anything that touches the network, the keychain, `ssh-keygen` or slow disk work runs on a worker thread: `Shared::spawn` (errors become toasts), `settings::Job` (poll a result each frame), or a module's own inbox. Call `ctx.request_repaint()` when the work finishes. Quick SQLite calls through `Service` are fine inline.
- **Colours come from `Tokens`** (`theme.rs`, `Tokens::get(ctx)`): `text`, `text_dim`, `text_faint`, `accent`, `success`, `warning`, `danger`, `field`, `card`, `dock`, … Don't hard-code colours; all three app themes (Pro, Midnight, Light) must work. Protocol colours and badges come from `views::protocol_color` / `protocol_badge`; terminal palettes from `terminal_themes.rs`.
- **Icons are Phosphor** (`egui_phosphor::regular as icon`), usually as `format!("{} Label", icon::X)`. Reuse the helpers in `widgets.rs` (`primary`, `button`, `icon_button`, `hint`, `empty`, `chip`, `section`, `kv`) and `chrome::modal` for modal dialogs.
- **Shortcut labels** go through `state::keys("Mod+Shift+K")`, which renders ⌘ on macOS and Ctrl elsewhere. A new shortcut needs an entry in `App::shortcuts()`, the command palette (`chrome::commands`), the menu, the Help "Keyboard Shortcuts" topic and the README table.
- **Formatting:** `rustfmt.toml` sets `max_width = 160` with `use_small_heuristics = "Max"`, so long single-line expressions are normal. Run `cargo fmt --all` rather than wrapping by hand.
- Errors are `nexus_core::Result<T>` (`Box<dyn Error + Send + Sync>`); use `?` and `format!` messages that read well in a toast.
- Protocol configs are `Option<…Config>` fields on `Profile` (`ssh`, `telnet`, `raw`, `github`, `vnc`, `irc`, `serial`), stored as JSON columns; `Profile::new(protocol)` fills the defaults, and the structs use `#[serde(default)]` so older rows still load.
- A `ponytail:` comment marks a deliberate simplification and names its limit; keep them accurate when you change that code.
**Go patterns:**
- String-typed enums: `type Protocol string` with `const ProtocolSSH Protocol = "ssh"` etc.
- Protocol configs are pointer fields on `Profile` (`SSH *SSHConfig`, `Telnet *TelnetConfig`, etc.) — nil when not applicable
- `NewProfile(protocol)` factory in `defaults.go` populates sensible defaults per protocol
- Storage uses `nullableJSON()` helper to write nil for unused config columns
- Tests use `t.TempDir()` for isolation, `t.Fatalf()` for setup failures
**Adding a protocol** touches: `models.rs` (`Protocol`, config struct, defaults, labels), `validation.rs`, a new migration (CHECK constraint and config column) plus the column lists in `storage.rs`, a client in `clients/`, the `Manager` connect/close/write paths in `sessions.rs`, the profile editor section, Quick Connect if relevant, a view (or `TermView` for terminal protocols), `views::protocol_badge`/`protocol_color`, the palette, and a Help topic.
**Frontend patterns:**
- Every JS file is IIFE-wrapped and exports to `window.AlchementaryLink` namespace
- Script load order matters (defined in `index.html`) — utils → state → notifications → sidebar → tabs → themes → terminal → profiles → raw → github → settings → commands → secrets → recordings → sftp → splitpane → snippets → app
- DOM helpers: `createElement(tag, className, attrs)` from utils.js (supports `textContent`, `innerHTML`, `dataset`, `on*` event handlers)
- Profile editor uses `_renderSection(title, fields)` pattern with `_field(type, name, label, value, opts)` helper for form generation
- Login script editor uses `_renderLoginScriptSection(prefix, steps)` for expect-style step lists
- TOTP section uses `_renderTOTPSection(profile)` with encrypted vault storage
- `protocolColour()` returns CSS class names like `proto-ssh`; tab badges use `badge-ssh` class naming (different pattern)
## Data compatibility (hard rules)
**CSS patterns:**
- Design tokens in `tokens.css`: `--bg-base`, `--bg-surface`, `--bg-elevated`, `--text-primary`, `--accent`, `--protocol-ssh`, etc.
- Dark charcoal palette — not Material Design literally, but Material-inspired layering
- Protocol colours: SSH=teal `#4ecdc4`, Telnet=amber `#ffa726`, RAW=purple `#ab47bc`, GitHub=indigo `#7c8aff`
Existing users upgrade in place, and their data came from the Go app first. Nexus Terminal must keep reading it.
**Adding a new protocol:**
1. Add `Protocol` constant + config struct in `models/models.go`
2. Add defaults in `models/defaults.go` `NewProfile()` switch
3. Create client package in `internal/<proto>client/`
4. Add `connect<Proto>()` in `sessions/manager.go` + case in `Connect()` switch
5. Add API methods in `api/api.go` if needed
6. Add DB migration in `storage/migrations/` (update CHECK constraint, add config column)
7. Update `storage/store.go`: all SELECT/INSERT/UPDATE queries + both scan functions
8. Frontend: add DEFAULTS in `profiles.js`, render sections, collect form values, protocol tab
9. Frontend: add viewer in new JS file (IIFE-wrapped), include in `index.html`, wire in `app.js`
10. Add CSS: protocol colour token, sidebar indicator, tab badge, viewer styles
- **Never change the database, vault, export or recording formats incompatibly.** The vault (`vault/vault.json`: Argon2id t=3, m=128 MiB, 4 lanes; AES-256-GCM per secret), the export envelope (`[version][32-byte salt][12-byte nonce][ciphertext]`, `.nexus`; `.link` must still import) and asciicast v2 recordings are fixed.
- **Fixtures** generated by the old Go app live in `rust/core/tests/fixtures/` (`link.db`, `vault/`, `profiles.link`, `recordings/`). Tests in `storage.rs`, `secrets.rs`, `exportimport.rs` and `recording.rs` read them. Don't regenerate or edit them to make a test pass.
- **Schema changes are new SQL files** in `rust/core/migrations/` (`008_….sql`, …), added to `MIGRATIONS` in `storage.rs`. Each ends with `INSERT INTO schema_version (version, applied_at) VALUES (N, datetime('now'));`. Never edit an applied migration. SQLite has no `ALTER COLUMN`; use the recreate-table pattern.
- **Keychain** entries are stored under the service `NexusTerminal`; reads fall back to `AlchementaryLink` and copy forward. Don't change key names (`profile:<id>:<kind>`).
- **Data directory** names (`NexusTerminal`, `nexus.db`, `vault/`, `recordings/`) and the first-run migration from `AlchementaryLink/` in `config.rs` are user-facing; keep them stable.
**Database migrations:**
- Numbered SQL files: `001_initial.sql`, `002_github_protocol.sql`, etc.
- Each must `INSERT INTO schema_version (version, applied_at) VALUES (N, datetime('now'))` at the end
- SQLite has no `ALTER COLUMN` — use the recreate-table pattern (CREATE new → INSERT INTO new SELECT FROM old → DROP old → ALTER TABLE RENAME)
- Every query in `store.go` that touches the profiles table must be updated when columns change (there are ~8 SELECT column lists + 2 scan functions)
## Testing tips
- **Never point a test or manual run at your real data.** Use `--data-dir DIR` (a temporary directory) for manual runs; tests create their own directory under `std::env::temp_dir()`, as the existing ones do.
- **App flags** (`rust/app/src/main.rs`):
- `--data-dir DIR` — use another data directory.
- `--screenshot out.png` — render, save a PNG and exit (works under `xvfb-run`); combine with `--size WxH` and `--theme pro|midnight|light`.
- `--open WINDOW` — open a window at startup: `editor[:protocol|id]`, `quick`, `settings[:terminal|security|knownhosts|help|about]`, `keys`, `snippets`, `triggers`, `recordings`, `palette`, `export`, `import`.
- `--connect NAME` — connect a saved profile by display name or id.
- `--run-local CMD` — open a local terminal and type `CMD` into it once connected.
For example: `cargo run -- --data-dir /tmp/nx --open settings:help --screenshot /tmp/help.png`.
- **SSH integration tests** (`core/tests/ssh_integration.rs`) start a throwaway `sshd` as the current user on a random port and skip themselves when OpenSSH isn't installed. They **must never open an interactive login shell** — the user's shell profile can auto-attach tmux/byobu to a real session. Set the profile's startup command to `/bin/sh -i`, as the existing `profile()` helper does.
- `core/tests/sessions.rs` drives the `Manager` end to end against local TCP listeners; follow its `manager()` and `wait()` helpers for event-driven tests.
- UI logic that can be tested without a window (parsers, fuzzy matching, split trees, snippet variables, trigger matching) has `#[cfg(test)]` unit tests next to it; add one with any non-trivial change.