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:
954801926@qq.com committed 2026-09-06 11:19:08 +08:00
commit 551723c2d4
58 files changed
+11929

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
import { BrowserWindow } from 'electron'
/** Send `payload` to every live BrowserWindow. */
export function broadcast(channel: string, ...args: unknown[]): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(channel, ...args)
}
}
+219
View File
@@ -0,0 +1,219 @@
/**
* SSH connection bookmarks. Persists to <userData>/connections.json as an array.
*
* Secret fields (password / keyContent / passphrase) are persisted only
* encrypted via Electron safeStorage (base64 blob in `*_enc` fields). The
* renderer contracts (`SshConnection`) never contain a secret: `*_enc` fields
* are stripped from the returned objects and replaced with `savedAuth` flags.
*
* When safeStorage is unavailable (e.g. during headless dev) the plaintext
* secret is base64'd and prefixed with `plain:` so the record stays
* serialisable — a dev-only fallback surfaced via console.warn.
*/
import { safeStorage } from 'electron'
import { randomUUID } from 'crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname } from 'path'
import type { SshAuthMethod, SshConnection, SshConnectionInput } from '../shared/connections'
const ENC_SUFFIX = '_enc'
const PLAIN_PREFIX = 'plain:'
const SECRET_KEYS = ['password', 'keyContent', 'passphrase'] as const
/** Internal persisted record: public fields + encrypted secret fields. */
interface StoredConnection {
id: string
name: string
group?: string
host: string
port: number
username: string
auth: SshAuthMethod
askPasswordAtConnect: boolean
askPassphraseAtConnect: boolean
keyPath?: string
keepaliveIntervalSec: number
createdAt: number
lastConnectedAt?: number
password_enc?: string
keyContent_enc?: string
passphrase_enc?: string
}
/** Public fields of SshConnectionInput reflected onto a StoredConnection. */
const PUBLIC_KEYS = [
'name',
'group',
'host',
'port',
'username',
'auth',
'askPasswordAtConnect',
'askPassphraseAtConnect',
'keyPath',
'keepaliveIntervalSec'
] as const satisfies readonly (keyof Omit<StoredConnection, 'password_enc' | 'keyContent_enc' | 'passphrase_enc'>)[]
function encrypt(plain: string): string {
if (safeStorage.isEncryptionAvailable()) {
return safeStorage.encryptString(plain).toString('base64')
}
// Dev fallback: no OS keychain, store a reversible base64 marker so the
// field still round-trips through JSON.
console.warn(
'[connections] safeStorage unavailable - storing plain: prefixed base64 secret (dev only)'
)
return PLAIN_PREFIX + Buffer.from(plain, 'utf8').toString('base64')
}
function decrypt(stored: string | undefined): string | undefined {
if (typeof stored !== 'string' || stored.length === 0) return undefined
if (stored.startsWith(PLAIN_PREFIX)) {
return Buffer.from(stored.slice(PLAIN_PREFIX.length), 'base64').toString('utf8')
}
if (safeStorage.isEncryptionAvailable()) {
try {
return safeStorage.decryptString(Buffer.from(stored, 'base64'))
} catch {
return undefined
}
}
return undefined
}
/** Strip secret fields from an internal record into the renderer contract. */
function toPublic(stored: StoredConnection): SshConnection {
return {
id: stored.id,
name: stored.name,
group: stored.group,
host: stored.host,
port: stored.port,
username: stored.username,
auth: stored.auth,
askPasswordAtConnect: stored.askPasswordAtConnect,
askPassphraseAtConnect: stored.askPassphraseAtConnect,
keyPath: stored.keyPath,
keepaliveIntervalSec: stored.keepaliveIntervalSec,
createdAt: stored.createdAt,
lastConnectedAt: stored.lastConnectedAt,
savedAuth: {
hasPassword: stored.password_enc !== undefined && stored.password_enc.length > 0,
hasKeyContent: stored.keyContent_enc !== undefined && stored.keyContent_enc.length > 0,
hasPassphrase: stored.passphrase_enc !== undefined && stored.passphrase_enc.length > 0
}
}
}
export class ConnectionsStore {
constructor(private readonly filePath: string) {}
private load(): StoredConnection[] {
try {
const raw: unknown = JSON.parse(readFileSync(this.filePath, 'utf8'))
if (Array.isArray(raw)) {
return raw.filter(
(x): x is StoredConnection =>
x !== null &&
typeof x === 'object' &&
typeof (x as StoredConnection).id === 'string' &&
typeof (x as StoredConnection).host === 'string'
)
}
} catch {
// missing / corrupted file -> start fresh
}
return []
}
private save(list: StoredConnection[]): void {
mkdirSync(dirname(this.filePath), { recursive: true })
writeFileSync(this.filePath, JSON.stringify(list, null, 2), 'utf8')
}
listConnections(): SshConnection[] {
return this.load().map(toPublic)
}
/** Decrypt a stored secret for a connection (undefined when absent). */
/** Decrypt a stored secret for a connection (undefined when absent). */
getSecret(
conn: SshConnection,
field: 'password' | 'keyContent' | 'passphrase'
): string | undefined {
const encField = `${field}${ENC_SUFFIX}` as keyof StoredConnection
const stored = this.load().find((c) => c.id === conn.id)
return stored ? decrypt(stored[encField] as string | undefined) : undefined
}
saveConnection(input: SshConnectionInput): SshConnection {
const list = this.load()
let stored: StoredConnection | undefined
if (typeof input.id === 'string' && input.id.length > 0) {
stored = list.find((c) => c.id === input.id)
}
if (stored) {
// Update in place; omitted secrets keep their previously stored value.
const target = stored as unknown as Record<string, unknown>
const source = input as unknown as Record<string, unknown>
for (const key of PUBLIC_KEYS) {
target[key] = source[key]
}
} else {
stored = {
id: input.id && input.id.length > 0 ? input.id : randomUUID(),
name: input.name,
group: input.group,
host: input.host,
port: input.port,
username: input.username,
auth: input.auth,
askPasswordAtConnect: input.askPasswordAtConnect,
askPassphraseAtConnect: input.askPassphraseAtConnect,
keyPath: input.keyPath,
keepaliveIntervalSec: input.keepaliveIntervalSec,
createdAt: Date.now()
}
list.push(stored)
}
// Encrypt new secret values; absent secrets leave the previous value intact.
const target = stored as unknown as Record<string, unknown>
for (const key of SECRET_KEYS) {
const value = input[key]
if (value === undefined) continue
if (value === '') {
delete target[`${key}${ENC_SUFFIX}`]
} else {
target[`${key}${ENC_SUFFIX}`] = encrypt(value)
}
}
this.save(list)
return toPublic(stored)
}
deleteConnection(id: string): void {
const list = this.load()
const next = list.filter((c) => c.id !== id)
if (next.length < list.length) this.save(next)
}
/** Mark a connection as recently used. */
touch(id: string, at = Date.now()): void {
const list = this.load()
const conn = list.find((c) => c.id === id)
if (!conn) return
conn.lastConnectedAt = at
this.save(list)
}
}
/** Default location: <userData>/connections.json */
export function defaultConnectionsPath(userDataPath: string): string {
return `${userDataPath}/connections.json`
}
+58
View File
@@ -0,0 +1,58 @@
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'path'
import { registerIpc } from './ipc'
import { killAllPtys } from './pty'
function createWindow(): void {
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 720,
minHeight: 480,
show: false,
backgroundColor: '#1e1e1e',
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
win.on('ready-to-show', () => win.show())
// Surface renderer console errors in the dev terminal for diagnosis.
win.webContents.on('console-message', (event) => {
if (event.level === 'error') {
console.error(`[renderer] ${event.message}`)
}
})
win.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl) {
win.loadURL(devUrl)
} else {
win.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
registerIpc()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('before-quit', () => {
killAllPtys()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+80
View File
@@ -0,0 +1,80 @@
import { app, ipcMain } from 'electron'
import fontList from 'font-list'
import { homedir } from 'os'
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts'
import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay } from './pty'
import { registerSettingsIpc } from './settingsStore'
import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore'
import { KnownHostsStore, defaultKnownHostsPath } from './knownHosts'
import { resolveHostKey } from './ssh'
import { broadcast } from './broadcast'
export function registerIpc(): void {
const connectionsStore = new ConnectionsStore(defaultConnectionsPath(app.getPath('userData')))
const knownHostsStore = new KnownHostsStore(defaultKnownHostsPath(app.getPath('userData')))
// Runtime deps for the session layer (pty.ts routes into ssh.ts, which stays
// Electron-free).
configureSessionRuntime({
broadcast: (channel, ...args) => broadcast(channel, ...args),
getConnection: (connectionId) => {
const found = connectionsStore.listConnections().find((c) => c.id === connectionId)
if (!found) throw new Error(`连接书签不存在 (${connectionId})`)
return found
},
getSecret: (conn, field) => connectionsStore.getSecret(conn, field),
touch: (id) => connectionsStore.touch(id),
knownHosts: {
check: (host, port, key) => knownHostsStore.check(host, port, key),
accept: (host, port, key, fingerprint) => knownHostsStore.accept(host, port, key, fingerprint)
},
promptHostKey: (prompt) => broadcast(Ipc.HOSTKEY_PROMPT, prompt)
})
ipcMain.handle(
Ipc.APP_INFO,
(): AppInfo => ({ platform: process.platform, appVersion: app.getVersion(), homeDir: homedir() })
)
ipcMain.handle(Ipc.PTY_CREATE, (_event, opts?: PtyCreateOptions) => createPty(opts))
ipcMain.handle(Ipc.SESSION_OPEN, (_event, opts: SessionOpenOptions) => openSession(opts))
ipcMain.handle(Ipc.SESSION_REPLAY, (_event, id: string) => getSessionReplay(id))
ipcMain.on(Ipc.PTY_WRITE, (_event, id: string, data: string) => writePty(id, data))
ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) =>
resizePty(id, cols, rows)
)
ipcMain.on(Ipc.PTY_KILL, (_event, id: string) => killPty(id))
// ---- ssh connections (bookmarks) ----
ipcMain.handle(Ipc.CONNECTIONS_LIST, (): SshConnection[] => connectionsStore.listConnections())
ipcMain.handle(Ipc.CONNECTIONS_SAVE, (_event, input: SshConnectionInput): SshConnection =>
connectionsStore.saveConnection(input)
)
ipcMain.handle(Ipc.CONNECTIONS_DELETE, (_event, id: string) => {
connectionsStore.deleteConnection(id)
})
// HOSTKEY_PROMPT is broadcast; the renderer answers here (send, not handle).
ipcMain.on(Ipc.HOSTKEY_RESPOND, (_event, promptId: string, action: HostKeyAction) => {
resolveHostKey(promptId, action)
})
ipcMain.handle(Ipc.FONTS_LIST, async () => {
try {
return await fontList.getFonts({ disableQuoting: true })
} catch {
return []
}
})
ipcMain.handle(Ipc.LAYOUTS_LIST, (): LayoutMeta[] => listLayouts())
ipcMain.handle(Ipc.LAYOUTS_GET, (_event, id: string) => getLayout(id))
ipcMain.handle(Ipc.LAYOUTS_SAVE, (_event, meta: LayoutMeta, json: string) =>
saveLayout(meta, json)
)
ipcMain.handle(Ipc.LAYOUTS_DELETE, (_event, id: string) => deleteLayout(id))
registerSettingsIpc()
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Host key pinning store. Persists to <userData>/ssh_known_hosts.json.
* No Electron imports here: the file location is injected so the module can be
* reused by the loopback test harness (and any future main-process unit tests).
*/
import { createHash, randomUUID } from 'crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname } from 'path'
export interface KnownHostEntry {
/** uuid; addedAt is split out so fingerprint clash updates can be precise */
id: string
host: string
port: number
/** raw ssh wire-format host key (base64, no padding) */
keyBase64: string
/** 'SHA256:' + base64(sha256(key)) without padding, OpenSSH style */
fingerprint: string
addedAt: number
}
export interface KnownHostsStoreShape {
version: 1
entries: KnownHostEntry[]
}
export type HostKeyCheckResult =
| { status: 'match'; entry: KnownHostEntry }
| { status: 'new' }
| { status: 'changed'; stored: KnownHostEntry }
/** sha256 fingerprint in ssh "SHA256:..." style (no padding) */
export function fingerprintOf(key: Buffer): string {
return 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '')
}
export class KnownHostsStore {
constructor(private readonly filePath: string) {}
private load(): KnownHostsStoreShape {
try {
const raw: unknown = JSON.parse(readFileSync(this.filePath, 'utf8'))
if (raw !== null && typeof raw === 'object') {
const shape = raw as Partial<KnownHostsStoreShape>
if (Array.isArray(shape.entries)) {
return {
version: 1,
entries: shape.entries.filter(
(e): e is KnownHostEntry =>
e !== null &&
typeof e === 'object' &&
typeof (e as KnownHostEntry).host === 'string' &&
typeof (e as KnownHostEntry).keyBase64 === 'string'
)
}
}
}
} catch {
// missing / corrupted file -> start fresh
}
return { version: 1, entries: [] }
}
private save(shape: KnownHostsStoreShape): void {
mkdirSync(dirname(this.filePath), { recursive: true })
writeFileSync(this.filePath, JSON.stringify(shape, null, 2), 'utf8')
}
/**
* Compare the live host key against the stored entry for (host, port).
*/
check(host: string, port: number, key: Buffer): HostKeyCheckResult {
const entries = this.load().entries.filter((e) => e.host === host && e.port === port)
if (entries.length === 0) return { status: 'new' }
const fingerprint = fingerprintOf(key)
const keyBase64 = key.toString('base64')
const stored = entries[0]
if (stored.keyBase64 === keyBase64 || stored.fingerprint === fingerprint) {
return { status: 'match', entry: stored }
}
return { status: 'changed', stored }
}
/** Record a new host key (accept of a 'new' or 'changed' prompt). */
accept(host: string, port: number, key: Buffer, fingerprint: string): KnownHostEntry {
const shape = this.load()
const entry: KnownHostEntry = {
id: randomUUID(),
host,
port,
keyBase64: key.toString('base64'),
fingerprint,
addedAt: Date.now()
}
shape.entries = shape.entries.filter((e) => !(e.host === host && e.port === port))
shape.entries.push(entry)
this.save(shape)
return entry
}
list(): KnownHostEntry[] {
return [...this.load().entries]
}
}
/** Default location: <userData>/ssh_known_hosts.json */
export function defaultKnownHostsPath(userDataPath: string): string {
return `${userDataPath}/ssh_known_hosts.json`
}
+61
View File
@@ -0,0 +1,61 @@
import { app } from 'electron'
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import type { LayoutMeta } from '../shared/ipc'
const layoutsDir = (): string => {
const dir = join(app.getPath('userData'), 'layouts')
mkdirSync(dir, { recursive: true })
return dir
}
function layoutPath(id: string): string {
return join(layoutsDir(), `${id}.json`)
}
interface LayoutFile {
id: string
name: string
createdAt: number
json: string
}
export function listLayouts(): LayoutMeta[] {
const dir = layoutsDir()
const metas: LayoutMeta[] = []
for (const entry of readdirSync(dir)) {
if (!entry.endsWith('.json')) continue
try {
const file = JSON.parse(readFileSync(join(dir, entry), 'utf8')) as Partial<LayoutFile>
const id = typeof file.id === 'string' ? file.id : entry.slice(0, -'.json'.length)
const name = typeof file.name === 'string' ? file.name : id
const createdAt = typeof file.createdAt === 'number' ? file.createdAt : 0
metas.push({ id, name, createdAt })
} catch {
// skip corrupted file
}
}
return metas.sort((a, b) => b.createdAt - a.createdAt)
}
export function getLayout(id: string): string | null {
try {
const file = JSON.parse(readFileSync(layoutPath(id), 'utf8')) as Partial<LayoutFile>
return typeof file.json === 'string' ? file.json : null
} catch {
return null
}
}
export function saveLayout(meta: LayoutMeta, json: string): void {
const file: LayoutFile = { id: meta.id, name: meta.name, createdAt: meta.createdAt, json }
writeFileSync(layoutPath(meta.id), JSON.stringify(file, null, 2), 'utf8')
}
export function deleteLayout(id: string): void {
try {
rmSync(layoutPath(id))
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
}
+239
View File
@@ -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()
}
+109
View File
@@ -0,0 +1,109 @@
import { app, ipcMain } from 'electron'
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
import { join } from 'path'
import { Ipc } from '../shared/ipc'
import {
DEFAULT_HIGHLIGHT_RULES,
DEFAULT_SETTINGS,
type AppSettings,
type HighlightRule,
type TerminalSettings
} from '../shared/settings'
import type { TerminalTheme } from '../shared/theme'
import { broadcast } from './broadcast'
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[])
/** Light structural check for a persisted highlight rule. */
function isHighlightRule(value: unknown): value is HighlightRule {
if (value === null || typeof value !== 'object') return false
const rule = value as Record<string, unknown>
return (
typeof rule.id === 'string' &&
typeof rule.pattern === 'string' &&
typeof rule.enabled === 'boolean' &&
typeof rule.priority === 'number' &&
rule.color !== null &&
typeof rule.color === 'object' &&
typeof (rule.color as { fg?: unknown }).fg === 'string'
)
}
function sanitizeRules(value: unknown): HighlightRule[] {
if (!Array.isArray(value)) return DEFAULT_HIGHLIGHT_RULES
const rules = value.filter(isHighlightRule)
return rules.length > 0 ? rules : DEFAULT_HIGHLIGHT_RULES
}
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
const errors: string[] = []
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
let customThemes: unknown = DEFAULT_SETTINGS.customThemes
let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES
if (raw !== null && typeof raw === 'object') {
const packageSettings = raw as { terminal?: unknown; customThemes?: unknown; highlightRules?: unknown }
if (packageSettings.terminal !== null && typeof packageSettings.terminal === 'object') {
const candidate = packageSettings.terminal as Record<string, unknown>
const merged: Record<string, unknown> = { ...terminal }
for (const key of TERMINAL_KEYS) {
const keyType = typeof DEFAULT_SETTINGS.terminal[key]
if (candidate[key] !== undefined && typeof candidate[key] === keyType) {
merged[key] = candidate[key]
} else if (candidate[key] !== undefined) {
errors.push(`terminal.${key}`)
}
}
terminal = merged as unknown as TerminalSettings
}
if (Array.isArray(packageSettings.customThemes)) {
customThemes = packageSettings.customThemes
}
if (packageSettings.highlightRules !== undefined) {
highlightRules = packageSettings.highlightRules
}
}
const themes: TerminalTheme[] = Array.isArray(customThemes) ? (customThemes as TerminalTheme[]) : []
return { settings: { terminal, customThemes: themes, highlightRules: sanitizeRules(highlightRules) }, errors }
}
export function loadSettings(): AppSettings {
try {
const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8'))
const { settings } = deepMerge(raw)
return settings
} catch {
return {
terminal: { ...DEFAULT_SETTINGS.terminal },
customThemes: [...DEFAULT_SETTINGS.customThemes],
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule }))
}
}
}
export function saveSettings(next: AppSettings): AppSettings {
const merged = deepMerge({
terminal: next.terminal,
customThemes: next.customThemes,
highlightRules: next.highlightRules
}).settings
const pretty = JSON.stringify(merged, null, 2)
mkdirSync(app.getPath('userData'), { recursive: true })
const path = settingsPath()
const tmp = `${path}.tmp`
writeFileSync(tmp, pretty, 'utf8')
renameSync(tmp, path)
broadcast(Ipc.SETTINGS_CHANGED, merged)
return merged
}
export function registerSettingsIpc(): void {
ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings())
ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: AppSettings) => saveSettings(next))
}
+303
View File
@@ -0,0 +1,303 @@
/**
* SSH session service (M2).
*
* Deliberately decoupled from Electron: broadcast and host-key store are
* injected (`SshServiceDeps`), so the connect / verify / shell flow can be
* exercised by the pure-node loopback harness (tests/ssh-loopback.mjs) with a
* plain ssh2 client and no Chromium runtime.
*
* Host-key verification pauses the handshake inside the `hostVerifier`
* callback: ssh2's kex suspends until `verify()` is called, which is exactly
* what we need for store-check + renderer prompt + accept-record. The 15s
* connect timeout is paused while a host-key prompt is outstanding so the
* prompt's own 30s budget governs that wait.
*/
import type { SshConnection, SshSecretOverride } from '../shared/connections'
import { Ipc } from '../shared/ipc'
import { randomUUID } from 'crypto'
import { readFileSync } from 'fs'
import { fingerprintOf, type HostKeyCheckResult } from './knownHosts'
import type { ConnectionsStore } from './connectionsStore'
import type { Client, ClientChannel, ConnectConfig } from 'ssh2'
export interface SshSessionHandle {
id: string
/** established ssh connection; powers the session routing in pty.ts */
client: Client
/** open interactive shell channel (ClientChannel, a Duplex) */
stream: ClientChannel
}
export interface SshServiceDeps {
/** resolves saved secrets (service never writes them) */
connections: Pick<ConnectionsStore, 'getSecret' | 'touch'>
/** host key pinning store access */
knownHosts: {
check(host: string, port: number, key: Buffer): HostKeyCheckResult
accept(host: string, port: number, key: Buffer, fingerprint: string): void
}
/** electron-free broadcast; matches main/broadcast.ts */
broadcast(channel: string, ...args: unknown[]): void
/**
* Ask the renderer to decide on an unknown / changed host key. The prompt
* resolves via the module-level `resolveHostKey(promptId, action)`.
*/
promptHostKey(prompt: {
promptId: string
host: string
port: number
fingerprint: string
reason: 'new' | 'changed'
}): void
timeoutMs?: { prompt: number; connect: number }
}
const DEFAULT_TIMEOUTS = { prompt: 30_000, connect: 15_000 }
/**
* Open an ssh session (connect + auth + open shell) for `conn`.
*
* Resolves with `{ id, client, stream }` once the shell stream is ready to
* stream data. Rejects with a human-readable Chinese Error when anything goes
* wrong before the shell is ready.
*/
export async function connectSsh(
conn: SshConnection,
secretOverride: SshSecretOverride | undefined,
deps: SshServiceDeps
): Promise<SshSessionHandle> {
const mod = await import('ssh2')
const Client = mod.Client
const timeouts = { ...DEFAULT_TIMEOUTS, ...deps.timeoutMs }
const sessionId = randomUUID()
const handshake = new Client()
let settled = false
let connectTimer: NodeJS.Timeout | undefined
let verifierErr: string | undefined
let resolvePromise!: (handle: SshSessionHandle) => void
let rejectPromise!: (err: Error) => void
const armConnectTimer = (): void => {
if (connectTimer) clearTimeout(connectTimer)
connectTimer = setTimeout(() => {
fail(new Error(`连接超时 (${conn.host}:${conn.port})`))
}, timeouts.connect)
}
const fail = (err: Error): void => {
if (settled) return
settled = true
if (connectTimer) clearTimeout(connectTimer)
rejectPromise(err)
try {
handshake.destroy()
} catch {
// best effort
}
}
// --- host key verification ------------------------------------------------
// Called by ssh2 during kex; return undefined => async verdict via verify().
const hostVerifier = (hostKey: Buffer, verify: (permitted: boolean) => void): void => {
let fingerprint: string
let status: 'new' | 'changed' | 'match'
try {
fingerprint = fingerprintOf(hostKey)
status = deps.knownHosts.check(conn.host, conn.port, hostKey).status
} catch {
fingerprint = fingerprintOf(hostKey)
status = 'new'
}
if (status === 'match') {
verify(true)
return
}
// Pause the connect timeout; the user's decision owns this wait.
if (connectTimer) clearTimeout(connectTimer)
promptUser(conn.host, conn.port, fingerprint, status, timeouts.prompt, deps)
.then((accepted) => {
if (accepted) {
try {
deps.knownHosts.accept(conn.host, conn.port, hostKey, fingerprint)
} catch (err) {
verifierErr = `保存主机指纹失败: ${(err as Error).message}`
verify(false)
return
}
verify(true)
} else {
verifierErr = '用户拒绝了主机指纹'
verify(false)
}
})
.finally(() => {
// Resume the overall connect timer once the decision lands.
if (!settled) armConnectTimer()
})
}
return new Promise<SshSessionHandle>((resolve, reject) => {
resolvePromise = resolve
rejectPromise = reject
// failure paths before resolution: `fail` and the connect timer
armConnectTimer()
handshake.on('error', (err: Error) => {
const message = verifierErr ?? err.message
if (!settled) {
fail(new Error(`连接失败 ${conn.host}:${conn.port}: ${message}`))
return
}
// Session already established: surface as a session exit and clean up.
try {
deps.broadcast(Ipc.PTY_EXIT, { id: sessionId, exitCode: 1 })
} catch {
// never crash the event loop
}
try {
handshake.destroy()
} catch {
// best effort
}
})
handshake.on('ready', () => {
handshake.shell(
{ term: 'xterm-256color', cols: 80, rows: 24 },
(err: Error | undefined, shell: ClientChannel) => {
if (err) {
fail(new Error(`无法打开 SSH shell (${conn.host}:${conn.port}): ${err.message}`))
return
}
if (settled) {
try {
shell.end()
} catch {
// best effort
}
return
}
settled = true
if (connectTimer) clearTimeout(connectTimer)
// Successful connect: record lastConnectedAt on the bookmark.
try {
deps.connections.touch(conn.id)
} catch {
// store write failure must not break the session
}
resolvePromise({ id: sessionId, client: handshake, stream: shell })
}
)
})
const cfg: ConnectConfig = {
host: conn.host,
port: conn.port,
username: conn.username,
// SshConnection keeps it in seconds; ssh2 expects milliseconds.
keepaliveInterval: Math.round(conn.keepaliveIntervalSec * 1000),
hostVerifier
}
// Password auth
const password = secretOverride?.password ?? deps.connections.getSecret(conn, 'password')
if (password !== undefined) cfg.password = password
// Private key auth (keyPath takes precedence over stored keyContent)
if (conn.auth === 'privateKey') {
const keyContent = deps.connections.getSecret(conn, 'keyContent')
const privateKey: string | undefined =
conn.keyPath !== undefined && conn.keyPath.length > 0
? readKeyFile(conn.keyPath)
: keyContent !== undefined && keyContent.length > 0
? keyContent
: undefined
if (privateKey !== undefined) {
cfg.privateKey = privateKey
const passphrase = secretOverride?.passphrase ?? deps.connections.getSecret(conn, 'passphrase')
if (passphrase !== undefined) cfg.passphrase = passphrase
}
}
// Agent auth (defaults supplied only when an agent socket is reachable)
if (conn.auth === 'agent') {
const agent = process.env.SSH_AUTH_SOCK ?? (process.platform === 'win32' ? 'pageant' : undefined)
if (agent !== undefined) cfg.agent = agent
}
try {
handshake.connect(cfg)
} catch (err) {
// e.g. unparseable privateKey is thrown synchronously by ssh2
fail(new Error(`SSH 连接初始化失败 (${conn.host}:${conn.port}): ${(err as Error).message}`))
}
})
}
// --- host-key prompt routing -------------------------------------------------
interface PendingPrompt {
resolve: (accepted: boolean) => void
timer: NodeJS.Timeout
}
const pendingPrompts = new Map<string, PendingPrompt>()
/**
* Ask the renderer to approve / reject a host key. Resolves `true`/`false`.
* Times out (default 30s) -> treated as reject.
*/
function promptUser(
host: string,
port: number,
fingerprint: string,
reason: 'new' | 'changed',
timeoutMs: number,
deps: SshServiceDeps
): Promise<boolean> {
const promptId = randomUUID()
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
pendingPrompts.delete(promptId)
resolve(false)
}, timeoutMs)
pendingPrompts.set(promptId, { resolve, timer })
try {
deps.promptHostKey({ promptId, host, port, fingerprint, reason })
} catch {
// broadcast failure must not hang the attempt forever
pendingPrompts.delete(promptId)
clearTimeout(timer)
resolve(false)
}
})
}
/**
* Route a renderer host-key decision to the matching pending prompt.
* Wired by the main process: ipcMain.on(Ipc.HOSTKEY_RESPOND, (…, promptId, action)).
*/
export function resolveHostKey(promptId: string, action: 'accept' | 'reject'): void {
const pending = pendingPrompts.get(promptId)
if (!pending) return
clearTimeout(pending.timer)
pendingPrompts.delete(promptId)
pending.resolve(action === 'accept')
}
/** Read a private key file; surfaces a descriptive error on failure. */
function readKeyFile(keyPath: string): string {
try {
return readFileSync(keyPath, 'utf8')
} catch (err) {
throw new Error(`无法读取私钥文件 ${keyPath}: ${(err as Error).message}`)
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Minimal ambient typings for the `ssh2` package (v1.17.0).
*
* ssh2 ships no type declarations and `@types/ssh2` is not installed; npm
* install is out of scope here, so this file declares exactly the surface this
* project consumes (verified against node_modules/ssh2/lib/client.js,
* Channel.js, kex.js, agent.js, server.js). Lives under src/main/, which is
* owned by this milestone.
*/
declare module 'ssh2' {
import { Duplex } from 'stream'
import { Socket } from 'net'
/** Duplex wrapper around an SSH channel (shell/exec). */
export interface ClientChannel extends Duplex {
setWindow(rows: number, cols: number, height: number, width: number): void
signal(signalName: string): void
exit(statusOrSignal: number | string, coreDumped?: boolean, msg?: string): void
close(): void
}
export interface PseudoTtyOptions {
rows?: number
cols?: number
width?: number
height?: number
term?: string
}
export interface ShellOptions {
rows?: number
cols?: number
width?: number
height?: number
term?: string
/** environment (name => value) requested over the session */
env?: Record<string, string>
x11?: boolean | number | Record<string, unknown>
/** forward a local ssh-agent to the remote session */
agentForward?: boolean
}
export interface ConnectConfig {
host?: string
port?: number
username: string
password?: string
privateKey?: Buffer | string
passphrase?: string
/** ssh-agent socket path or a BaseAgent instance */
agent?: string
/** forward the local agent to the remote (requires `agent`) */
agentForward?: boolean
/** keepalive interval in milliseconds (0 disables) */
keepaliveInterval?: number
keepaliveCountMax?: number
/** ms to wait for handshake before erroring (0 disables) */
readyTimeout?: number
/** socket connect timeout in ms (0 disables) */
timeout?: number
hostHash?: string
/**
* Optional host key verification. When it returns a boolean, ssh2 uses it
* synchronously; a promise-using implementer must instead call the `verify`
* callback (returning `undefined`), which defers the handshake.
*/
hostVerifier?: (key: Buffer, verify: (permitted: boolean) => void) => boolean | void
debug?: (...args: unknown[]) => void
algorithms?: Record<string, unknown>
ident?: string | Buffer
sock?: Socket
strictVendor?: boolean
localAddress?: string
localHostname?: string
localUsername?: string
/** try keyboard-interactive auth */
tryKeyboard?: boolean
authHandler?: unknown
forceIPv4?: boolean
forceIPv6?: boolean
}
export class Client {
connect(cfg: ConnectConfig): this
end(): this
destroy(): this
shell(cb: (err: Error | undefined, stream: ClientChannel) => void): this
shell(
opts: PseudoTtyOptions | ShellOptions | false,
cb: (err: Error | undefined, stream: ClientChannel) => void
): this
exec(cmd: string, cb: (err: Error | undefined, stream: ClientChannel) => void): this
on(event: 'ready' | 'close', listener: () => void): this
on(event: 'error', listener: (err: Error) => void): this
on(event: string, listener: (...args: never[]) => void): this
}
export function createAgent(path: string): unknown
}