diff --git a/src/main/commands.ts b/src/main/commands.ts index 13f3f49..e03221a 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -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) diff --git a/src/main/connectionsStore.ts b/src/main/connectionsStore.ts index c9281a3..e77a407 100644 --- a/src/main/connectionsStore.ts +++ b/src/main/connectionsStore.ts @@ -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[] { diff --git a/src/main/cwd.ts b/src/main/cwd.ts index 1e3575b..0df34cd 100644 --- a/src/main/cwd.ts +++ b/src/main/cwd.ts @@ -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 diff --git a/src/main/index.ts b/src/main/index.ts index bc6c05d..92fb73a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index cc09f92..7414390 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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) => diff --git a/src/main/knownHosts.ts b/src/main/knownHosts.ts index 1852eb2..7ced5c9 100644 --- a/src/main/knownHosts.ts +++ b/src/main/knownHosts.ts @@ -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) } /** diff --git a/src/main/layouts.ts b/src/main/layouts.ts index 73570fb..f22b524 100644 --- a/src/main/layouts.ts +++ b/src/main/layouts.ts @@ -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 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) { diff --git a/src/main/logSanitizer.ts b/src/main/logSanitizer.ts index 29c54b6..f9bcb5d 100644 --- a/src/main/logSanitizer.ts +++ b/src/main/logSanitizer.ts @@ -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,9 +128,15 @@ 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) { - out += this.line - this.line = '' + } 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 diff --git a/src/main/pty.ts b/src/main/pty.ts index 7bf3caa..98218be 100644 --- a/src/main/pty.ts +++ b/src/main/pty.ts @@ -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() const REPLAY_CAP = 64 * 1024 const replayBuffers = new Map() +/** + * 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() + 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() } \ No newline at end of file diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index f3b80ad..0ff44c1 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -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 + 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) + : null + const colors: ThemeColors = { ...DEFAULT_DARK.colors } + const target = colors as unknown as Record + 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 { - return mutateSettings((current) => ({ ...current, ...next })) +/** Merge a (partial) settings patch into the persisted settings. */ +export function saveSettings(next: Partial): Promise { + // 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) => saveSettings(next)) } \ No newline at end of file diff --git a/src/main/sftp.ts b/src/main/sftp.ts index 52e821e..071bcde 100644 --- a/src/main/sftp.ts +++ b/src/main/sftp.ts @@ -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 { const sftp = await new Promise((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 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 { 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 }) diff --git a/src/main/ssh.ts b/src/main/ssh.ts index 0944600..08b4129 100644 --- a/src/main/ssh.ts +++ b/src/main/ssh.ts @@ -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) diff --git a/src/main/store.ts b/src/main/store.ts index be369af..ba65a6d 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -35,12 +35,15 @@ export function readJson(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. */ diff --git a/src/main/sysinfo.ts b/src/main/sysinfo.ts index d9f58df..7195586 100644 --- a/src/main/sysinfo.ts +++ b/src/main/sysinfo.ts @@ -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 } diff --git a/src/main/updater.ts b/src/main/updater.ts index 75bcf2a..7b8c0cd 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -67,10 +67,12 @@ function wireEvents(): void { /** Check the active feed; on failure switch Gitea → GitHub and retry once. */ async function checkWithFallback(): Promise { + // 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 { diff --git a/src/main/zmodem.ts b/src/main/zmodem.ts index 5165a4d..6343937 100644 --- a/src/main/zmodem.ts +++ b/src/main/zmodem.ts @@ -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'))