OpenTerminal v0.2.1: terminal suite with SSH, split panes, themes and keyword highlighting
- Local terminal (node-pty) + SSH sessions (ssh2) with unified data plane - dockview split panes, per-group '+' tab bar button, layout templates - Session replay buffer (late-subscriber catch-up), host key pinning, credential encryption via safeStorage - 12 builtin themes + custom theme editor, system font enumeration - Keyword highlighting: regex rules with priority, ANSI truecolor injection, chunk-boundary-safe stream (carry-over of partial escape sequences) - Dark antd theme app-wide, settings dialog redesign, error boundary - Tests: ssh loopback, session e2e, highlight split-chunk regression
This commit is contained in:
commit
551723c2d4
58 files changed
+11929
No files matched your search
+239
@@ -0,0 +1,239 @@
|
||||
import { spawn, type IPty } from '@lydell/node-pty'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
import { Ipc, type PtyCreateOptions, type PtyCreateResult } from '../shared/ipc'
|
||||
import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../shared/connections'
|
||||
import { broadcast } from './broadcast'
|
||||
import { connectSsh, type SshSessionHandle } from './ssh'
|
||||
import type { HostKeyCheckResult } from './knownHosts'
|
||||
|
||||
/**
|
||||
* Session routing table. A session is either a local pty or an established ssh
|
||||
* shell; the generic PTY_* (data plane) channels address both. PTY_DATA /
|
||||
* PTY_EXIT broadcasts are identical for both kinds.
|
||||
*/
|
||||
type Session = { kind: 'local'; pty: IPty } | { kind: 'ssh'; ssh: SshSessionHandle }
|
||||
|
||||
const sessions = new Map<string, Session>()
|
||||
|
||||
/**
|
||||
* Per-session replay of recent output (capped). Late subscribers — a terminal
|
||||
* view mounting after the shell already printed its banner, or a panel rebound
|
||||
* by template apply — read this instead of losing the head of the stream.
|
||||
*/
|
||||
const REPLAY_CAP = 64 * 1024
|
||||
const replayBuffers = new Map<string, string>()
|
||||
|
||||
function appendReplay(id: string, data: string): void {
|
||||
const prev = replayBuffers.get(id) ?? ''
|
||||
const next = prev.length + data.length > REPLAY_CAP
|
||||
? (prev + data).slice(prev.length + data.length - REPLAY_CAP)
|
||||
: prev + data
|
||||
replayBuffers.set(id, next)
|
||||
}
|
||||
|
||||
/** Recent output of a session ('' when unknown). */
|
||||
export function getSessionReplay(id: string): string {
|
||||
return replayBuffers.get(id) ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies injected once by ipc.ts (configureSessionRuntime) so pty.ts
|
||||
* stays free of store / known-hosts imports and the ssh service can remain
|
||||
* decoupled from Electron.
|
||||
*/
|
||||
export interface SessionRuntimeDeps {
|
||||
/** resolve a bookmark by id (throws a Chinese message when missing) */
|
||||
getConnection(connectionId: string): SshConnection
|
||||
/** decrypt a stored secret for a connection */
|
||||
getSecret(conn: SshConnection, field: 'password' | 'keyContent' | 'passphrase'): string | undefined
|
||||
/** mark a bookmark as recently connected */
|
||||
touch(connectionId: string): void
|
||||
/** host key pinning: check against the store, record an accepted key */
|
||||
knownHosts: {
|
||||
check(host: string, port: number, key: Buffer): HostKeyCheckResult
|
||||
accept(host: string, port: number, key: Buffer, fingerprint: string): void
|
||||
}
|
||||
/** ask the renderer to decide an unknown / changed host key */
|
||||
promptHostKey(prompt: HostKeyPromptEvent): void
|
||||
broadcast(channel: string, ...args: unknown[]): void
|
||||
}
|
||||
|
||||
let runtimeDeps: SessionRuntimeDeps | undefined
|
||||
|
||||
/** Wire the real stores + renderer prompt. Called once during app startup. */
|
||||
export function configureSessionRuntime(deps: SessionRuntimeDeps): void {
|
||||
runtimeDeps = deps
|
||||
}
|
||||
|
||||
function defaultShell(): string {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return 'powershell.exe'
|
||||
case 'darwin':
|
||||
return process.env.SHELL || '/bin/zsh'
|
||||
default:
|
||||
return process.env.SHELL || '/bin/bash'
|
||||
}
|
||||
}
|
||||
|
||||
export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
|
||||
const id = randomUUID()
|
||||
const shell = opts.shell ?? defaultShell()
|
||||
const cwd = opts.cwd ?? homedir()
|
||||
const env = { ...process.env, ...opts.env } as Record<string, string>
|
||||
|
||||
const pty = spawn(shell, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd, env })
|
||||
sessions.set(id, { kind: 'local', pty })
|
||||
replayBuffers.set(id, '')
|
||||
|
||||
pty.onData((data) => {
|
||||
try {
|
||||
appendReplay(id, data)
|
||||
broadcast(Ipc.PTY_DATA, { id, data })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
})
|
||||
|
||||
pty.onExit(({ exitCode }) => {
|
||||
try {
|
||||
sessions.delete(id)
|
||||
broadcast(Ipc.PTY_EXIT, { id, exitCode })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
})
|
||||
|
||||
return { id, shell, cwd }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a session. `local` reuses createPty; `ssh` goes through the ssh service
|
||||
* and resolves only once the shell stream is ready to stream data.
|
||||
*/
|
||||
export async function openSession(opts: SessionOpenOptions): Promise<{ id: string }> {
|
||||
if (opts.kind === 'local') {
|
||||
return { id: createPty().id }
|
||||
}
|
||||
|
||||
const deps = runtimeDeps
|
||||
if (!deps) {
|
||||
throw new Error('SSH 会话服务尚未初始化')
|
||||
}
|
||||
if (!opts.connectionId) {
|
||||
throw new Error('SSH 会话缺少 connectionId')
|
||||
}
|
||||
|
||||
const conn = deps.getConnection(opts.connectionId)
|
||||
const handle = await connectSsh(conn, opts.secretOverride, {
|
||||
connections: {
|
||||
getSecret: (c, field) => deps.getSecret(c, field),
|
||||
touch: (id: string) => {
|
||||
// Successful connect: record lastConnectedAt on the bookmark.
|
||||
try {
|
||||
deps.touch(id)
|
||||
} catch {
|
||||
// store write failure must not break the session
|
||||
}
|
||||
}
|
||||
},
|
||||
knownHosts: deps.knownHosts,
|
||||
broadcast: deps.broadcast,
|
||||
promptHostKey: deps.promptHostKey
|
||||
})
|
||||
sessions.set(handle.id, { kind: 'ssh', ssh: handle })
|
||||
|
||||
handle.stream.on('data', (data: Buffer) => {
|
||||
try {
|
||||
const text = data.toString('utf8')
|
||||
appendReplay(handle.id, text)
|
||||
deps.broadcast(Ipc.PTY_DATA, { id: handle.id, data: text })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
})
|
||||
|
||||
handle.stream.on('close', () => {
|
||||
try {
|
||||
sessions.delete(handle.id)
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
})
|
||||
|
||||
return { id: handle.id }
|
||||
}
|
||||
|
||||
export function writePty(id: string, data: string): void {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
try {
|
||||
if (session.kind === 'local') session.pty.write(data)
|
||||
else session.ssh.stream.write(data)
|
||||
} catch {
|
||||
// session may already be dead
|
||||
}
|
||||
}
|
||||
|
||||
export function resizePty(id: string, cols: number, rows: number): void {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
try {
|
||||
if (session.kind === 'local') session.pty.resize(cols, rows)
|
||||
// ssh2 Channel.setWindow(rows, cols, height, width)
|
||||
else session.ssh.stream.setWindow(rows, cols, 0, 0)
|
||||
} catch {
|
||||
// session may already be dead
|
||||
}
|
||||
}
|
||||
|
||||
export function killPty(id: string): void {
|
||||
replayBuffers.delete(id)
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
if (session.kind === 'ssh') {
|
||||
try {
|
||||
session.ssh.stream.close()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
try {
|
||||
session.ssh.client.end()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
session.pty.kill()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
sessions.delete(id)
|
||||
}
|
||||
|
||||
export function killAllPtys(): void {
|
||||
for (const session of sessions.values()) {
|
||||
if (session.kind === 'ssh') {
|
||||
try {
|
||||
session.ssh.stream.close()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
try {
|
||||
session.ssh.client.end()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
session.pty.kill()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
sessions.clear()
|
||||
}
|
||||
Reference in new issue
Block a user