fix(main): SFTP data integrity, session lifecycle, security hardening

- upload: per-chunk buffer (ssh2 re-reads the overflow tail after the ACK;
  a reused buffer silently corrupted every file >= ~254KB)
- close the cached SFTP channel on eviction, attach an 'error' handler,
  close the download handle, time out execQuiet, fail partial deletes
- per-session StringDecoder for the ssh data plane (CJK mojibake), real
  exit codes, safe replay truncation, zmodem abort/counter/timer fixes
- sysinfo: idempotent poll end, error routing, per-poll watchdog, proc(5)
  CPU total; expand cd ~/$HOME/%USERPROFILE% paths; log sanitizer fixes
- security: will-navigate guard, central IPC sender check, scheme
  allowlist for openExternal, single-instance else branch, layout id and
  log-name whitelists, custom theme sanitizing, atomic JSON writes with
  EPERM retry in store.writeJson
- updater: per-attempt feed choice, quitAndInstall relaunch, dev guard,
  update-state getter
This commit is contained in:
Bill committed 2026-09-20 20:26:34 +08:00
1 parent 4c6a29ef28
commit e04f4f0ac1
16 files changed
+450 -96

No files matched your search

+10 -6
View File
@@ -27,6 +27,7 @@ import type { CommandItem, SessionLogMeta } from '../shared/commands'
import { DEFAULT_SETTINGS } from '../shared/settings'
import { loadSettings } from './settingsStore'
import { LogSanitizer } from './logSanitizer'
import { writeJson } from './store'
/** Upper bound on recorded history entries when no limit is configured. */
const HISTORY_CAP = 500
@@ -113,8 +114,7 @@ export class CommandsStore {
}
private saveCommands(data: CommandsFile): void {
mkdirSync(join(this.file, '..'), { recursive: true })
writeFileSync(this.file, JSON.stringify(data, null, 2), 'utf8')
writeJson(this.file, data)
}
/**
@@ -132,7 +132,9 @@ export class CommandsStore {
* existing history is left untouched rather than cleared.
*/
recordCommand(cmd: string): void {
const trimmed = cmd.trim()
// The line arrives from the renderer's line buffer, which can still hold
// editing bytes (Ctrl+U, a stray ESC): only the printable text is a command.
const trimmed = cmd.replace(/[\x00-\x1f\x7f]/g, '').trim()
if (!trimmed) return
const { historyEnabled, historyLimit } = loadHistoryPrefs()
if (!historyEnabled) return
@@ -246,11 +248,10 @@ export class CommandsStore {
/** 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')
writeJson(this.indexFile, all)
} catch {
// best effort: never break the session over an index write failure
}
@@ -269,7 +270,10 @@ export class CommandsStore {
if (prev) this.finishLog(prev)
mkdirSync(this.logsDir, { recursive: true })
const fileName = `${stamp()}-${sessionId.slice(0, 8)}.log`
// The id comes from the renderer and lands in a file name: keep only the
// characters a session id is made of so it can never reach outside logsDir.
const safeId = sessionId.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 8) || 'session'
const fileName = `${stamp()}-${safeId}.log`
const file = join(this.logsDir, fileName)
// Create the file eagerly so the first async append cannot race a missing fd.
describeFile(file)
+3 -4
View File
@@ -13,9 +13,9 @@
import { safeStorage } from 'electron'
import { randomUUID } from 'crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname } from 'path'
import { readFileSync } from 'fs'
import type { SshAuthMethod, SshConnection, SshConnectionInput } from '../shared/connections'
import { writeJson } from './store'
const ENC_SUFFIX = '_enc'
const PLAIN_PREFIX = 'plain:'
@@ -128,8 +128,7 @@ export class ConnectionsStore {
}
private save(list: StoredConnection[]): void {
mkdirSync(dirname(this.filePath), { recursive: true })
writeFileSync(this.filePath, JSON.stringify(list, null, 2), 'utf8')
writeJson(this.filePath, list)
}
listConnections(): SshConnection[] {
+4
View File
@@ -37,6 +37,10 @@ export function resolveCwd(current: string | undefined, rawArg: string): string
}
// `cd -` means the shell's previous directory, which we cannot know here.
if (arg === '-') return null
// `cd ~/src`, `cd $HOME/x`, `cd %USERPROFILE%\x`: expand the home prefix
// before the absolute/relative decision below.
const homePrefix = arg.match(/^(?:~|\$HOME)(?=[/\\])/) ?? arg.match(/^%USERPROFILE%(?=[/\\])/i)
if (homePrefix) arg = homedir() + arg.slice(homePrefix[0].length)
// Drive-relative `cd d:` (cmd/PowerShell): the target is the drive's
// current directory, which the shell never tells us — path.isAbsolute('d:')
// is false, so without this branch it resolves against the base and dies on
+55 -21
View File
@@ -1,8 +1,8 @@
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 { isTrustedRendererUrl, registerIpc } from './ipc'
import { killAllPtys, killPtysByOwner } from './pty'
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
import { configureAutoUpdater, registerUpdateIpc } from './updater'
@@ -21,6 +21,21 @@ function showOrCreate(): void {
}
}
/**
* Hand a link to the OS browser. Only the web schemes are allowed: a renderer
* that asked to open `file:`/`ms-*:`/anything else must not reach the shell.
*/
function openExternal(url: string): void {
try {
const { protocol } = new URL(url)
if (protocol === 'http:' || protocol === 'https:' || protocol === 'ftp:') {
void shell.openExternal(url)
}
} catch {
// unparsable target: nothing to open
}
}
// Dev runs get their own userData directory (settings, session snapshot,
// command history, logs) *and* their own single-instance lock — both are keyed
// on that path. Without this a dev instance fights the installed build: it
@@ -32,11 +47,34 @@ if (!app.isPackaged) {
// Single instance: a second launch just surfaces the existing window (pulls
// it out of the tray if hidden there) instead of starting another process.
// The refused instance skips the whole startup path: `app.quit()` only asks
// the app to exit, so running the `whenReady` init behind it left a second
// process with IPC, a tray and an updater but no window.
const gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!gotSingleInstanceLock) {
app.quit()
} else {
app.on('second-instance', () => showOrCreate())
app.whenReady().then(() => {
registerIpc()
registerUpdateIpc()
// Before the window exists: a renderer-triggered check must not run against
// the updater's defaults (feed, proxy, autoDownload are set in here).
configureAutoUpdater()
// OS-level effects (login item, sleep blocker) must apply even if the
// settings dialog is never opened this run.
applyStartupSystemSettings(loadSettings())
createWindow()
initTray(showOrCreate)
// Tray labels are resolved from the dictionary at build time, so the menu has
// to be rebuilt whenever the interface language changes.
onLanguageChange(() => refreshTrayMenu())
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
}
function createWindow(): void {
@@ -95,11 +133,25 @@ function createWindow(): void {
}
})
// A renderer that crashed or was destroyed without running its own cleanup
// (normal close/reload kills its sessions via beforeunload first) leaves
// orphaned PTY/SSH transports behind — and session logs that keep appending.
const dropOwnedSessions = (): void => killPtysByOwner(win.webContents.id)
win.webContents.on('render-process-gone', dropOwnedSessions)
win.webContents.on('destroyed', dropOwnedSessions)
win.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
openExternal(details.url)
return { action: 'deny' }
})
// Dropping a local .html file on the window is a navigation like any other,
// and the new document would inherit this window's privileged preload. Only
// the app's own page may (re)load here.
win.webContents.on('will-navigate', (event, url) => {
if (!isTrustedRendererUrl(url)) event.preventDefault()
})
const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl) {
win.loadURL(devUrl)
@@ -115,24 +167,6 @@ process.on('uncaughtException', (err) => {
console.error('[main] uncaughtException:', err)
})
app.whenReady().then(() => {
registerIpc()
registerUpdateIpc()
// OS-level effects (login item, sleep blocker) must apply even if the
// settings dialog is never opened this run.
applyStartupSystemSettings(loadSettings())
createWindow()
initTray(showOrCreate)
// Tray labels are resolved from the dictionary at build time, so the menu has
// to be rebuilt whenever the interface language changes.
onLanguageChange(() => refreshTrayMenu())
configureAutoUpdater()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('before-quit', () => {
// Let window 'close' events pass through so teardown completes.
markQuitting()
+66 -2
View File
@@ -1,7 +1,10 @@
import { app, ipcMain, shell } from 'electron'
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
import fontList from 'font-list'
import { homedir } from 'os'
import { statSync } from 'fs'
import { join } from 'path'
import { pathToFileURL } from 'url'
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
import { t } from '../shared/i18n'
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
@@ -33,6 +36,66 @@ import { createPty, killPty, resizePty, writePty, openSession, configureSessionR
import { respondZmodem } from './zmodem'
import type { ZmodemResponse } from '../shared/ipc'
/** The renderer document this app loads (dev builds load it from vite instead). */
const RENDERER_FILE = join(__dirname, '../renderer/index.html')
/**
* True only for a document the app itself loaded: the bundled renderer file, or
* in dev anything served by the vite dev server. Used both to refuse a
* navigation away from the app page and to refuse IPC from a frame that is not
* it — a window that navigated elsewhere would still hold this preload bridge,
* which is the whole main-process API (`createPty` included).
*/
export function isTrustedRendererUrl(raw: string): boolean {
const devUrl = process.env['ELECTRON_RENDERER_URL']
try {
const target = new URL(raw)
if (devUrl) return target.origin === new URL(devUrl).origin
return target.protocol === 'file:' && target.pathname === pathToFileURL(RENDERER_FILE).pathname
} catch {
return false
}
}
type InvokeListener = (event: IpcMainInvokeEvent, ...args: any[]) => unknown
type EventListener = (event: IpcMainEvent, ...args: any[]) => void
let senderGuardInstalled = false
/**
* Channels are registered from four modules (this one, settingsStore,
* sessionState, updater) and Electron exposes no global IPC hook, so the sender
* check is installed once here — the single entry point all of them are
* registered through — rather than repeated at every registration site.
*
* Handlers never saw the sender before: any frame that managed to navigate
* could drive the main process. Refused invokes reject the renderer's promise;
* refused sends are dropped.
*/
function installSenderGuard(): void {
if (senderGuardInstalled) return
senderGuardInstalled = true
const rawHandle = ipcMain.handle.bind(ipcMain) as (
channel: string,
listener: InvokeListener
) => void
const rawOn = ipcMain.on.bind(ipcMain) as (channel: string, listener: EventListener) => void
ipcMain.handle = ((channel: string, listener: InvokeListener) =>
rawHandle(channel, (event, ...args) => {
if (!isTrustedRendererUrl(event.senderFrame?.url ?? '')) {
throw new Error(`[ipc] refused "${channel}" from an untrusted frame`)
}
return listener(event, ...args)
})) as typeof ipcMain.handle
ipcMain.on = ((channel: string, listener: EventListener) =>
rawOn(channel, (event, ...args) => {
if (!isTrustedRendererUrl(event.senderFrame?.url ?? '')) return
listener(event, ...args)
})) as typeof ipcMain.on
}
let commandsStore: CommandsStore | undefined
/** M5: inject a custom command store (tests) or default to userData-backed. */
@@ -48,6 +111,7 @@ export function getCommandsStore(): CommandsStore {
}
export function registerIpc(): void {
installSenderGuard()
const connectionsStore = new ConnectionsStore(defaultConnectionsPath(app.getPath('userData')))
const knownHostsStore = new KnownHostsStore(defaultKnownHostsPath(app.getPath('userData')))
const cmds = getCommandsStore()
@@ -82,8 +146,8 @@ export function registerIpc(): void {
(): 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.PTY_CREATE, (event, opts?: PtyCreateOptions) => createPty(opts, event.sender.id))
ipcMain.handle(Ipc.SESSION_OPEN, (event, opts: SessionOpenOptions) => openSession(opts, event.sender.id))
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) =>
+3 -4
View File
@@ -5,8 +5,8 @@
*/
import { createHash, randomUUID } from 'crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname } from 'path'
import { readFileSync } from 'fs'
import { writeJson } from './store'
export interface KnownHostEntry {
/** uuid; addedAt is split out so fingerprint clash updates can be precise */
@@ -63,8 +63,7 @@ export class KnownHostsStore {
}
private save(shape: KnownHostsStoreShape): void {
mkdirSync(dirname(this.filePath), { recursive: true })
writeFileSync(this.filePath, JSON.stringify(shape, null, 2), 'utf8')
writeJson(this.filePath, shape)
}
/**
+19 -2
View File
@@ -1,7 +1,13 @@
import { app } from 'electron'
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
import { mkdirSync, readFileSync, readdirSync, rmSync } from 'fs'
import { join } from 'path'
import type { LayoutMeta } from '../shared/ipc'
import { writeJson } from './store'
/** Layout ids reach this module straight from the renderer and are joined into
* a path, so only a plain id is accepted — `..`, separators and drive prefixes
* would otherwise escape `userData/layouts`. */
const LAYOUT_ID = /^[A-Za-z0-9._-]{1,64}$/
const layoutsDir = (): string => {
const dir = join(app.getPath('userData'), 'layouts')
@@ -9,6 +15,10 @@ const layoutsDir = (): string => {
return dir
}
function isValidLayoutId(id: unknown): id is string {
return typeof id === 'string' && LAYOUT_ID.test(id)
}
function layoutPath(id: string): string {
return join(layoutsDir(), `${id}.json`)
}
@@ -39,6 +49,7 @@ export function listLayouts(): LayoutMeta[] {
}
export function getLayout(id: string): string | null {
if (!isValidLayoutId(id)) return null
try {
const file = JSON.parse(readFileSync(layoutPath(id), 'utf8')) as Partial<LayoutFile>
return typeof file.json === 'string' ? file.json : null
@@ -48,11 +59,17 @@ export function getLayout(id: string): string | null {
}
export function saveLayout(meta: LayoutMeta, json: string): void {
if (!isValidLayoutId(meta.id)) {
throw new Error(`[layouts] invalid layout id: ${String(meta.id)}`)
}
const file: LayoutFile = { id: meta.id, name: meta.name, createdAt: meta.createdAt, json }
writeFileSync(layoutPath(meta.id), JSON.stringify(file, null, 2), 'utf8')
writeJson(layoutPath(meta.id), file)
}
export function deleteLayout(id: string): void {
if (!isValidLayoutId(id)) {
throw new Error(`[layouts] invalid layout id: ${String(id)}`)
}
try {
rmSync(layoutPath(id))
} catch (err) {
+12 -2
View File
@@ -65,7 +65,7 @@ export class LogSanitizer {
this.cr = true
} else if (c === '\n') {
out += this.emitLine()
} else if (c === '\t' || c >= ' ' || c === '\x7f') {
} else if (c === '\t' || c >= ' ') {
// printable + tab; DEL and C0 controls (bell etc.) are dropped
if (this.alt) this.altDirty = true
else this.line += c
@@ -97,6 +97,10 @@ export class LogSanitizer {
this.mode = 'text'
} else if (this.seq.length < 1024) {
this.seq += c
} else {
// Runaway sequence past the cap: resync as plain text instead of
// swallowing all following output while stuck in 'csi'.
this.mode = 'text'
}
break
case 'osc':
@@ -124,10 +128,16 @@ export class LogSanitizer {
this.cr = false
// A trailing \r at stop: treat as line ending rather than overwrite.
out += this.emitLine()
} else if (this.line && !this.alt) {
} else if (this.line) {
if (this.alt) {
// Stopped mid-TUI: route through emitLine so the suppressed span gets
// its [TUI output omitted] marker via altDirty below.
out += this.emitLine()
} else {
out += this.line
this.line = ''
}
}
if (this.altDirty) {
this.altDirty = false
out += MARKER()
+55 -12
View File
@@ -2,6 +2,7 @@ import { t } from '../shared/i18n'
import { spawn, type IPty } from '@lydell/node-pty'
import { randomUUID } from 'crypto'
import { homedir } from 'os'
import { StringDecoder } from 'string_decoder'
import { Ipc, type PtyCreateOptions, type PtyCreateResult } from '../shared/ipc'
import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../shared/connections'
import { broadcast } from './broadcast'
@@ -55,7 +56,7 @@ function safeStopLog(id: string): void {
* 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 }
type Session = { kind: 'local'; pty: IPty; owner?: number } | { kind: 'ssh'; ssh: SshSessionHandle; owner?: number }
/** The live ssh2 client behind an ssh session, or undefined. */
export function getSshClient(id: string): SshSessionHandle['client'] | undefined {
@@ -73,11 +74,28 @@ const sessions = new Map<string, Session>()
const REPLAY_CAP = 64 * 1024
const replayBuffers = new Map<string, string>()
/**
* Per-session UTF-8 decoder for the ssh data plane. ssh2 hands out raw TCP
* chunks, so a multi-byte character split across a chunk boundary must be
* held over by the decoder instead of decoding each chunk alone (which turns
* the split char into U+FFFD — systematic for CJK output).
*/
const sshDecoders = new Map<string, StringDecoder>()
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
const combined = prev + data
let next = combined.length > REPLAY_CAP ? combined.slice(combined.length - REPLAY_CAP) : combined
if (next !== combined) {
// The cut may have landed inside an escape sequence or a surrogate pair.
// Resync at the next ESC (which starts a complete sequence) and drop a
// leading lone low surrogate so xterm neither swallows later output nor
// renders a replacement char.
const esc = next.indexOf('\x1b')
if (esc > 0 && esc < 64) next = next.slice(esc)
const code = next.charCodeAt(0)
if (code >= 0xdc00 && code <= 0xdfff) next = next.slice(1)
}
replayBuffers.set(id, next)
}
@@ -148,7 +166,7 @@ function defaultShell(): string {
}
}
export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
export function createPty(opts: PtyCreateOptions = {}, owner?: number): PtyCreateResult {
const id = randomUUID()
const shell = opts.shell ?? defaultShell()
// A remembered directory can be gone by the next launch (renamed, unmounted,
@@ -185,7 +203,7 @@ export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
}
const pty = spawn(shell, args, { name: 'xterm-256color', cols: 80, rows: 24, cwd, env })
sessions.set(id, { kind: 'local', pty })
sessions.set(id, { kind: 'local', pty, owner })
replayBuffers.set(id, '')
pty.onData((data) => {
@@ -218,9 +236,9 @@ export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
* 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 }> {
export async function openSession(opts: SessionOpenOptions, owner?: number): Promise<{ id: string }> {
if (opts.kind === 'local') {
return { id: createPty().id }
return { id: createPty(undefined, owner).id }
}
const deps = runtimeDeps
@@ -248,7 +266,8 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
broadcast: deps.broadcast,
promptHostKey: deps.promptHostKey
})
sessions.set(handle.id, { kind: 'ssh', ssh: handle })
sessions.set(handle.id, { kind: 'ssh', ssh: handle, owner })
sshDecoders.set(handle.id, new StringDecoder('utf8'))
// 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
@@ -258,7 +277,9 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
// writePty drops user keystrokes while isZmodemActive is true).
attachZmodem(handle.id, {
broadcast: (channel, ...args) => deps.broadcast(channel, ...args),
toTerminal: (id, text) => {
toTerminal: (id, data) => {
const text = sshDecoders.get(id)?.write(data) ?? data.toString('utf8')
if (!text) return // the chunk was entirely a partial multi-byte char
appendReplay(id, text)
safeLog(id, text)
deps.broadcast(Ipc.PTY_DATA, { id, data: text })
@@ -282,6 +303,11 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
feedZmodem(handle.id, data)
})
handle.stream.on('exit', (code: number | undefined) => {
// ssh2 reports the remote command's real exit status here, before 'close'.
if (typeof code === 'number') handle.exitCode = code
})
handle.stream.on('close', () => {
try {
stopPolling(handle.id)
@@ -290,7 +316,8 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
safeStopLog(handle.id)
sessions.delete(handle.id)
replayBuffers.delete(handle.id)
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 })
sshDecoders.delete(handle.id)
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: handle.exitCode })
} catch {
// never crash the event loop
}
@@ -325,12 +352,13 @@ export function resizePty(id: string, cols: number, rows: number): void {
}
}
export function killPty(id: string): void {
function killSession(id: string): void {
stopPolling(id)
closeSftp(id)
detachZmodem(id)
safeStopLog(id)
replayBuffers.delete(id)
sshDecoders.delete(id)
const session = sessions.get(id)
if (!session) return
if (session.kind === 'ssh') {
@@ -354,6 +382,20 @@ export function killPty(id: string): void {
sessions.delete(id)
}
export function killPty(id: string): void {
killSession(id)
}
/** Kill every session owned by a renderer that crashed or was destroyed
* without running its own cleanup — normal close/reload kills its own
* sessions via beforeunload before we ever get here. */
export function killPtysByOwner(owner: number): void {
for (const id of [...sessions.keys()]) {
if (sessions.get(id)?.owner !== owner) continue
killSession(id)
}
}
export function killAllPtys(): void {
for (const id of sessions.keys()) {
stopPolling(id)
@@ -382,4 +424,5 @@ export function killAllPtys(): void {
}
}
sessions.clear()
sshDecoders.clear()
}
+75 -17
View File
@@ -10,7 +10,7 @@ import {
type SystemSettings,
type TerminalSettings
} from '../shared/settings'
import type { TerminalTheme } from '../shared/theme'
import { DEFAULT_DARK, type TerminalTheme, type ThemeColors } from '../shared/theme'
import { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n'
import { broadcast } from './broadcast'
import { applyGlobalShortcut } from './globalShortcuts'
@@ -18,16 +18,12 @@ import { applyWindowChrome } from './windowChrome'
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
const DEFAULT_SYSTEM: SystemSettings = {
launchAtLogin: false,
preventSleep: false,
globalShowHide: '',
// Must match DEFAULT_SETTINGS.system in @shared/settings — an "ask" here made
// a fresh install prompt on close while the docs and UI promised tray.
closeAction: 'tray',
autoCheckUpdate: true,
language: DEFAULT_LANGUAGE
}
/**
* Snapshot of the shared system defaults, derived rather than retyped so the two
* cannot drift (a hand-written copy once shipped closeAction 'ask', which made a
* fresh install prompt on close while the docs and UI promised the tray).
*/
const DEFAULT_SYSTEM: SystemSettings = { ...DEFAULT_SETTINGS.system }
const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[])
@@ -108,6 +104,58 @@ function sanitizeRules(value: unknown, warnings: Warnings): HighlightRule[] {
return rules
}
const THEME_COLOR_KEYS = Object.keys(DEFAULT_DARK.colors) as (keyof ThemeColors)[]
/**
* Validate custom themes, repairing colours instead of dropping the theme.
*
* These reach `getThemeById`, which does a bare `.find()` over the list and
* hands the result to xterm *and* to the window/title-bar colours — an entry
* without an id or without colours used to throw inside `whenReady`, i.e. before
* the tray and updater were wired, leaving a windowless process holding the
* single-instance lock. Every colour missing from the stored entry is filled
* from the built-in dark theme so a theme is always complete.
*/
function sanitizeThemes(value: unknown, warnings: Warnings): TerminalTheme[] {
if (!Array.isArray(value)) return []
const themes: TerminalTheme[] = []
value.forEach((entry, index) => {
if (entry === null || typeof entry !== 'object') {
warnings.push(`customThemes[${index}]: not an object — skipped`)
return
}
const theme = entry as Record<string, unknown>
if (typeof theme.id !== 'string' || theme.id === '' || typeof theme.name !== 'string') {
warnings.push(`customThemes[${index}]: missing id/name — skipped`)
return
}
if (theme.colors === null || typeof theme.colors !== 'object') {
warnings.push(`customThemes[${index}].colors repaired → built-in dark palette`)
}
const candidate =
theme.colors !== null && typeof theme.colors === 'object'
? (theme.colors as Record<string, unknown>)
: null
const colors: ThemeColors = { ...DEFAULT_DARK.colors }
const target = colors as unknown as Record<string, string>
for (const key of THEME_COLOR_KEYS) {
const color = candidate?.[key]
if (typeof color === 'string' && color !== '') target[key] = color
else if (color !== undefined) {
warnings.push(`customThemes[${index}].colors.${key} repaired → built-in default`)
}
}
themes.push({
...theme,
id: theme.id,
name: theme.name,
builtin: theme.builtin === true,
colors
} as TerminalTheme)
})
return themes
}
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
const errors: string[] = []
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
@@ -137,7 +185,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
}
}
let system: unknown = DEFAULT_SYSTEM
let system: unknown = { ...DEFAULT_SYSTEM }
if (raw !== null && typeof raw === 'object') {
const sys = (raw as { system?: unknown }).system
if (sys !== null && typeof sys === 'object') {
@@ -164,7 +212,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
}
}
const themes: TerminalTheme[] = Array.isArray(customThemes) ? (customThemes as TerminalTheme[]) : []
const themes = sanitizeThemes(customThemes, errors)
return {
settings: {
@@ -306,13 +354,23 @@ export function mutateSettings(mutate: (settings: AppSettings) => AppSettings):
return run
}
/** Replace the persisted settings with `next` (serialized). */
export function saveSettings(next: AppSettings): Promise<AppSettings> {
return mutateSettings((current) => ({ ...current, ...next }))
/** Merge a (partial) settings patch into the persisted settings. */
export function saveSettings(next: Partial<AppSettings>): Promise<AppSettings> {
// system/terminal merge one level deep, so a partial patch cannot wipe sibling
// keys. The renderer now sends a minimal patch, so *absent* fields no longer
// win over the stored state; stale-but-sent fields still do, which is why
// main-process writes go through mutateSettings on the freshly read state
// instead of this path.
return mutateSettings((current) => ({
...current,
...next,
terminal: { ...current.terminal, ...next.terminal },
system: { ...current.system, ...next.system }
}))
}
export function registerSettingsIpc(): void {
migrateDefaultsOnce()
ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings())
ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: AppSettings) => saveSettings(next))
ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: Partial<AppSettings>) => saveSettings(next))
}
+40 -7
View File
@@ -41,8 +41,14 @@ export function formatMode(mode: number): string {
[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
const perm = groups.map(([bit, ch]) => ((mode & bit) !== 0 ? ch : '-'))
// Special bits share the x columns: setuid/setgid → s/S, sticky → t/T
// (uppercase when the corresponding execute bit is clear). The renderer's
// permission editor round-trips these, so report them instead of dropping.
if ((mode & 0o4000) !== 0) perm[2] = perm[2] === 'x' ? 's' : 'S'
if ((mode & 0o2000) !== 0) perm[5] = perm[5] === 'x' ? 's' : 'S'
if ((mode & 0o1000) !== 0) perm[8] = perm[8] === 'x' ? 't' : 'T'
return kind + perm.join('')
}
/**
@@ -70,12 +76,23 @@ async function sftpOf(sessionId: string): Promise<SFTPWrapper> {
const sftp = await new Promise<SFTPWrapper>((resolve, reject) => {
client.sftp((err, sftp_) => (err != null ? reject(err) : resolve(sftp_)))
})
// ssh2.d.ts declares only the used surface; the wrapper is an EventEmitter
;(sftp as SFTPWrapper & { on(event: 'error', cb: (err: Error) => void): void }).on('error', (err: Error) => {
console.error(`[sftp] channel error sessionId=${sessionId}: ${err.message}`)
if (sftpCache.get(sessionId) === sftp) evictSftp(sessionId)
})
sftpCache.set(sessionId, sftp)
return sftp
}
function evictSftp(sessionId: string): void {
const sftp = sftpCache.get(sessionId)
sftpCache.delete(sessionId)
try {
sftp?.end?.()
} catch {
// best effort — the channel may already be dead
}
}
/**
@@ -87,7 +104,7 @@ function evictSftp(sessionId: string): void {
function isTransportError(err: unknown): boolean {
if (err instanceof SessionGoneError) return true
const msg = (err as Error | undefined)?.message ?? ''
return /not connected|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end/i.test(msg)
return /not connected|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end|no response/i.test(msg)
}
/** Run `fn` with the session's cached sftp; a dead cached channel is evicted and retried once. */
@@ -193,7 +210,7 @@ export function deleteRemote(sessionId: string, paths: string[]): Promise<void>
failures.push(`${path}: ${(err as Error).message}`)
}
}
if (paths.length > 0 && failures.length === paths.length) {
if (failures.length > 0) {
throw new Error(t('main.sftp.deleteFailed', { detail: failures.join('; ') }))
}
})
@@ -235,13 +252,25 @@ function execQuiet(sessionId: string, cmd: string): Promise<void> {
reject(err)
return
}
let stdout = ''
let stderr = ''
stream.on('data', () => undefined)
const timer = setTimeout(() => {
stream.close()
reject(new Error(t('main.sftp.commandTimeout')))
}, 10_000)
stream.on('data', (d: Buffer) => {
stdout += d.toString('utf8')
})
stream.stderr?.on('data', (d: Buffer) => {
stderr += d.toString('utf8')
})
stream.on('error', (e: Error) => {
clearTimeout(timer)
reject(e)
})
stream.on('close', (code: number) => {
if (code) reject(new Error(stderr || t('main.sftp.commandExitCode', { code })))
clearTimeout(timer)
if (code) reject(new Error(stderr || stdout || t('main.sftp.commandExitCode', { code })))
else resolve()
})
})
@@ -299,7 +328,6 @@ export function uploadRemote(
try {
let pos = 0
let lastEmit = 0
const buf = Buffer.alloc(CHUNK)
for (;;) {
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
if (firstError) throw firstError
@@ -307,6 +335,9 @@ export function uploadRemote(
await Promise.race(inflight)
if (firstError) throw firstError
}
// per-iteration buffer: pipelined writes outlive the loop, and
// ssh2 re-reads the overflow tail after the ACK arrives
const buf = Buffer.allocUnsafe(CHUNK)
const { bytesRead } = await localHandle.read(buf, 0, CHUNK, pos)
if (bytesRead === 0) break
const offset = pos
@@ -408,6 +439,8 @@ export function downloadRemote(
} catch (err) {
await fsp.rm(local, { force: true })
throw err
} finally {
await pVoid(cb => sftp.close(handle, cb)).catch(() => undefined)
}
if (transfer.cancelled) {
await fsp.rm(local, { force: true })
+19 -3
View File
@@ -28,6 +28,11 @@ export interface SshSessionHandle {
client: Client
/** open interactive shell channel (ClientChannel, a Duplex) */
stream: ClientChannel
/**
* Exit code reported for the session: the channel's 'exit' event when the
* remote sends one, 1 when the client errors out mid-session, else 0.
*/
exitCode: number
}
export interface SshServiceDeps {
@@ -80,6 +85,7 @@ export async function connectSsh(
let settled = false
let connectTimer: NodeJS.Timeout | undefined
let verifierErr: string | undefined
let sessionHandle: SshSessionHandle | undefined
let resolvePromise!: (handle: SshSessionHandle) => void
let rejectPromise!: (err: Error) => void
@@ -161,6 +167,9 @@ export async function connectSsh(
return
}
// Session already established: surface as a session exit and clean up.
// Record the failure code on the handle so the stream's later 'close'
// (pty.ts) reports the same exit code instead of a bogus 0.
if (sessionHandle) sessionHandle.exitCode = 1
try {
deps.broadcast(Ipc.PTY_EXIT, { id: sessionId, exitCode: 1 })
} catch {
@@ -201,7 +210,8 @@ export async function connectSsh(
} catch {
// store write failure must not break the session
}
resolvePromise({ id: sessionId, client: handshake, stream: shell })
sessionHandle = { id: sessionId, client: handshake, stream: shell, exitCode: 0 }
resolvePromise(sessionHandle)
}
)
})
@@ -215,8 +225,14 @@ export async function connectSsh(
hostVerifier
}
// Password auth
const password = secretOverride?.password ?? deps.connections.getSecret(conn, 'password')
// Password auth. A stored password is offered only when the bookmark is
// configured for password auth: connectionsStore keeps password_enc when a
// bookmark is switched to key/agent auth, and silently falling back to it
// would authenticate a weaker method than the user chose. An explicitly
// typed connect-time password (secretOverride) is always honoured.
const password =
secretOverride?.password ??
(conn.auth === 'password' ? deps.connections.getSecret(conn, 'password') : undefined)
if (password !== undefined) cfg.password = password
// Private key auth (keyPath takes precedence over stored keyContent)
+24 -1
View File
@@ -35,12 +35,15 @@ export function readJson<T = unknown>(file: string): T | null {
}
}
/** Backing buffer for Atomics.wait below: the retry sleep must be blocking
* because every caller writes synchronously. */
const RENAME_RETRY_WAIT = new Int32Array(new SharedArrayBuffer(4))
export function writeJson(file: string, value: unknown): void {
mkdirSync(dirname(file), { recursive: true })
const tmp = tmpPath(file)
try {
writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
renameSync(tmp, file)
} catch (err) {
// Clean up the temp file so a failed write does not leave litter behind.
try {
@@ -50,6 +53,26 @@ export function writeJson(file: string, value: unknown): void {
}
throw err
}
// On Windows the replacing rename fails transiently with EPERM/EBUSY while
// an indexer or AV scanner holds the destination open; a short retry keeps
// an otherwise good write from being thrown away.
for (let attempt = 0; ; attempt++) {
try {
renameSync(tmp, file)
return
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (attempt >= 5 || (code !== 'EPERM' && code !== 'EBUSY')) {
try {
unlinkSync(tmp)
} catch {
/* nothing to clean */
}
throw err
}
Atomics.wait(RENAME_RETRY_WAIT, 0, 0, 5 * (attempt + 1))
}
}
}
/** Convenience for stores whose file lives under a directory. */
+31 -6
View File
@@ -129,10 +129,26 @@ function pollOnce(id: string, state: PollState): void {
return
}
let out = ''
let done = false
// A wedged remote command (e.g. df on a hung NFS mount) never closes the
// stream; without this watchdog the poll loop freezes silently forever.
const watchdog = setTimeout(() => {
if (done) return
done = true
try {
stream.close()
} catch {
// best effort
}
handleError(id, state, t('main.sysinfo.pollTimeout'))
}, state.intervalMs * 2)
stream.on('data', (d: Buffer) => {
out += d.toString('utf8')
})
const onEnd = (): void => {
if (done) return
done = true
clearTimeout(watchdog)
if (state.stopped) return
try {
stream.close()
@@ -142,7 +158,18 @@ function pollOnce(id: string, state: PollState): void {
handleOutput(id, state, out)
}
stream.on('close', onEnd)
stream.on('error', () => onEnd())
stream.on('error', (streamErr: Error) => {
if (done) return
done = true
clearTimeout(watchdog)
if (state.stopped) return
try {
stream.close()
} catch {
// best effort
}
handleError(id, state, streamErr?.message || t('main.sysinfo.execFailed'))
})
})
} catch (err) {
handleError(id, state, (err as Error).message)
@@ -304,8 +331,8 @@ function parseProc(blob: string): { cpu: SysinfoSample['cpu']; cpuIdle: number;
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
// guest/guest_nice (f[8]/f[9]) are already included in user/nice per
// proc(5) — summing them in would double-count and understate CPU%.
idle = idleTicks + iowait
total =
user +
@@ -314,9 +341,7 @@ function parseProc(blob: string): { cpu: SysinfoSample['cpu']; cpuIdle: number;
idle +
irq +
softirq +
steal +
guest +
guestNice
steal
} else {
cores += 1
}
+8 -2
View File
@@ -67,10 +67,12 @@ function wireEvents(): void {
/** Check the active feed; on failure switch Gitea → GitHub and retry once. */
async function checkWithFallback(): Promise<void> {
// The feed choice is per attempt: a transient Gitea failure must not pin
// every later check to GitHub (and its system proxy) for the whole session.
useFeed('gitea')
try {
await autoUpdater.checkForUpdates()
} catch (err) {
if (activeFeed !== 'gitea') throw err
console.warn('[updater] gitea feed failed, falling back to github:', err)
const giteaErr = err instanceof Error ? err.message : String(err)
useFeed('github')
@@ -164,11 +166,15 @@ export function registerUpdateIpc(): void {
ipcMain.handle(Ipc.UPDATE_CHECK, handleCheck)
ipcMain.handle(Ipc.UPDATE_DOWNLOAD, handleDownload)
ipcMain.handle(Ipc.UPDATE_INSTALL, () => {
if (!app.isPackaged) return
// The close interceptor (tray flow) would swallow this quit — mark first.
markQuitting()
autoUpdater.quitAndInstall()
// (isSilent, isForceRunAfter): relaunch the installed build, otherwise the
// app would just disappear on "restart to update".
autoUpdater.quitAndInstall(false, true)
})
ipcMain.handle(Ipc.UPDATE_CHANGELOG, fetchChangelog)
ipcMain.handle(Ipc.UPDATE_STATE_GET, () => state)
}
export function configureAutoUpdater(): void {
+26 -7
View File
@@ -37,10 +37,12 @@ export interface ZmodemDeps {
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.
* (utf8 decode + replay + session log + PTY_DATA). pty.ts injects this and
* owns the decoding (per-session StringDecoder — a multi-byte char may be
* split across TCP chunks); the engine only calls it when no transfer is
* active.
*/
toTerminal(sessionId: string, text: string): void
toTerminal(sessionId: string, data: Buffer): void
/** Write bytes out to the ssh stream (zmodem frames + CAN abort sequence). */
writeStream(sessionId: string, data: Buffer): void
}
@@ -129,7 +131,17 @@ function finalize(
}
engine.receiveStream = null
}
engine.session = null // the zsession already ended; drop the reference
if (!ok && engine.session) {
// Failure paths (stall watchdog, corrupt frame) must stop the peer too:
// without an abort the remote rz/sz keeps transmitting frames that then
// leak into the terminal and the session log.
try {
engine.session.abort()
} catch {
// session already ended
}
}
engine.session = null
engine.detection = null
if (!engine.progressEmitted) {
@@ -300,7 +312,7 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
// 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'))
deps.toTerminal(sessionId, Buffer.from(octets))
} catch {
// never crash the event loop
}
@@ -321,6 +333,8 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
engine.dir = null
engine.session = null
engine.receiveStream = null
engine.bytes = 0
engine.totalBytes = 0
engine.transferId = `zm-${sessionId}`
emitProgress(engine, { file: '', bytes: 0, totalBytes: 0 })
try {
@@ -328,7 +342,6 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
} catch {
// never crash the event loop
}
touchActivity(engine)
engine.offerTimer = setTimeout(() => {
if (engine.detection && !engine.confirmed) {
abortWithoutSession(engine, t('main.zmodem.offerTimedOut'))
@@ -373,6 +386,12 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
* the CAN abort sequence directly so the remote program exits.
*/
function abortWithoutSession(engine: Engine, message: string): void {
try {
engine.detection?.deny()
} catch {
// already retracted or confirmed
}
engine.detection = null
try {
engine.deps.writeStream(engine.id, ABORT_BUFFER)
} catch {
@@ -425,7 +444,7 @@ export function isZmodemActive(sessionId: string): boolean {
*/
export function respondZmodem(resp: ZmodemResponse): void {
const engine = current(resp.id)
if (!engine || engine.confirmed) return
if (!engine || !engine.active || engine.confirmed) return
if (resp.cancelled) {
abortWithoutSession(engine, t('main.zmodem.cancelled'))