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:
commit
a9acdbe500
99 files changed
+23940
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 131 B |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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}`)
|
||||
}
|
||||
}
|
||||
Vendored
+137
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+125
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user