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:
1 parent
4c6a29ef28
commit
e04f4f0ac1
16 files changed
+452
-98
No files matched your search
+10
-6
@@ -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)
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+55
-12
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'))
|
||||
|
||||
Reference in new issue
Block a user