Initial commit: OpenTerminal v0.1.0

Open-source terminal app (local + SSH): split panes, themes, SFTP,
server monitoring, broadcast input, ZMODEM, system tray, single instance.
Electron + React + TypeScript. MIT License.
This commit is contained in:
Bill committed 2026-09-07 16:15:50 +08:00
commit a9acdbe500
99 files changed
+23940

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

+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)
}
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Command history / command library + session output logs (M5).
*
* Persistence mirrors the connections store: plain JSON files under
* <userData>/ without sqlite. Two concerns live here:
*
* - Commands: <userData>/commands.json as `{ history, library }`.
* History is capped, deduped against the newest entry and sorted by
* lastUsedAt; library items are sorted by createdAt and support id-based
* update-or-create.
* - Session logs: <userData>/logs/<YYYYMMDD-HHmmss>-<id8>.log plain-text
* files plus an index file (<userData>/logs/index.json) of SessionLogMeta
* that survives restart. PTY data is pushed in by the session layer via the
* registered data logger; streams stop on session close.
*
* The only Electron binding is `app.getPath` (data dir) and `shell.openPath`
* (open the logs folder). Both are injectable so this module can be bundled
* and tested under plain Node.
*/
import { app, shell } from 'electron'
import { randomUUID } from 'crypto'
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'
import { appendFile } from 'fs/promises'
import { join } from 'path'
import type { CommandItem, SessionLogMeta } from '../shared/commands'
/** Upper bound on recorded history entries. */
const HISTORY_CAP = 500
interface CommandsFile {
history: CommandItem[]
library: CommandItem[]
}
function emptyFile(): CommandsFile {
return { history: [], library: [] }
}
/** Default data root: <userData> (computed by the store itself). */
export function defaultCommandsPath(userDataPath: string): string {
return userDataPath
}
function two(n: number): string {
return String(n).padStart(2, '0')
}
/** YYYYMMDD-HHmmss timestamp used in log file names. */
function stamp(d = new Date()): string {
return (
`${d.getFullYear()}${two(d.getMonth() + 1)}${two(d.getDate())}` +
`-${two(d.getHours())}${two(d.getMinutes())}${two(d.getSeconds())}`
)
}
export class CommandsStore {
/** <userData>/commands.json */
private readonly file: string
/** <userData>/logs */
private readonly logsDir: string
/** <userData>/logs/index.json */
private readonly indexFile: string
private readonly openPathImpl?: (p: string) => Promise<unknown>
/** All known logs keyed by absolute file path (hydrated from index on boot). */
private readonly metasByFile = new Map<string, SessionLogMeta>()
/** Logs currently accumulating output, keyed by sessionId. */
private readonly activeBySession = new Map<string, SessionLogMeta>()
constructor(userData?: string, openPathImpl?: (p: string) => Promise<unknown>) {
const ud = userData ?? app.getPath('userData')
this.file = join(ud, 'commands.json')
this.logsDir = join(ud, 'logs')
this.indexFile = join(this.logsDir, 'index.json')
this.openPathImpl = openPathImpl
this.hydrateIndex()
}
// ---- commands file (history + library) -----------------------------------
private loadCommands(): CommandsFile {
try {
const raw: unknown = JSON.parse(readFileSync(this.file, 'utf8'))
if (raw && typeof raw === 'object' && Array.isArray((raw as CommandsFile).history)) {
const data = raw as CommandsFile
return {
history: Array.isArray(data.history) ? data.history : [],
library: Array.isArray(data.library) ? data.library : []
}
}
} catch {
// missing / corrupted file -> start fresh
}
return emptyFile()
}
private saveCommands(data: CommandsFile): void {
mkdirSync(join(this.file, '..'), { recursive: true })
writeFileSync(this.file, JSON.stringify(data, null, 2), 'utf8')
}
/** Record one executed command: trim; dedupe vs newest; move to front; cap. */
recordCommand(cmd: string): void {
const trimmed = cmd.trim()
if (!trimmed) return
const data = this.loadCommands()
const top = data.history[0]
if (top && top.command === trimmed) {
// Same as the newest entry: refresh its recency (already at the front).
top.lastUsedAt = Date.now()
} else {
data.history.unshift({
id: randomUUID(),
command: trimmed,
createdAt: Date.now(),
lastUsedAt: Date.now()
})
if (data.history.length > HISTORY_CAP) data.history.length = HISTORY_CAP
}
this.saveCommands(data)
}
/** History sorted by most recently used. */
listHistory(): CommandItem[] {
return this.loadCommands()
.history.slice()
.sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0))
}
clearHistory(): void {
const data = this.loadCommands()
data.history = []
this.saveCommands(data)
}
/** Library sorted newest first. */
listLibrary(): CommandItem[] {
return this.loadCommands()
.library.slice()
.sort((a, b) => b.createdAt - a.createdAt)
}
/** Insert (no id) or update (has id); returns the stored entry. */
saveLibraryItem(item: CommandItem): CommandItem {
const data = this.loadCommands()
let stored: CommandItem | undefined
if (typeof item.id === 'string' && item.id.length > 0) {
stored = data.library.find((x) => x.id === item.id)
}
if (stored) {
stored.name = item.name
stored.command = item.command
stored.note = item.note
stored.group = item.group
stored.params = item.params
} else {
stored = {
id: item.id && item.id.length > 0 ? item.id : randomUUID(),
name: item.name,
command: item.command,
note: item.note,
group: item.group,
params: item.params,
createdAt: Date.now()
}
data.library.push(stored)
}
this.saveCommands(data)
return stored
}
deleteLibraryItem(id: string): void {
const data = this.loadCommands()
const next = data.library.filter((x) => x.id !== id)
if (next.length < data.library.length) {
data.library = next
this.saveCommands(data)
}
}
// ---- session logs -----------------------------------------------------------
private hydrateIndex(): void {
try {
if (!existsSync(this.indexFile)) return
const raw: unknown = JSON.parse(readFileSync(this.indexFile, 'utf8'))
if (!Array.isArray(raw)) return
for (const x of raw) {
if (
x &&
typeof (x as SessionLogMeta).sessionId === 'string' &&
typeof (x as SessionLogMeta).file === 'string'
) {
const meta = x as SessionLogMeta
this.metasByFile.set(meta.file, meta)
if (meta.endedAt === undefined) this.activeBySession.set(meta.sessionId, meta)
}
}
} catch {
// index missing / corrupt -> rebuild on next write
}
}
/** Write the full log index so listSessionLogs survives restart. */
private persistIndex(): void {
try {
mkdirSync(this.logsDir, { recursive: true })
const all = Array.from(this.metasByFile.values()).sort(
(a, b) => b.startedAt - a.startedAt
)
writeFileSync(this.indexFile, JSON.stringify(all, null, 2), 'utf8')
} catch {
// best effort: never break the session over an index write failure
}
}
/** Finalize a log (set endedAt, drop from active, persist index). */
private finishLog(meta: SessionLogMeta): void {
meta.endedAt = Date.now()
this.activeBySession.delete(meta.sessionId)
this.persistIndex()
}
/** Start logging a session; any prior active log for the id is stopped first. */
logStart(sessionId: string): SessionLogMeta {
const prev = this.activeBySession.get(sessionId)
if (prev) this.finishLog(prev)
mkdirSync(this.logsDir, { recursive: true })
const fileName = `${stamp()}-${sessionId.slice(0, 8)}.log`
const file = join(this.logsDir, fileName)
// Create the file eagerly so the first async append cannot race a missing fd.
describeFile(file)
const meta: SessionLogMeta = { sessionId, file, fileName, startedAt: Date.now() }
this.metasByFile.set(file, meta)
this.activeBySession.set(sessionId, meta)
this.persistIndex()
return meta
}
/** Append output for an active session. No-op when not logging (post-stop safe). */
logWrite(sessionId: string, data: string): void {
const meta = this.activeBySession.get(sessionId)
if (!meta) {
// not logging (or already stopped) -> ignore, prevents stop/append race
return
}
appendFile(meta.file, data, 'utf8').catch(() => {
// file may have been removed after stop -> ignore
})
}
/** Stop logging a session, stamping endedAt into the registry + index. */
logStop(sessionId: string): void {
const meta = this.activeBySession.get(sessionId)
if (!meta) return
this.finishLog(meta)
}
/** All known session logs (including persisted ones), newest first. */
listSessionLogs(): SessionLogMeta[] {
return Array.from(this.metasByFile.values()).sort((a, b) => b.startedAt - a.startedAt)
}
/** Reveal the logs directory in the OS file manager. */
openLogsDir(): void {
try {
mkdirSync(this.logsDir, { recursive: true })
if (this.openPathImpl) {
void this.openPathImpl(this.logsDir)
} else {
void shell.openPath(this.logsDir)
}
} catch {
// best effort
}
}
}
/** Create a log file empty so the path is real before async writes begin. */
function describeFile(p: string): void {
try {
writeFileSync(p, '', 'utf8')
} catch {
// best effort; appends will also create it lazily
}
}
+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`
}
+37
View File
@@ -0,0 +1,37 @@
import { BrowserWindow, globalShortcut } from 'electron'
/**
* Register the global show/hide toggle for the main window.
*
* - accelerator '' / undefined => disabled (no global key bound).
* - Passing an invalid accelerator string makes Electron's register() throw;
* we swallow that here so a bad user-supplied value never crashes the app.
* - register() returning false means the accelerator is already taken by
* another application; we only log a warning and keep running.
*/
export function applyGlobalShortcut(accelerator: string | undefined): void {
// No other global shortcuts are registered anywhere in this app, so a full
// unregister is safe and guarantees a clean re-register on every change.
globalShortcut.unregisterAll()
if (!accelerator) return
const handler = (): void => {
const win = BrowserWindow.getAllWindows()[0]
if (!win || win.isDestroyed()) return
if (win.isVisible() && win.isFocused()) {
win.hide()
} else {
win.show()
win.focus()
}
}
try {
const ok = globalShortcut.register(accelerator, handler)
if (!ok) {
console.warn(`[global-shortcut] register "${accelerator}" failed (conflict with another app)`)
}
} catch (err) {
console.warn(`[global-shortcut] register "${accelerator}" threw`, err)
}
}
+122
View File
@@ -0,0 +1,122 @@
import { app, BrowserWindow, globalShortcut, nativeImage, shell } from 'electron'
import { existsSync } from 'fs'
import { join } from 'path'
import { registerIpc } from './ipc'
import { killAllPtys } from './pty'
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
import { initTray, markQuitting, onMainWindowClose } from './tray'
import { configureAutoUpdater } from './updater'
import { applyWindowChrome } from './windowChrome'
import { getThemeById } from '@shared/theme'
/** Re-create the main window (tray restore path after all windows are gone). */
function showOrCreate(): void {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
else {
const win = BrowserWindow.getAllWindows()[0]
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
}
// Single instance: a second launch just surfaces the existing window (pulls
// it out of the tray if hidden there) instead of starting another process.
const gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!gotSingleInstanceLock) {
app.quit()
} else {
app.on('second-instance', () => showOrCreate())
}
function createWindow(): void {
// Dev-mode window/taskbar icon; packaged builds inherit the exe icon
// (electron-builder embeds build/icon.png), so undefined is fine there.
const devIcon = join(__dirname, '../../build/icon.png')
const settings = loadSettings()
const theme = getThemeById(settings.terminal.themeId, settings.customThemes)
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 720,
minHeight: 480,
show: false,
backgroundColor: theme.colors.background,
autoHideMenuBar: true,
// Custom title bar: the renderer draws the strip, Windows draws the
// min/max/close overlay — colors follow the active terminal theme.
...(process.platform === 'win32'
? {
titleBarStyle: 'hidden' as const,
titleBarOverlay: {
color: theme.colors.background,
symbolColor: theme.colors.foreground,
height: 36
}
}
: {}),
...(existsSync(devIcon) ? { icon: nativeImage.createFromPath(devIcon) } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
win.on('ready-to-show', () => win.show())
// Close button → tray / exit per the closeAction setting (ask by default).
win.on('close', (e) => {
void onMainWindowClose(win, e, showOrCreate)
})
// 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'))
}
}
process.on('unhandledRejection', (reason) => {
console.error('[main] unhandledRejection:', reason)
})
process.on('uncaughtException', (err) => {
console.error('[main] uncaughtException:', err)
})
app.whenReady().then(() => {
registerIpc()
// OS-level effects (login item, sleep blocker) must apply even if the
// settings dialog is never opened this run.
applyStartupSystemSettings(loadSettings())
createWindow()
initTray(showOrCreate)
configureAutoUpdater()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('before-quit', () => {
// Let window 'close' events pass through so teardown completes.
markQuitting()
killAllPtys()
globalShortcut.unregisterAll()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+174
View File
@@ -0,0 +1,174 @@
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 { startPolling, stopPolling } from './sysinfo'
import { registerSettingsIpc } from './settingsStore'
import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore'
import { KnownHostsStore, defaultKnownHostsPath } from './knownHosts'
import { resolveHostKey } from './ssh'
import {
listRemote,
mkdirRemote,
renameRemote,
deleteRemote,
chmodRemote,
chownRemote,
uploadRemote,
downloadRemote,
cancelTransfer,
pickFiles,
pickDirectory
} from './sftp'
import { broadcast } from './broadcast'
import { CommandsStore, defaultCommandsPath } from './commands'
import type { CommandItem } from '../shared/commands'
import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, registerLogHooks } from './pty'
import { respondZmodem } from './zmodem'
import type { ZmodemResponse } from '../shared/ipc'
let commandsStore: CommandsStore | undefined
/** M5: inject a custom command store (tests) or default to userData-backed. */
export function setCommandsStore(store: CommandsStore): void {
commandsStore = store
}
export function getCommandsStore(): CommandsStore {
if (!commandsStore) {
commandsStore = new CommandsStore(defaultCommandsPath(app.getPath('userData')))
}
return commandsStore
}
export function registerIpc(): void {
const connectionsStore = new ConnectionsStore(defaultConnectionsPath(app.getPath('userData')))
const knownHostsStore = new KnownHostsStore(defaultKnownHostsPath(app.getPath('userData')))
const cmds = getCommandsStore()
// Route PTY output into the session log (M5). logWrite decides whether a
// session is actively logging; logStop finalizes on session close / kill.
registerLogHooks(
(id, data) => cmds.logWrite(id, data),
(id) => cmds.logStop(id)
)
// 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))
// ---- zmodem (M6: main-process engine over ssh sessions) ----
// ZMODEM_OFFER / ZMODEM_DONE are broadcasts (main -> renderer); this is the
// renderer's answer, forwarded to the engine (which routes on resp.id).
ipcMain.on(Ipc.ZMODEM_RESPOND, (_event, resp: ZmodemResponse) => respondZmodem(resp))
// ---- sysinfo (M3: remote hardware monitoring, ssh sessions only) ----
ipcMain.on(Ipc.SYSINFO_START, (_event, id: string) => startPolling(id))
ipcMain.on(Ipc.SYSINFO_STOP, (_event, id: string) => stopPolling(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)
})
// ---- sftp (M4) ----
ipcMain.handle(Ipc.SFTP_LIST, (_e, sessionId: string, dir: string) => listRemote(sessionId, dir))
ipcMain.handle(Ipc.SFTP_MKDIR, (_e, sessionId: string, dir: string, name: string) =>
mkdirRemote(sessionId, dir, name)
)
ipcMain.handle(Ipc.SFTP_RENAME, (_e, sessionId: string, from: string, to: string) =>
renameRemote(sessionId, from, to)
)
ipcMain.handle(Ipc.SFTP_DELETE, (_e, sessionId: string, paths: string[]) =>
deleteRemote(sessionId, paths)
)
ipcMain.handle(Ipc.SFTP_CHMOD, (_e, sessionId: string, path: string, mode: string) =>
chmodRemote(sessionId, path, mode)
)
ipcMain.handle(Ipc.SFTP_CHOWN, (_e, sessionId: string, path: string, uid: number, gid: number) =>
chownRemote(sessionId, path, uid, gid)
)
ipcMain.handle(Ipc.SFTP_UPLOAD, (_e, sessionId: string, localPaths: string[], remoteDir: string) =>
uploadRemote(sessionId, localPaths, remoteDir, broadcast)
)
ipcMain.handle(
Ipc.SFTP_DOWNLOAD,
(_e, sessionId: string, remotePaths: string[], localDir: string) =>
downloadRemote(sessionId, remotePaths, localDir, broadcast)
)
ipcMain.on(Ipc.TRANSFER_CANCEL, (_e, transferId: string) => cancelTransfer(transferId))
ipcMain.handle(Ipc.DIALOG_PICK_FILES, () => pickFiles())
ipcMain.handle(Ipc.DIALOG_PICK_DIR, () => pickDirectory())
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))
// ---- command history / library + session logs (M5) ----
ipcMain.handle(Ipc.COMMANDS_RECORD, (_event, cmd: string) => cmds.recordCommand(cmd))
ipcMain.handle(Ipc.COMMANDS_HISTORY_LIST, () => cmds.listHistory())
ipcMain.handle(Ipc.COMMANDS_HISTORY_CLEAR, () => cmds.clearHistory())
ipcMain.handle(Ipc.COMMANDS_LIBRARY_LIST, () => cmds.listLibrary())
ipcMain.handle(Ipc.COMMANDS_LIBRARY_SAVE, (_event, item: CommandItem) =>
cmds.saveLibraryItem(item)
)
ipcMain.handle(Ipc.COMMANDS_LIBRARY_DELETE, (_event, id: string) =>
cmds.deleteLibraryItem(id)
)
ipcMain.handle(Ipc.LOG_START, (_event, sessionId: string) => cmds.logStart(sessionId))
ipcMain.handle(Ipc.LOG_STOP, (_event, sessionId: string) => cmds.logStop(sessionId))
ipcMain.handle(Ipc.LOG_LIST, () => cmds.listSessionLogs())
ipcMain.on(Ipc.LOG_OPEN_DIR, () => cmds.openLogsDir())
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
}
}
+346
View File
@@ -0,0 +1,346 @@
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'
import { configureSysinfo, registerSysinfoClient, stopPolling } from './sysinfo'
import { registerSftpClientProvider, closeSftp } from './sftp'
import { attachZmodem, detachZmodem, feedZmodem, isZmodemActive } from './zmodem'
// Re-export so the session-layer loopback bundle (tests/*-e2e.mjs) can drive the
// M3 polling engine without importing src/main/sysinfo.ts separately.
export { startPolling, stopPolling } from './sysinfo'
/**
* M5 command-store / log-service hooks (injected once by ipc.ts). Mirrors the
* sysinfo injection pattern so pty.ts routes PTY data into the session log
* without importing the store directly. `log` runs on every onData chunk (the
* logger itself decides whether a session is actively logging), `stop` runs on
* session close / kill to finalize the log file.
*/
type DataLogger = (id: string, data: string) => void
let dataLogger: DataLogger | undefined
let logStopper: (id: string) => void | undefined
export function registerLogHooks(log: DataLogger, stop: (id: string) => void): void {
dataLogger = log
logStopper = stop
}
function safeLog(id: string, data: string): void {
try {
dataLogger?.(id, data)
} catch {
// never crash the event loop
}
}
function safeStopLog(id: string): void {
try {
logStopper?.(id)
} catch {
// never crash the event loop
}
}
/**
* 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 }
/** The live ssh2 client behind an ssh session, or undefined. */
export function getSshClient(id: string): SshSessionHandle['client'] | undefined {
const session = sessions.get(id)
return session?.kind === 'ssh' ? session.ssh.client : undefined
}
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
configureSysinfo({
broadcast: (channel, ...args) => deps.broadcast(channel, ...args)
})
registerSysinfoClient((id) => {
const session = sessions.get(id)
if (session?.kind === 'ssh') return session.ssh.client
return undefined
})
registerSftpClientProvider((id) => {
const session = sessions.get(id)
if (session?.kind === 'ssh') return session.ssh.client
return undefined
})
}
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()
// Advertise color capability + terminal identity so TUIs (e.g. kimi CLI)
// use their full-color theme instead of the degraded white/amber fallback.
const env = {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'OpenTerminal',
...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)
safeLog(id, data)
broadcast(Ipc.PTY_DATA, { id, data })
} catch {
// never crash the event loop
}
})
pty.onExit(({ exitCode }) => {
try {
sessions.delete(id)
safeStopLog(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 })
// ZMODEM (M6): attach the in-process engine to the raw ssh stream. Only ssh
// sessions are supported -- node-pty/Windows ConPTY hands out UTF-8 strings
// only and would corrupt binary frames. The sentry inspects every data chunk:
// non-ZMODEM traffic is routed back through toTerminal below; once a ZMODEM
// header is spotted the transfer suppresses the terminal data plane (and
// writePty drops user keystrokes while isZmodemActive is true).
attachZmodem(handle.id, {
broadcast: (channel, ...args) => deps.broadcast(channel, ...args),
toTerminal: (id, text) => {
appendReplay(id, text)
safeLog(id, text)
deps.broadcast(Ipc.PTY_DATA, { id, data: text })
},
writeStream: (id, data) => {
const s = sessions.get(id)
if (s?.kind === 'ssh') {
try {
s.ssh.stream.write(data)
} catch {
// stream may already be dead
}
}
}
})
handle.stream.on('data', (data: Buffer) => {
// Route through the ZMODEM sentry. It forwards non-ZMODEM bytes to
// deps.toTerminal and absorbs the transfer untouched; while a transfer is
// active the engine suppresses the normal data plane entirely.
feedZmodem(handle.id, data)
})
handle.stream.on('close', () => {
try {
stopPolling(handle.id)
closeSftp(handle.id)
detachZmodem(handle.id)
safeStopLog(handle.id)
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 {
// While a ZMODEM transfer is active the stream carries binary frames; user
// keystrokes must be dropped (they would corrupt the transfer).
if (isZmodemActive(id)) return
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 {
stopPolling(id)
closeSftp(id)
detachZmodem(id)
safeStopLog(id)
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 id of sessions.keys()) {
stopPolling(id)
closeSftp(id)
detachZmodem(id)
safeStopLog(id)
const session = sessions.get(id)
if (!session) continue
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()
}
+177
View File
@@ -0,0 +1,177 @@
import { app, ipcMain, powerSaveBlocker } 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 SystemSettings,
type TerminalSettings
} from '../shared/settings'
import type { TerminalTheme } from '../shared/theme'
import { broadcast } from './broadcast'
import { applyGlobalShortcut } from './globalShortcuts'
import { applyWindowChrome } from './windowChrome'
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
const DEFAULT_SYSTEM: SystemSettings = {
launchAtLogin: false,
preventSleep: false,
globalShowHide: '',
closeAction: 'ask'
}
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
}
}
let system: unknown = DEFAULT_SYSTEM
if (raw !== null && typeof raw === 'object') {
const sys = (raw as { system?: unknown }).system
if (sys !== null && typeof sys === 'object') {
const candidate = sys as Record<string, unknown>
const globalShowHide =
typeof candidate.globalShowHide === 'string' ? candidate.globalShowHide : ''
const closeAction =
candidate.closeAction === 'tray' || candidate.closeAction === 'exit'
? candidate.closeAction
: 'ask'
system = {
launchAtLogin: candidate.launchAtLogin === true,
preventSleep: candidate.preventSleep === true,
globalShowHide,
closeAction
}
}
}
const themes: TerminalTheme[] = Array.isArray(customThemes) ? (customThemes as TerminalTheme[]) : []
return {
settings: {
terminal,
customThemes: themes,
highlightRules: sanitizeRules(highlightRules),
system: system as SystemSettings
},
errors
}
}
// ---- real system side effects ------------------------------------------------
let sleepBlockerId: number | undefined
/** Apply OS-level effects of the system settings (login item, sleep blocker). */
function applySystemSettings(system: SystemSettings): void {
try {
app.setLoginItemSettings({ openAtLogin: system.launchAtLogin })
} catch (err) {
console.error('[settings] setLoginItemSettings failed', err)
}
if (system.preventSleep && sleepBlockerId === undefined) {
sleepBlockerId = powerSaveBlocker.start('prevent-app-suspension')
} else if (!system.preventSleep && sleepBlockerId !== undefined) {
powerSaveBlocker.stop(sleepBlockerId)
sleepBlockerId = undefined
}
// Re-register the global show/hide shortcut whenever system settings change.
applyGlobalShortcut(system.globalShowHide)
}
/** Apply system side effects for the settings loaded at startup. */
export function applyStartupSystemSettings(settings: AppSettings): void {
applySystemSettings(settings.system)
}
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 })),
system: { ...DEFAULT_SYSTEM }
}
}
}
export function saveSettings(next: AppSettings): AppSettings {
const merged = deepMerge({
terminal: next.terminal,
customThemes: next.customThemes,
highlightRules: next.highlightRules,
system: next.system ?? DEFAULT_SYSTEM
}).settings
applySystemSettings(merged.system)
applyWindowChrome(merged)
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))
}
+424
View File
@@ -0,0 +1,424 @@
import { dialog } from 'electron'
import type { Client, SFTPWrapper } from 'ssh2'
import { randomUUID } from 'crypto'
import { createWriteStream, promises as fsp } from 'fs'
import { basename, join } from 'path'
import { Ipc } from '../shared/ipc'
import type { SftpEntry, TransferProgressEvent } from '../shared/sftp'
/** ssh2 Client lookup injected by pty.ts (kind === 'ssh' sessions only). */
let clientProvider: ((id: string) => Client | undefined) | undefined
export function registerSftpClientProvider(provider: (id: string) => Client | undefined): void {
clientProvider = provider
}
function p<T>(fn: (cb: (err: Error | null, res: T) => void) => void): Promise<T> {
return new Promise((resolve, reject) => {
fn((err, res) => (err ? reject(err) : resolve(res)))
})
}
/** For ssh2 calls whose callback only yields an error. */
function pVoid(fn: (cb: (err: Error | null) => void) => void): Promise<void> {
return new Promise((resolve, reject) => {
fn(err => (err ? reject(err) : resolve()))
})
}
type StatLike = { isDirectory(): boolean; size: number; mtime: number; mode: number; uid: number; gid: number }
function lstat(sftp: SFTPWrapper, path: string): Promise<StatLike> {
return p(cb => sftp.lstat(path, cb))
}
/** "drwxr-xr-x"-style string from a numeric POSIX mode (best effort). */
export function formatMode(mode: number): string {
const kind = (mode & 0o170000) === 0o040000 ? 'd' : '-'
const groups: [number, string][] = [
[0o400, 'r'], [0o200, 'w'], [0o100, 'x'],
[0o040, 'r'], [0o020, 'w'], [0o010, 'x'],
[0o004, 'r'], [0o002, 'w'], [0o001, 'x']
]
const perm = groups.map(([bit, ch]) => ((mode & bit) !== 0 ? ch : '-')).join('')
return kind + perm
}
/**
* One SFTP channel PER SESSION, cached. Strict sshd builds (MaxSessions 2-3,
* e.g. hardened cloud images) refuse extra channels and drop the whole
* connection when every operation opens its own subsystem.
*/
const sftpCache = new Map<string, SFTPWrapper>()
async function sftpOf(sessionId: string): Promise<SFTPWrapper> {
const cached = sftpCache.get(sessionId)
if (cached) return cached
const client = clientProvider?.(sessionId)
if (!client) {
console.error(`[sftp-debug] provider=${typeof clientProvider} sessionId=${sessionId} cacheSize=${sftpCache.size}`)
throw new Error('SSH 会话不存在或已断开')
}
const sftp = await new Promise<SFTPWrapper>((resolve, reject) => {
client.sftp((err, sftp_) => (err != null ? reject(err) : resolve(sftp_)))
})
sftpCache.set(sessionId, sftp)
return sftp
}
function evictSftp(sessionId: string): void {
sftpCache.delete(sessionId)
}
/**
* Server-side SFTP failures (Failure / permission denied / no such file /
* already exists) mean the CHANNEL IS ALIVE — retrying them would just open
* another subsystem channel, which strict sshd (MaxSessions 2) punishes by
* dropping the whole connection. Only transport-level death retries.
*/
function isTransportError(err: unknown): boolean {
const msg = (err as Error | undefined)?.message ?? ''
return /not connected|会话不存在|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end/i.test(msg)
}
/** Run `fn` with the session's cached sftp; a dead cached channel is evicted and retried once. */
async function withSftp<T>(sessionId: string, fn: (sftp: SFTPWrapper) => Promise<T>): Promise<T> {
try {
return await fn(await sftpOf(sessionId))
} catch (err) {
if (!isTransportError(err)) throw err
evictSftp(sessionId)
return fn(await sftpOf(sessionId))
}
}
/** Drop the cached channel when the session is gone. */
export function closeSftp(sessionId: string): void {
evictSftp(sessionId)
}
const FAKE_FS = new Set(['tmpfs', 'overlay', 'udev', 'devtmpfs', 'none', 'squashfs', 'shm'])
export function listRemote(sessionId: string, dir: string): Promise<SftpEntry[]> {
return withSftp(sessionId, async sftp => {
const raw = await new Promise<Array<string | { filename: string }>>((resolve, reject) => {
sftp.readdir(dir, (err, names) => (err ? reject(err) : resolve(names)))
})
// ssh2 readdir yields plain strings OR {filename} objects depending on version/options
const names = raw.map(n => (typeof n === 'string' ? n : n.filename))
const entries: SftpEntry[] = []
const queue = [...names]
const base = dir.replace(/\/+$/, '') || '/'
const workers = Array.from({ length: Math.min(8, Math.max(queue.length, 1)) }, async () => {
for (;;) {
const name = queue.shift()
if (name === undefined) return
const path = `${base}/${name}`
try {
const st = await lstat(sftp, path)
entries.push({
name, path, isDir: st.isDirectory(), size: st.size, mtime: st.mtime * 1000,
uid: st.uid, gid: st.gid, mode: formatMode(st.mode)
})
} catch {
entries.push({ name, path, isDir: false, size: 0, mtime: 0 })
}
}
})
await Promise.all(workers)
entries.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
return a.name.localeCompare(b.name)
})
return entries
})
}
export function mkdirRemote(sessionId: string, dir: string, name: string): Promise<void> {
return withSftp(sessionId, sftp => {
const base = dir.replace(/\/+$/, '') || '/'
return pVoid(cb => sftp.mkdir(`${base}/${name}`, cb))
})
}
export function renameRemote(sessionId: string, from: string, to: string): Promise<void> {
return withSftp(sessionId, sftp => pVoid(cb => sftp.rename(from, to, cb)))
}
async function deleteRecursive(sftp: SFTPWrapper, path: string, isDir: boolean): Promise<void> {
if (!isDir) {
await pVoid(cb => sftp.unlink(path, cb))
return
}
const raw = await new Promise<Array<string | { filename: string }>>((resolve, reject) => {
sftp.readdir(path, (err, names) => (err ? reject(err) : resolve(names)))
})
const names = raw.map(n => (typeof n === 'string' ? n : n.filename))
const base = path.replace(/\/+$/, '') || '/'
for (const name of names) {
const child = `${base}/${name}`
let childIsDir = false
try {
childIsDir = (await lstat(sftp, child)).isDirectory()
} catch {
// unreadable child — fall through to unlink
}
await deleteRecursive(sftp, child, childIsDir)
}
await pVoid(cb => sftp.rmdir(path, cb))
}
export function deleteRemote(sessionId: string, paths: string[]): Promise<void> {
return withSftp(sessionId, async sftp => {
const failures: string[] = []
for (const path of paths) {
try {
let isDir = false
try {
isDir = (await lstat(sftp, path)).isDirectory()
} catch {
// stat failed — fall through to unlink
}
await deleteRecursive(sftp, path, isDir)
} catch (err) {
failures.push(`${path}: ${(err as Error).message}`)
}
}
if (paths.length > 0 && failures.length === paths.length) {
throw new Error(`删除失败: ${failures.join('; ')}`)
}
})
}
/** mode = octal string, e.g. "755" or "0644". */
/**
* chmod/chown run over an exec channel: the JD test server's sftp subsystem
* accepts SETSTAT but silently ignores it, while shell chmod/chown work.
*/
export function chmodRemote(sessionId: string, path: string, mode: string): Promise<void> {
if (!/^[0-7]{1,4}$/.test(mode)) throw new Error(`非法权限值: ${mode}`)
return execQuiet(sessionId, `chmod ${mode} ${JSON.stringify(path)}`)
}
export function chownRemote(sessionId: string, path: string, uid: number, gid: number): Promise<void> {
return execQuiet(sessionId, `chown ${uid}:${gid} ${JSON.stringify(path)}`)
}
/** Run a command on the session's shell channel and wait for it to finish. */
function execQuiet(sessionId: string, cmd: string): Promise<void> {
const client = clientProvider?.(sessionId)
if (!client) throw new Error('SSH 会话不存在或已断开')
return new Promise((resolve, reject) => {
client.exec(cmd, (err, stream) => {
if (err) {
reject(err)
return
}
let stderr = ''
stream.on('data', () => undefined)
stream.stderr?.on('data', (d: Buffer) => {
stderr += d.toString('utf8')
})
stream.on('close', (code: number) => {
if (code) reject(new Error(stderr || `命令退出码 ${code}`))
else resolve()
})
})
})
}
// ---- transfers ----------------------------------------------------------------
//
// Low-level positional read/write over an SFTP file handle. The JD cloud sshd
// ACKs write requests LAZILY — awaiting each ack deadlocks (the ack only
// flushes when more requests arrive) — so writes keep a bounded pipeline in
// flight and the remote handle is closed with a stall guard at the end.
// Reads respond normally, so downloads stay sequential.
interface ActiveTransfer {
kind: 'upload' | 'download'
cancelled: boolean
}
const activeTransfers = new Map<string, ActiveTransfer>()
export function cancelTransfer(transferId: string): void {
const t = activeTransfers.get(transferId)
if (t) t.cancelled = true
}
type Broadcast = (channel: string, payload: unknown) => void
const CHUNK = 256 * 1024
const UPLOAD_WINDOW = 64
export function uploadRemote(
sessionId: string,
localPaths: string[],
remoteDir: string,
broadcast: Broadcast
): Promise<string> {
return withSftp(sessionId, async sftp => {
const id = randomUUID()
const transfer: ActiveTransfer = { kind: 'upload', cancelled: false }
activeTransfers.set(id, transfer)
const emit = (e: TransferProgressEvent): void => broadcast(Ipc.TRANSFER_PROGRESS, e)
void (async () => {
try {
for (const local of localPaths) {
if (transfer.cancelled) break
const name = basename(local)
const size = (await fsp.stat(local)).size
const remote = `${remoteDir.replace(/\/+$/, '')}/${name}`
const handle = await p<Buffer>(cb => sftp.open(remote, 'w', cb))
const inflight = new Set<Promise<void>>()
let firstError: Error | undefined
try {
const localHandle = await fsp.open(local, 'r')
try {
let pos = 0
let lastEmit = 0
const buf = Buffer.alloc(CHUNK)
for (;;) {
if (transfer.cancelled) throw new Error('已取消')
if (firstError) throw firstError
while (inflight.size >= UPLOAD_WINDOW) {
await Promise.race(inflight)
if (firstError) throw firstError
}
const { bytesRead } = await localHandle.read(buf, 0, CHUNK, pos)
if (bytesRead === 0) break
const offset = pos
pos += bytesRead
const now = Date.now()
if (now - lastEmit > 150 || pos === size) {
lastEmit = now
emit({ transferId: id, kind: 'upload', state: 'running', file: name, bytes: pos, totalBytes: size })
}
const pr = pVoid(cb => sftp.write(handle, buf, 0, bytesRead, offset, cb))
inflight.add(
pr.catch((err: Error) => {
firstError = firstError ?? err
})
)
}
// NOTE: do NOT await the write acks before close — this server
// acks lazily; close flushes and commits everything server-side.
} finally {
await localHandle.close()
}
await Promise.race([
pVoid(cb => sftp.close(handle, cb)),
new Promise<void>((r) => setTimeout(r, 10_000))
])
await Promise.allSettled(inflight)
if (firstError) throw firstError
} catch (err) {
await pVoid(cb => sftp.unlink(remote, cb)).catch(() => undefined)
throw err
}
if (transfer.cancelled) {
await pVoid(cb => sftp.unlink(remote, cb)).catch(() => undefined)
emit({ transferId: id, kind: 'upload', state: 'cancelled', file: name, bytes: 0, totalBytes: size })
return
}
}
emit({ transferId: id, kind: 'upload', state: 'done', file: '', bytes: 0, totalBytes: 0 })
} catch (err) {
emit({
transferId: id, kind: 'upload',
state: transfer.cancelled ? 'cancelled' : 'error',
file: '', bytes: 0, totalBytes: 0,
error: (err as Error).message
})
} finally {
activeTransfers.delete(id)
closeSftp(sessionId)
}
})()
return id
})
}
export function downloadRemote(
sessionId: string,
remotePaths: string[],
localDir: string,
broadcast: Broadcast
): Promise<string> {
return withSftp(sessionId, async sftp => {
const id = randomUUID()
const transfer: ActiveTransfer = { kind: 'download', cancelled: false }
activeTransfers.set(id, transfer)
const emit = (e: TransferProgressEvent): void => broadcast(Ipc.TRANSFER_PROGRESS, e)
void (async () => {
try {
for (const remote of remotePaths) {
if (transfer.cancelled) break
const name = basename(remote)
const { size } = await p<{ size: number }>(cb => sftp.stat(remote, cb))
const local = join(localDir, name)
const handle = await p<Buffer>(cb => sftp.open(remote, 'r', cb))
try {
const localHandle = await fsp.open(local, 'w')
try {
let pos = 0
let lastEmit = 0
const buf = Buffer.alloc(CHUNK)
for (;;) {
if (transfer.cancelled) throw new Error('已取消')
const { bytesRead } = await p<{ bytesRead: number; buffer: Buffer }>(cb =>
sftp.read(handle, buf, 0, CHUNK, pos, cb)
)
if (bytesRead === 0) break
await localHandle.write(buf, 0, bytesRead, pos)
pos += bytesRead
const now = Date.now()
if (now - lastEmit > 150 || pos === size) {
lastEmit = now
emit({ transferId: id, kind: 'download', state: 'running', file: name, bytes: pos, totalBytes: size })
}
}
} finally {
await localHandle.close()
}
} catch (err) {
await fsp.rm(local, { force: true })
throw err
}
if (transfer.cancelled) {
await fsp.rm(local, { force: true })
emit({ transferId: id, kind: 'download', state: 'cancelled', file: name, bytes: 0, totalBytes: 0 })
return
}
}
emit({ transferId: id, kind: 'download', state: 'done', file: '', bytes: 0, totalBytes: 0 })
} catch (err) {
emit({
transferId: id, kind: 'download',
state: transfer.cancelled ? 'cancelled' : 'error',
file: '', bytes: 0, totalBytes: 0,
error: (err as Error).message
})
} finally {
activeTransfers.delete(id)
closeSftp(sessionId)
}
})()
return id
})
}
/** Native file dialogs parented to the focused window. */
export async function pickFiles(): Promise<string[]> {
const r = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] })
return r.filePaths
}
export async function pickDirectory(): Promise<string> {
const r = await dialog.showOpenDialog({ properties: ['openDirectory'] })
return r.filePaths[0] ?? ''
}
+310
View File
@@ -0,0 +1,310 @@
/**
* 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 }
}
// Host-key confirmation is a security decision — users may take time to
// compare fingerprints. 30s timed out on people who merely screenshot it.
const DEFAULT_TIMEOUTS = { prompt: 120_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 (err) {
console.error(`[ssh] knownHosts.check threw: ${(err as Error).message}`)
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) => {
console.error(`[ssh] client error: ${err.message}`)
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) {
console.error(`[ssh] resolveHostKey: no pending prompt ${promptId}`)
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}`)
}
}
+137
View File
@@ -0,0 +1,137 @@
/**
* 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
stderr: import('stream').Readable
}
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
/** request an SFTP channel (M4) */
sftp(cb: (err: Error | undefined, sftp: SFTPWrapper) => void): this
on(event: 'ready' | 'close' | 'continue', listener: () => void): this
on(event: 'error', listener: (err: Error) => void): this
on(event: string, listener: (...args: never[]) => void): this
}
/** POSIX attributes ssh2 attachments to readdir entries / returns from stat. */
export interface SftpAttrs {
mode: number
uid: number
gid: number
size: number
mtime: number
}
export interface SftpStats extends SftpAttrs {
isDirectory(): boolean
}
/** Minimal SFTP surface used by src/main/sftp.ts (verified against SFTPWrapper). */
export interface SFTPWrapper {
end?(): void
readdir(
location: string,
cb: (err: Error | null, names: Array<string | { filename: string; longname: string; attrs?: SftpAttrs }>) => void
): void
mkdir(location: string, cb: (err: Error | null) => void): void
rmdir(location: string, cb: (err: Error | null) => void): void
unlink(location: string, cb: (err: Error | null) => void): void
rename(src: string, dest: string, cb: (err: Error | null) => void): void
setstat(location: string, attrs: { permissions?: number; uid?: number; gid?: number }, cb: (err: Error | null) => void): void
stat(location: string, cb: (err: Error | null, stats: SftpStats) => void): void
lstat(location: string, cb: (err: Error | null, stats: SftpStats) => void): void
open(location: string, flags: string, cb: (err: Error | null, handle: Buffer) => void): void
close(handle: Buffer, cb: (err: Error | null) => void): void
fstat(handle: Buffer, cb: (err: Error | null, stats: SftpStats) => void): void
write(handle: Buffer, buf: Buffer, offset: number, length: number, position: number, cb: (err: Error | null) => void): void
read(handle: Buffer, buf: Buffer, offset: number, length: number, position: number, cb: (err: Error | null, res: { bytesRead: number; buffer: Buffer }) => void): void
}
export function createAgent(path: string): unknown
}
+415
View File
@@ -0,0 +1,415 @@
/**
* Remote server hardware monitoring (M3): ssh sessions only.
*
* Polls a remote via an ssh2 `exec` (a single command returns CPU / memory /
* load / uptime / disk / network in one transport), parses the cat'd /proc
* files, diffs successive samples to compute usage and throughput, and
* broadcasts SYSINFO_SAMPLE to every live window. SYSINFO_META (hostname + os)
* is emitted once per session.
*
* The ssh `Client` for a session is injected by pty.ts (registerSysinfoClient);
* the broadcast sink is injected by pty.ts at runtime configure time, so this
* module stays Electron-free and testable through the same session-layer bundle
* as the ssh harness.
*/
import { Ipc } from '../shared/ipc'
import type { Client } from 'ssh2'
import type { SysinfoMeta, SysinfoSample } from '../shared/sysinfo'
/** One exec returns stat+mem+load+uptime, then df, then net, then host/uname. */
const COLLECT_CMD =
'cat /proc/stat /proc/meminfo /proc/loadavg /proc/uptime 2>/dev/null; ' +
'echo __DF__; df -kP 2>/dev/null; ' +
'echo __NET__; cat /proc/net/dev 2>/dev/null; ' +
'echo __HOST__; hostname 2>/dev/null; uname -sr 2>/dev/null'
/** Failures this many in a row before auto-stopping the poll. */
const MAX_CONSECUTIVE_FAILS = 3
/** File-system types produced by `df -kP` that are not real disks. */
const FAKE_FS = new Set(['tmpfs', 'overlay', 'udev', 'devtmpfs', 'none', 'squashfs', 'shm'])
// Some df versions key the type column slightly differently; 'overlay'/'shm'
// are the common container hosts. sysfs/procfs never appear in df -kP.
/** Mirrors main/broadcast.ts but injectable so the loopback harness can capture it. */
interface SysinfoDeps {
broadcast(channel: string, ...args: unknown[]): void
}
let deps: SysinfoDeps | undefined
/** Resolve an ssh `Client` for a session id (injected by pty.ts). */
type ClientProvider = (id: string) => Client | undefined
let clientProvider: ClientProvider | undefined
/** Wire the broadcast sink. Called once during app startup (from pty.ts). */
export function configureSysinfo(d: SysinfoDeps): void {
deps = d
}
/** Register the session-id -> ssh client resolver (called by pty.ts). */
export function registerSysinfoClient(provider: ClientProvider): void {
clientProvider = provider
}
interface Prev {
/** cpu `idle`+`iowait` ticks from the previous sample */
idle: number
/** total ticks from the previous sample */
total: number
/** cumulative rx/tx bytes from the previous sample */
rx: number
tx: number
}
interface PollState {
id: string
intervalMs: number
stopped: boolean
consecutiveFails: number
prev?: Prev
/** most recent successful sample (kept for error frames) */
lastSample?: SysinfoSample
prevAt: number
metaSent: boolean
timer: NodeJS.Timeout
}
const polls = new Map<string, PollState>()
/**
* Start polling a session. Calling again for the same id stops the previous
* poll first (re-entrancy safe).
*/
export function startPolling(id: string, intervalMs = 3000): void {
stopPolling(id)
const state: PollState = {
id,
intervalMs,
stopped: false,
consecutiveFails: 0,
prev: undefined,
lastSample: undefined,
prevAt: 0,
metaSent: false,
timer: setTimeout(() => pollOnce(id, state), 0)
}
polls.set(id, state)
}
/** Stop polling a session (no-op when not polling). Also run on session close. */
export function stopPolling(id: string): void {
const state = polls.get(id)
if (!state) return
state.stopped = true
clearTimeout(state.timer)
polls.delete(id)
}
function armNext(id: string, state: PollState): void {
if (state.stopped) return
state.timer = setTimeout(() => pollOnce(id, state), state.intervalMs)
}
function pollOnce(id: string, state: PollState): void {
if (state.stopped) return
const client = clientProvider?.(id)
if (!client) {
handleError(id, state, 'SSH 会话不存在或已断开')
return
}
try {
client.exec(COLLECT_CMD, (err: Error | undefined, stream) => {
if (state.stopped) return
if (err || !stream) {
handleError(id, state, err?.message || 'SSH exec 失败')
return
}
let out = ''
stream.on('data', (d: Buffer) => {
out += d.toString('utf8')
})
const onEnd = (): void => {
if (state.stopped) return
try {
stream.close()
} catch {
// best effort
}
handleOutput(id, state, out)
}
stream.on('close', onEnd)
stream.on('error', () => onEnd())
})
} catch (err) {
handleError(id, state, (err as Error).message)
}
}
function handleError(id: string, state: PollState, message: string): void {
if (state.stopped) return
state.consecutiveFails += 1
const last = state.lastSample
const sample: SysinfoSample = last
? {
...last,
ts: Date.now(),
error: message
}
: {
ts: Date.now(),
cpu: { usage: 0, cores: 0, loadavg: [0, 0, 0] },
mem: { totalMb: 0, usedMb: 0 },
disks: [],
net: { rxKbs: 0, txKbs: 0 },
uptimeSec: 0,
error: message
}
broadcastSample(id, sample)
if (state.consecutiveFails >= MAX_CONSECUTIVE_FAILS) {
console.warn(`[sysinfo] session ${id} failed ${state.consecutiveFails} polls, stopping`)
stopPolling(id)
return
}
armNext(id, state)
}
function handleOutput(id: string, state: PollState, out: string): void {
if (state.stopped) return
const now = Date.now()
try {
const parsed = parseOutput(out)
const sample: SysinfoSample = {
ts: now,
cpu: parsed.cpu,
mem: parsed.mem,
disks: parsed.disks,
net: parsed.net,
uptimeSec: parsed.uptimeSec
}
state.consecutiveFails = 0
// Diff against the previous sample when one exists and rates are computable.
if (state.prev && state.prevAt > 0) {
const dtSec = (now - state.prevAt) / 1000
const totalDelta = parsed.cpuTotal - state.prev.total
const idleDelta = parsed.cpuIdle - state.prev.idle
if (totalDelta > 0) {
sample.cpu.usage = Math.max(0, Math.min(100, 100 * (1 - idleDelta / totalDelta)))
}
if (dtSec > 0) {
sample.net.rxKbs = Math.max(0, (parsed.netRx - state.prev.rx) / 1024 / dtSec)
sample.net.txKbs = Math.max(0, (parsed.netTx - state.prev.tx) / 1024 / dtSec)
}
}
state.prevAt = now
state.prev = {
idle: parsed.cpuIdle,
total: parsed.cpuTotal,
rx: parsed.netRx,
tx: parsed.netTx
}
state.lastSample = sample
broadcastSample(id, sample)
if (!state.metaSent && (parsed.hostname || parsed.osInfo)) {
state.metaSent = true
broadcastMeta(id, { hostname: parsed.hostname, os: parsed.osInfo })
}
} catch (err) {
handleError(id, state, `解析失败: ${(err as Error).message}`)
return
}
armNext(id, state)
}
function broadcastSample(id: string, sample: SysinfoSample): void {
try {
deps?.broadcast(Ipc.SYSINFO_SAMPLE, { id, sample })
} catch {
// never crash the event loop
}
}
function broadcastMeta(id: string, meta: SysinfoMeta): void {
try {
deps?.broadcast(Ipc.SYSINFO_META, { id, meta })
} catch {
// never crash the event loop
}
}
// ---- parsing ------------------------------------------------------------------
interface Parsed {
cpu: SysinfoSample['cpu']
cpuIdle: number
cpuTotal: number
mem: SysinfoSample['mem']
disks: SysinfoSample['disks']
net: SysinfoSample['net']
netRx: number
netTx: number
uptimeSec: number
hostname: string
osInfo: string
}
function parseOutput(raw: string): Parsed {
// Split into the four delimited regions.
const parts = raw.split(/__DF__|__NET__|__HOST__/)
const procBlob = parts[0] ?? ''
const dfBlob = parts[1] ?? ''
const netBlob = parts[2] ?? ''
const hostBlob = parts[3] ?? ''
return {
...parseProc(procBlob),
disks: parseDf(dfBlob),
...parseNet(netBlob),
...parseHost(hostBlob)
}
}
function toNumber(s: string | undefined): number {
const n = Number(s)
return Number.isFinite(n) ? n : 0
}
function parseProc(blob: string): { cpu: SysinfoSample['cpu']; cpuIdle: number; cpuTotal: number; mem: SysinfoSample['mem']; uptimeSec: number } {
const lines = blob.split('\n')
let total = 0
let idle = 0
let cores = 0
let user = 0
const loadavg: [number, number, number] = [0, 0, 0]
let memTotal = 0
let memAvailable = 0
let uptimeSec = 0
for (const raw of lines) {
const line = raw.trimEnd()
if (line.startsWith('cpu')) {
if (line.startsWith('cpu ')) {
const f = line.split(/\s+/).slice(1).map(toNumber)
user = f[0] ?? 0
const nice = f[1] ?? 0
const system = f[2] ?? 0
const idleTicks = f[3] ?? 0
const iowait = f[4] ?? 0
const irq = f[5] ?? 0
const softirq = f[6] ?? 0
const steal = f[7] ?? 0
const guest = f[8] ?? 0
const guestNice = f[9] ?? 0
idle = idleTicks + iowait
total =
user +
nice +
system +
idle +
irq +
softirq +
steal +
guest +
guestNice
} else {
cores += 1
}
} else if (line.startsWith('MemTotal:')) {
memTotal = toNumber(line.split(/\s+/)[1])
} else if (line.startsWith('MemAvailable:')) {
memAvailable = toNumber(line.split(/\s+/)[1])
} else if (line.includes('/')) {
// /proc/loadavg: "0.00 0.01 0.05 1/234 5678"
const loadMatch = /^\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)/.exec(line)
if (loadMatch) {
loadavg[0] = toNumber(loadMatch[1])
loadavg[1] = toNumber(loadMatch[2])
loadavg[2] = toNumber(loadMatch[3])
}
} else if (/^\d/.test(line) && line.split(/\s+/).length === 2) {
// /proc/uptime: "12345.67 9876.54"
uptimeSec = toNumber(line.split(/\s+/)[0])
}
}
return {
cpu: {
usage: 0,
cores,
loadavg
},
cpuIdle: idle,
cpuTotal: total,
mem: {
// meminfo columns are kB; sample schema is MiB.
totalMb: Math.round(memTotal / 1024),
usedMb: memAvailable > 0 && memTotal >= memAvailable
? Math.round((memTotal - memAvailable) / 1024)
: 0
},
uptimeSec: Math.round(uptimeSec)
}
}
function parseDf(blob: string): SysinfoSample['disks'] {
const disks: SysinfoSample['disks'] = []
for (const raw of blob.split('\n')) {
const line = raw.trim()
if (!line || line.startsWith('Filesystem')) continue
const f = line.split(/\s+/)
if (f.length < 6) continue
// Columns: Filesystem 1K-blocks Used Available Use% Mounted on
const fs = f[0]
const mount = f.slice(5).join(' ')
// df -kP has no type column; pseudofs appear as the device name (tmpfs,
// overlay, udev, ...) or as a mount point under the shared /dev harness.
if (FAKE_FS.has(fs) || FAKE_FS.has(mount)) continue
const totalKb = toNumber(f[1])
const usedKb = toNumber(f[2])
if (totalKb <= 0) continue
disks.push({
mount,
totalMb: Math.round(totalKb / 1024),
usedMb: Math.round(usedKb / 1024)
})
}
return disks
}
function parseNet(
blob: string
): { net: SysinfoSample['net']; netRx: number; netTx: number } {
let rx = 0
let tx = 0
for (const raw of blob.split('\n')) {
const line = raw.trim()
if (!line || line.startsWith('Inter')) continue
const colon = line.indexOf(':')
if (colon <= 0) continue
const iface = line.slice(0, colon)
const fields = line
.slice(colon + 1)
.trim()
.split(/\s+/)
.map(Number)
// /proc/net/dev rows: rx_bytes rx_packets ... rx_multicast tx_bytes ...
if (fields.length < 10 || fields.some((n) => !Number.isFinite(n))) continue
if (iface === 'lo') continue
rx += fields[0]
tx += fields[8]
}
return { net: { rxKbs: 0, txKbs: 0 }, netRx: rx, netTx: tx }
}
function parseHost(blob: string): { hostname: string; osInfo: string } {
const lines = blob.split('\n').map((l) => l.trim()).filter(Boolean)
return {
hostname: lines[0] ?? '',
osInfo: lines.slice(1).join(' ').trim()
}
}
+118
View File
@@ -0,0 +1,118 @@
import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron'
import type { SystemSettings } from '@shared/settings'
import { loadSettings, saveSettings } from './settingsStore'
import trayIconPath from './assets/tray.png?asset'
export type CloseAction = NonNullable<SystemSettings['closeAction']>
let tray: Tray | null = null
let quitting = false
let balloonShown = false
/** Once true, window close events pass through and the app really quits. */
export function markQuitting(): void {
quitting = true
}
function persistCloseAction(action: CloseAction): void {
const next = loadSettings()
next.system.closeAction = action
saveSettings(next)
}
function showMainWindow(showOrCreate: () => void): void {
const win = BrowserWindow.getAllWindows()[0]
if (win) {
if (win.isMinimized()) win.restore()
win.show()
win.focus()
} else {
showOrCreate()
}
}
/** Rebuild the context menu so the close-action radio items reflect settings. */
function refreshContextMenu(showOrCreate: () => void): void {
if (!tray) return
const action = loadSettings().system.closeAction ?? 'ask'
const setAction = (value: CloseAction) => (): void => {
persistCloseAction(value)
refreshContextMenu(showOrCreate)
}
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: '显示主窗口', click: (): void => showMainWindow(showOrCreate) },
{ type: 'separator' },
{ label: '关闭按钮行为', enabled: false },
{ label: '每次询问', type: 'radio', checked: action === 'ask', click: setAction('ask') },
{ label: '最小化到托盘', type: 'radio', checked: action === 'tray', click: setAction('tray') },
{ label: '直接退出', type: 'radio', checked: action === 'exit', click: setAction('exit') },
{ type: 'separator' },
{
label: '退出',
click: (): void => {
markQuitting()
app.quit()
}
}
])
)
}
/** Create the tray icon and wire left-click to window restore. */
export function initTray(showOrCreate: () => void): void {
const icon = nativeImage.createFromPath(trayIconPath)
tray = new Tray(icon)
tray.setToolTip('OpenTerminal')
tray.on('click', (): void => showMainWindow(showOrCreate))
refreshContextMenu(showOrCreate)
}
function hideToTray(win: BrowserWindow): void {
win.hide()
if (process.platform === 'win32' && tray && !balloonShown) {
balloonShown = true
tray.displayBalloon({
iconType: 'info',
title: 'OpenTerminal 仍在运行',
content: '已最小化到系统托盘,终端会话保持运行。点击托盘图标可恢复窗口。'
})
}
}
/**
* Window close interception. Must be called synchronously from the 'close'
* event: preventDefault happens before any await on every branch.
*/
export async function onMainWindowClose(win: BrowserWindow, e: Electron.Event, showOrCreate: () => void): Promise<void> {
if (quitting) return
const action = loadSettings().system.closeAction ?? 'ask'
if (action === 'tray') {
e.preventDefault()
hideToTray(win)
return
}
if (action === 'exit') return // fall through: close proceeds, app quits
e.preventDefault()
const { response, checkboxChecked } = await dialog.showMessageBox(win, {
type: 'question',
title: '关闭 OpenTerminal',
message: '最小化到托盘,还是直接退出?',
detail: '最小化到托盘时终端会话保持运行。',
buttons: ['最小化到托盘', '直接退出'],
defaultId: 0,
cancelId: 1,
checkboxLabel: '记住我的选择,不再询问',
checkboxChecked: false,
noLink: true
})
if (response === 0) {
if (checkboxChecked) persistCloseAction('tray')
hideToTray(win)
} else {
if (checkboxChecked) persistCloseAction('exit')
markQuitting()
app.quit()
}
refreshContextMenu(showOrCreate)
}
+54
View File
@@ -0,0 +1,54 @@
import { app } from 'electron'
import { autoUpdater } from 'electron-updater'
/**
* Default update feed (generic provider). The main agent uploads latest.yml and
* the NSIS installer to this stable path.
*
* KNOWN LIMITATION: electron-updater does NOT support MSI auto-updates. The
* update channel always runs through the NSIS .exe (latest.yml + .exe are
* uploaded together). MSI is manual/enterprise distribution only.
*/
const DEFAULT_FEED_URL =
'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/'
export function configureAutoUpdater(): void {
// Update checks only run in packaged builds; no-op during development.
if (!app.isPackaged) {
return
}
const url = process.env.OT_UPDATE_URL || DEFAULT_FEED_URL
const token = process.env.OT_UPDATE_TOKEN
autoUpdater.setFeedURL({
provider: 'generic',
url,
...(token ? { requestHeaders: { Authorization: `token ${token}` } } : {})
})
autoUpdater.logger = console
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
// Check shortly after startup; log errors without crashing the app.
const timer = setTimeout(() => {
autoUpdater
.checkForUpdatesAndNotify()
.catch((err) => console.warn('[updater] checkForUpdates failed:', err))
}, 5000)
timer.unref?.()
}
/** Public entry point for later UI wiring (not connected this milestone). */
export function checkForUpdates(): Promise<void> {
if (!app.isPackaged) {
return Promise.resolve()
}
return autoUpdater
.checkForUpdates()
.then(() => {})
.catch((err) => {
console.warn('[updater] checkForUpdates failed:', err)
})
}
+16
View File
@@ -0,0 +1,16 @@
import { BrowserWindow } from 'electron'
import type { AppSettings } from '@shared/settings'
import { getThemeById } from '@shared/theme'
/** Title bar overlay + window background follow the active terminal theme. */
export function applyWindowChrome(settings: AppSettings): void {
const theme = getThemeById(settings.terminal.themeId, settings.customThemes)
const bg = theme.colors.background
const fg = theme.colors.foreground
for (const win of BrowserWindow.getAllWindows()) {
win.setBackgroundColor(bg)
if (process.platform === 'win32') {
win.setTitleBarOverlay({ color: bg, symbolColor: fg, height: 36 })
}
}
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Minimal ambient typings for the `zmodem.js` package (v0.1.10).
*
* zmodem.js ships no type declarations and `@types/zmodem.js` is not installed;
* npm install is out of scope here, so this file declares exactly the surface
* src/main/zmodem.ts consumes. Verified against the sources in
* node_modules/zmodem.js/src/{zsentry,zsession,zvalidation,zheader}.js:
*
* - Sentry.consume() accepts a Uint8Array / ArrayBuffer / octet Array.
* - Detection.confirm() returns the matched Session; the Session's `.type`
* is "receive" (remote `sz` -> we download) or "send" (remote `rz` -> we
* upload).
* - The byte arrays passed to `to_terminal` / `sender` are plain number[].
*/
declare module 'zmodem.js' {
/** one batch of a file being received (see Offer) */
export interface FileDetails {
name: string
/** can be null when the peer streams an unknown size; zmodem.js accepts */
size?: number | null
/** POSIX mode bits (0x8000 | perm); optional */
mode?: number | null
/** Date or epoch seconds; optional */
mtime?: Date | number | null
files_remaining?: number | null
bytes_remaining?: number | null
}
/** A sender-side single-file transfer handle (result of send_offer()). */
export interface Transfer {
get_details(): FileDetails
get_offset(): number
/** Send a non-terminal chunk. */
send(arrayLike: number[] | Uint8Array): void
/** Send the terminal chunk; resolves when the receiver confirms file end. */
end(arrayLike?: number[] | Uint8Array): Promise<void>
}
/** A receiver-side offerable file (shown on a receive Session's "offer"). */
export interface Offer {
get_details(): FileDetails
get_offset(): number
/** Skip this file (not an error, unlike abort()). */
skip(): Promise<never>
/**
* Accept the offer. With { on_input: fn } the payload of every data
* subpacket is delivered to `fn` (streaming, no full spool in memory).
*/
accept(opts?: {
offset?: number
on_input?: 'spool_array' | 'spool_uint8array' | ((payload: Uint8Array) => void)
}): Promise<unknown>
on(event: 'input', cb: (payload: Uint8Array) => void): Offer
on(event: 'complete', cb: () => void): Offer
}
/** Base ZMODEM session (Send/Receive share this). */
export interface Session {
type: 'send' | 'receive'
set_sender(fn: (octets: number[]) => void): void
has_ended(): boolean
aborted(): boolean
/** Abort the transfer (sends the ZMODEM CAN sequence to the peer). */
abort(): void
/** Register a session_end handler (fires once the transfer is done). */
on(event: 'session_end', cb: () => void): void
/** Catch-all overload (keeps the Send|Receive union from collapsing to never). */
on(event: never, cb: (...args: unknown[]) => void): void
}
/** Send session (we upload, remote ran rz). */
export interface SendSession extends Session {
type: 'send'
/** Offer one file; resolves with the Transfer or undefined if skipped. */
send_offer(params: FileDetails): Promise<Transfer | undefined>
/** Ends a send session gracefully; resolves when the receiver acks ZFIN. */
close(): Promise<void>
}
/** Receive session (we download, remote ran sz). */
export interface ReceiveSession extends Session {
type: 'receive'
on(event: 'offer', cb: (offer: Offer) => void): void
/** Begin receiving: tells the peer we are ready for the first offer. */
start(): Promise<void>
}
export interface SentryOptions {
/** Non-ZMODEM octets to forward to the terminal. */
to_terminal(octets: number[]): void
/** Called with a Detection when a ZMODEM header is spotted. */
on_detect(detection: Detection): void
/** Called when a pending Detection is retracted (no session after all). */
on_retract(): void
/** Send octets to the peer (the ssh stream). */
sender(octets: number[]): void
}
export interface Detection {
/** Whether the detection is still valid (not retracted). */
is_valid(): boolean
/** Confirm the ZMODEM session and return the active Session object. */
confirm(): SendSession | ReceiveSession
/** Give the session's role: 'send' or 'receive'. */
get_session_role(): 'send' | 'receive'
/** Reject: tell the peer to abort the session. */
deny(): void
}
export const Sentry: new (options: SentryOptions) => Sentry
export interface Sentry {
consume(input: Uint8Array | ArrayBuffer | number[]): void
get_confirmed_session(): Session | null
}
export const ZMLIB: { ABORT_SEQUENCE: number[] }
export const ZDLE: new () => unknown
export const CRC: { crc16(input: ArrayLike<number>): number }
export const Header: Record<string, unknown>
export const Subpacket: Record<string, unknown>
export const Session: {
Send: new (zrinitHeader: unknown) => SendSession
Receive: new () => ReceiveSession
}
}
+476
View File
@@ -0,0 +1,476 @@
/**
* ZMODEM engine (M6) - lives in the MAIN process and only ever runs over an SSH
* session stream, using zmodem.js@0.1.10.
*
* LOCAL PTY SESSIONS ARE DELIBERATELY NOT SUPPORTED: node-pty / Windows ConPTY
* only hand us UTF-8 strings and are lossy for arbitrary bytes, so a binary
* ZMODEM stream would be corrupted (and there is no file dialog on the local
* side anyway). The engine is therefore only ever attached to an ssh2 channel,
* where we get raw Buffers.
*
* Design mirrors pty.ts's dependency-injection pattern: this module holds no
* Electron import. pty.ts injects `broadcast`/`toTerminal`/`writeStream`, and
* routes the ssh stream's 'data' through `feedZmodem`. While a transfer is
* active we suppress the normal terminal data-plane (binary frames must never
* reach the terminal), and pty.ts's writePty drops user keystrokes via
* isZmodemActive.
*/
import { basename, join } from 'path'
import { createReadStream, createWriteStream } from 'fs'
import { stat } from 'fs/promises'
import type { Writable } from 'stream'
import * as Zmodem from 'zmodem.js'
import { Ipc } from '../shared/ipc'
import type { ZmodemDoneEvent, ZmodemResponse } from '../shared/ipc'
import type { TransferProgressEvent } from '../shared/sftp'
/** How long to wait for the renderer to answer a ZMODEM_OFFER (ms). */
const OFFER_TIMEOUT_MS = 120_000
/** Abort a transfer that makes no byte progress for this long (ms). */
const STALL_TIMEOUT_MS = 90_000
const ABORT_BUFFER = Buffer.from(Zmodem.ZMLIB.ABORT_SEQUENCE)
export interface ZmodemDeps {
/** Emit a main->renderer broadcast (ZMODEM_OFFER / ZMODEM_DONE / TRANSFER_PROGRESS). */
broadcast(channel: string, ...args: unknown[]): void
/**
* Forward NON-ZMODEM octets back through the normal terminal data path
* (utf8 + replay + session log + PTY_DATA). pty.ts injects this; the engine
* only calls it when no transfer is active.
*/
toTerminal(sessionId: string, text: string): void
/** Write bytes out to the ssh stream (zmodem frames + CAN abort sequence). */
writeStream(sessionId: string, data: Buffer): void
}
interface Engine {
id: string
deps: ZmodemDeps
sentry: Zmodem.Sentry
detection: Zmodem.Detection | null
session: Zmodem.SendSession | Zmodem.ReceiveSession | null
/** True from header detection until the session ends / teardown. */
active: boolean
mode: 'send' | 'receive' | null
dir: string | null
/** True once the detection is confirmed and files start flowing. */
confirmed: boolean
transferId: string
/** one progress terminal event at most */
progressEmitted: boolean
doneEmitted: boolean
offerTimer: NodeJS.Timeout | null
stallTimer: NodeJS.Timeout | null
receiveStream: Writable | null
bytes: number
totalBytes: number
}
const engines = new Map<string, Engine>()
function current(sessionId: string): Engine | undefined {
return engines.get(sessionId)
}
function cancelTimers(engine: Engine): void {
if (engine.offerTimer) clearTimeout(engine.offerTimer)
if (engine.stallTimer) clearTimeout(engine.stallTimer)
engine.offerTimer = null
engine.stallTimer = null
}
function emitProgress(engine: Engine, patch: Partial<TransferProgressEvent>): void {
const evt: TransferProgressEvent = {
transferId: engine.transferId,
kind: engine.mode === 'receive' ? 'zmodem-download' : 'zmodem-upload',
state: 'running',
file: '',
bytes: engine.bytes,
totalBytes: engine.totalBytes,
...patch
}
try {
engine.deps.broadcast(Ipc.TRANSFER_PROGRESS, evt)
} catch {
// never crash the event loop
}
}
function emitDone(engine: Engine, ok: boolean, message?: string): void {
if (engine.doneEmitted) return
engine.doneEmitted = true
const evt: ZmodemDoneEvent = { id: engine.id, ok, message }
try {
engine.deps.broadcast(Ipc.ZMODEM_DONE, evt)
} catch {
// never crash the event loop
}
}
/**
* End-of-session / cancel / error common path: stop suppressing the terminal
* data plane, drop timers, release streams, and emit the terminal events once.
*/
function finalize(
engine: Engine,
ok: boolean,
message?: string,
failState: 'cancelled' | 'error' = 'error'
): void {
engine.active = false
cancelTimers(engine)
if (engine.receiveStream) {
try {
engine.receiveStream.end()
} catch {
// already closed
}
engine.receiveStream = null
}
engine.session = null // the zsession already ended; drop the reference
engine.detection = null
if (!engine.progressEmitted) {
emitProgress(engine, { state: ok ? 'done' : failState, file: message ?? '' })
}
engine.progressEmitted = true
if (ok) emitDone(engine, true)
else emitDone(engine, false, message ?? '传输失败')
}
/** Called by the zsession's own 'session_end' event (its `this` is the session). */
function makeSessionEnd(engine: Engine): () => void {
return () => finalize(engine, true)
}
function touchActivity(engine: Engine): void {
if (engine.stallTimer) clearTimeout(engine.stallTimer)
const timer = setTimeout(() => {
if (engine.stallTimer === null) return // already finalized
finalize(engine, false, '传输超时')
}, STALL_TIMEOUT_MS)
engine.stallTimer = timer
}
function wireSession(engine: Engine): void {
const session = engine.session
if (!session) return
const sendToPeer = (octets: number[]): void => {
try {
engine.deps.writeStream(engine.id, Buffer.from(octets))
} catch {
// stream gone mid-transfer
}
}
session.set_sender(sendToPeer)
;(session as unknown as { on: (event: string, cb: () => void) => void }).on('session_end', makeSessionEnd(engine))
touchActivity(engine)
}
// ---------------------------------------------------------------------------
// Sending (remote `rz` -> we upload local files)
// ---------------------------------------------------------------------------
async function sendOneFile(
engine: Engine,
session: Zmodem.SendSession,
file: { path: string; name: string; size: number; mtimeMs: number }
): Promise<void> {
const xfer = await session.send_offer({
name: file.name,
size: file.size,
mtime: new Date(file.mtimeMs),
mode: undefined
})
if (!xfer) return // receiver skipped the file
emitProgress(engine, { file: file.name, bytes: engine.bytes, totalBytes: engine.totalBytes })
await new Promise<void>((resolve, reject) => {
const rs = createReadStream(file.path)
rs.on('data', (chunk: string | Buffer) => {
rs.pause()
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
try {
xfer.send(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength))
} catch (err) {
rs.destroy()
reject(err instanceof Error ? err : new Error('发送失败'))
return
}
engine.bytes += buf.length
emitProgress(engine, { file: file.name, bytes: engine.bytes, totalBytes: engine.totalBytes })
touchActivity(engine)
rs.resume()
})
rs.on('end', () => {
xfer
.end()
.then(() => resolve())
.catch(reject)
})
rs.on('error', reject)
})
}
async function runSend(engine: Engine, paths: string[]): Promise<void> {
try {
const session = engine.session as Zmodem.SendSession
const files = await Promise.all(
paths.map(async (p) => {
const s = await stat(p)
return { path: p, name: basename(p), size: s.size, mtimeMs: s.mtimeMs }
})
)
engine.totalBytes = files.reduce((acc, f) => acc + f.size, 0)
for (const f of files) {
emitProgress(engine, { file: f.name, bytes: engine.bytes, totalBytes: engine.totalBytes })
await sendOneFile(engine, session, f)
}
emitProgress(engine, { file: '', bytes: engine.totalBytes, totalBytes: engine.totalBytes })
// Final state + ZMODEM_DONE come from the session_end event fired by close().
await session.close()
} catch (err) {
finalize(engine, false, err instanceof Error ? err.message : '上传失败')
}
}
// ---------------------------------------------------------------------------
// Receiving (remote `sz` -> we download into a chosen local directory)
// ---------------------------------------------------------------------------
function handleOffer(engine: Engine, offer: Zmodem.Offer): void {
const details = offer.get_details()
const name = basename(String(details.name ?? 'file'))
const dest = join(engine.dir ?? '.', name)
if (details.size) engine.totalBytes += details.size
const stream = createWriteStream(dest)
engine.receiveStream = stream
emitProgress(engine, { file: name, bytes: engine.bytes, totalBytes: engine.totalBytes })
offer
.accept({
on_input: (payload: Uint8Array | number[]) => {
// zmodem.js delivers the payload as a plain octet Array (not a
// Uint8Array); coerce defensively to a Buffer before writing.
const buf = Array.isArray(payload) ? Buffer.from(payload) : Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)
if (!stream.destroyed && !stream.closed) {
stream.write(buf)
}
engine.bytes += buf.byteLength
emitProgress(engine, { file: name, bytes: engine.bytes, totalBytes: engine.totalBytes })
touchActivity(engine)
}
})
.then(() => {
stream.end(() => {
if (engine.receiveStream === stream) engine.receiveStream = null
emitProgress(engine, { file: name, bytes: engine.bytes, totalBytes: engine.totalBytes })
})
})
.catch((err: unknown) => {
try {
stream.destroy()
} catch {
// already gone
}
finalize(engine, false, err instanceof Error ? err.message : '接收失败')
})
}
// ---------------------------------------------------------------------------
// Public API (used by pty.ts / ipc.ts)
// ---------------------------------------------------------------------------
/**
* Attach a ZMODEM Sentry to an ssh session. Only call for ssh sessions (see
* module docs: local ptys are unsupported by design). deps are injected by
* pty.ts so this module stays Electron-free.
*/
export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
const engine: Engine = {
id: sessionId,
deps,
sentry: new Zmodem.Sentry({
to_terminal: (octets: number[]) => {
// Analysis: also defensive against active http-parsing leftovers.
if (engine.active) return // suppress binary terminal output during transfer
try {
deps.toTerminal(sessionId, Buffer.from(octets).toString('utf8'))
} catch {
// never crash the event loop
}
},
on_detect: (detection: Zmodem.Detection) => {
const role = detection.get_session_role()
engine.mode = role === 'receive' ? 'receive' : 'send'
engine.detection = detection
engine.active = true
engine.transferId = `zm-${sessionId}`
emitProgress(engine, { file: '', bytes: 0, totalBytes: 0 })
try {
deps.broadcast(Ipc.ZMODEM_OFFER, { id: sessionId, mode: engine.mode })
} catch {
// never crash the event loop
}
touchActivity(engine)
engine.offerTimer = setTimeout(() => {
if (engine.detection && !engine.confirmed) abortWithoutSession(engine, '未选择文件,已取消')
}, OFFER_TIMEOUT_MS)
},
on_retract: () => {
// The detected "session" turned out not to be ZMODEM; return to normal.
engine.detection = null
engine.mode = null
engine.active = false
cancelTimers(engine)
},
sender: (octets: number[]) => {
try {
deps.writeStream(sessionId, Buffer.from(octets))
} catch {
// stream gone mid-transfer
}
}
}),
detection: null,
session: null,
active: false,
mode: null,
dir: null,
confirmed: false,
transferId: `zm-${sessionId}`,
progressEmitted: false,
doneEmitted: false,
offerTimer: null,
stallTimer: null,
receiveStream: null,
bytes: 0,
totalBytes: 0
}
engines.set(sessionId, engine)
}
/**
* Abort an unconfirmed (or pre-session) offer. No live zsession yet, so we write
* the CAN abort sequence directly so the remote program exits.
*/
function abortWithoutSession(engine: Engine, message: string): void {
try {
engine.deps.writeStream(engine.id, ABORT_BUFFER)
} catch {
// stream may be gone
}
engine.active = false
cancelTimers(engine)
if (!engine.progressEmitted) emitProgress(engine, { state: 'cancelled', file: message })
engine.progressEmitted = true
emitDone(engine, false, message)
}
/** Abort a confirmed session cleanly (sends CAN so remote rz/sz exits). */
function abortSession(engine: Engine, message: string): void {
try {
engine.session?.abort()
} catch {
// fall through to write the abort sequence ourselves
}
try {
engine.deps.writeStream(engine.id, ABORT_BUFFER)
} catch {
// stream may be gone
}
finalize(engine, false, message, 'cancelled')
}
/** Feed raw ssh-stream bytes into the engine. Only call when isZmodemActive(). */
export function feedZmodem(sessionId: string, data: Buffer): void {
const engine = current(sessionId)
if (!engine) return
try {
engine.sentry.consume(data)
} catch (err) {
// A zmodem.js Error (peer abort / corrupt frame) ends the session.
if (!engine.doneEmitted) finalize(engine, false, err instanceof Error ? err.message : 'ZMODEM 传输异常')
}
}
/** Whether a zmodem transfer is active (pty.ts suppresses user writes while true). */
export function isZmodemActive(sessionId: string): boolean {
return current(sessionId)?.active ?? false
}
/**
* The renderer answered an offer (via ipc.ts). `cancelled` aborts; otherwise
* the flow proceeds according to the detected mode.
*/
export function respondZmodem(resp: ZmodemResponse): void {
const engine = current(resp.id)
if (!engine || engine.confirmed) return
if (resp.cancelled) {
abortWithoutSession(engine, '已取消')
return
}
const detection = engine.detection
if (!detection || !detection.is_valid()) {
abortWithoutSession(engine, '会话已失效')
return
}
engine.confirmed = true
// The offer was answered — stop the offer watchdog; the stall timer takes over.
if (engine.offerTimer) {
clearTimeout(engine.offerTimer)
engine.offerTimer = null
}
engine.session = detection.confirm()
if (!engine.session) {
abortWithoutSession(engine, '无法建立 ZMODEM 会话')
return
}
wireSession(engine)
if (engine.mode === 'receive') {
if (!resp.dir) {
abortSession(engine, '未指定保存目录')
return
}
engine.dir = resp.dir
const session = engine.session as Zmodem.ReceiveSession
session.on('offer', (offer) => handleOffer(engine, offer))
void session
.start()
.catch((err) => finalize(engine, false, err instanceof Error ? err.message : '接收失败'))
} else {
const paths = resp.paths ?? []
if (paths.length === 0) {
abortSession(engine, '未选择文件')
return
}
void runSend(engine, paths)
}
}
/** Detach + clean up (session close / kill). Safe to call any number of times. */
export function detachZmodem(sessionId: string): void {
const engine = current(sessionId)
if (!engine) return
engine.active = false
cancelTimers(engine)
if (engine.receiveStream) {
try {
engine.receiveStream.end()
} catch {
// ignore
}
engine.receiveStream = null
}
engine.session = null
engine.detection = null
engines.delete(sessionId)
}