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
+450
-96
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 { DEFAULT_SETTINGS } from '../shared/settings'
|
||||||
import { loadSettings } from './settingsStore'
|
import { loadSettings } from './settingsStore'
|
||||||
import { LogSanitizer } from './logSanitizer'
|
import { LogSanitizer } from './logSanitizer'
|
||||||
|
import { writeJson } from './store'
|
||||||
|
|
||||||
/** Upper bound on recorded history entries when no limit is configured. */
|
/** Upper bound on recorded history entries when no limit is configured. */
|
||||||
const HISTORY_CAP = 500
|
const HISTORY_CAP = 500
|
||||||
@@ -113,8 +114,7 @@ export class CommandsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private saveCommands(data: CommandsFile): void {
|
private saveCommands(data: CommandsFile): void {
|
||||||
mkdirSync(join(this.file, '..'), { recursive: true })
|
writeJson(this.file, data)
|
||||||
writeFileSync(this.file, JSON.stringify(data, null, 2), 'utf8')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,7 +132,9 @@ export class CommandsStore {
|
|||||||
* existing history is left untouched rather than cleared.
|
* existing history is left untouched rather than cleared.
|
||||||
*/
|
*/
|
||||||
recordCommand(cmd: string): void {
|
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
|
if (!trimmed) return
|
||||||
const { historyEnabled, historyLimit } = loadHistoryPrefs()
|
const { historyEnabled, historyLimit } = loadHistoryPrefs()
|
||||||
if (!historyEnabled) return
|
if (!historyEnabled) return
|
||||||
@@ -246,11 +248,10 @@ export class CommandsStore {
|
|||||||
/** Write the full log index so listSessionLogs survives restart. */
|
/** Write the full log index so listSessionLogs survives restart. */
|
||||||
private persistIndex(): void {
|
private persistIndex(): void {
|
||||||
try {
|
try {
|
||||||
mkdirSync(this.logsDir, { recursive: true })
|
|
||||||
const all = Array.from(this.metasByFile.values()).sort(
|
const all = Array.from(this.metasByFile.values()).sort(
|
||||||
(a, b) => b.startedAt - a.startedAt
|
(a, b) => b.startedAt - a.startedAt
|
||||||
)
|
)
|
||||||
writeFileSync(this.indexFile, JSON.stringify(all, null, 2), 'utf8')
|
writeJson(this.indexFile, all)
|
||||||
} catch {
|
} catch {
|
||||||
// best effort: never break the session over an index write failure
|
// best effort: never break the session over an index write failure
|
||||||
}
|
}
|
||||||
@@ -269,7 +270,10 @@ export class CommandsStore {
|
|||||||
if (prev) this.finishLog(prev)
|
if (prev) this.finishLog(prev)
|
||||||
|
|
||||||
mkdirSync(this.logsDir, { recursive: true })
|
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)
|
const file = join(this.logsDir, fileName)
|
||||||
// Create the file eagerly so the first async append cannot race a missing fd.
|
// Create the file eagerly so the first async append cannot race a missing fd.
|
||||||
describeFile(file)
|
describeFile(file)
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
|
|
||||||
import { safeStorage } from 'electron'
|
import { safeStorage } from 'electron'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { dirname } from 'path'
|
|
||||||
import type { SshAuthMethod, SshConnection, SshConnectionInput } from '../shared/connections'
|
import type { SshAuthMethod, SshConnection, SshConnectionInput } from '../shared/connections'
|
||||||
|
import { writeJson } from './store'
|
||||||
|
|
||||||
const ENC_SUFFIX = '_enc'
|
const ENC_SUFFIX = '_enc'
|
||||||
const PLAIN_PREFIX = 'plain:'
|
const PLAIN_PREFIX = 'plain:'
|
||||||
@@ -128,8 +128,7 @@ export class ConnectionsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private save(list: StoredConnection[]): void {
|
private save(list: StoredConnection[]): void {
|
||||||
mkdirSync(dirname(this.filePath), { recursive: true })
|
writeJson(this.filePath, list)
|
||||||
writeFileSync(this.filePath, JSON.stringify(list, null, 2), 'utf8')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
listConnections(): SshConnection[] {
|
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.
|
// `cd -` means the shell's previous directory, which we cannot know here.
|
||||||
if (arg === '-') return null
|
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
|
// Drive-relative `cd d:` (cmd/PowerShell): the target is the drive's
|
||||||
// current directory, which the shell never tells us — path.isAbsolute('d:')
|
// 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
|
// 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 { app, BrowserWindow, globalShortcut, nativeImage, shell } from 'electron'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { registerIpc } from './ipc'
|
import { isTrustedRendererUrl, registerIpc } from './ipc'
|
||||||
import { killAllPtys } from './pty'
|
import { killAllPtys, killPtysByOwner } from './pty'
|
||||||
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
|
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
|
||||||
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
|
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
|
||||||
import { configureAutoUpdater, registerUpdateIpc } from './updater'
|
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,
|
// Dev runs get their own userData directory (settings, session snapshot,
|
||||||
// command history, logs) *and* their own single-instance lock — both are keyed
|
// 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
|
// 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
|
// Single instance: a second launch just surfaces the existing window (pulls
|
||||||
// it out of the tray if hidden there) instead of starting another process.
|
// 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()
|
const gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||||
if (!gotSingleInstanceLock) {
|
if (!gotSingleInstanceLock) {
|
||||||
app.quit()
|
app.quit()
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', () => showOrCreate())
|
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 {
|
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) => {
|
win.webContents.setWindowOpenHandler((details) => {
|
||||||
shell.openExternal(details.url)
|
openExternal(details.url)
|
||||||
return { action: 'deny' }
|
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']
|
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||||
if (devUrl) {
|
if (devUrl) {
|
||||||
win.loadURL(devUrl)
|
win.loadURL(devUrl)
|
||||||
@@ -115,24 +167,6 @@ process.on('uncaughtException', (err) => {
|
|||||||
console.error('[main] 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', () => {
|
app.on('before-quit', () => {
|
||||||
// Let window 'close' events pass through so teardown completes.
|
// Let window 'close' events pass through so teardown completes.
|
||||||
markQuitting()
|
markQuitting()
|
||||||
|
|||||||
+66
-2
@@ -1,7 +1,10 @@
|
|||||||
import { app, ipcMain, shell } from 'electron'
|
import { app, ipcMain, shell } from 'electron'
|
||||||
|
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
|
||||||
import fontList from 'font-list'
|
import fontList from 'font-list'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
import { statSync } from 'fs'
|
import { statSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { pathToFileURL } from 'url'
|
||||||
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
|
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
|
||||||
import { t } from '../shared/i18n'
|
import { t } from '../shared/i18n'
|
||||||
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
|
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 { respondZmodem } from './zmodem'
|
||||||
import type { ZmodemResponse } from '../shared/ipc'
|
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
|
let commandsStore: CommandsStore | undefined
|
||||||
|
|
||||||
/** M5: inject a custom command store (tests) or default to userData-backed. */
|
/** M5: inject a custom command store (tests) or default to userData-backed. */
|
||||||
@@ -48,6 +111,7 @@ export function getCommandsStore(): CommandsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerIpc(): void {
|
export function registerIpc(): void {
|
||||||
|
installSenderGuard()
|
||||||
const connectionsStore = new ConnectionsStore(defaultConnectionsPath(app.getPath('userData')))
|
const connectionsStore = new ConnectionsStore(defaultConnectionsPath(app.getPath('userData')))
|
||||||
const knownHostsStore = new KnownHostsStore(defaultKnownHostsPath(app.getPath('userData')))
|
const knownHostsStore = new KnownHostsStore(defaultKnownHostsPath(app.getPath('userData')))
|
||||||
const cmds = getCommandsStore()
|
const cmds = getCommandsStore()
|
||||||
@@ -82,8 +146,8 @@ export function registerIpc(): void {
|
|||||||
(): AppInfo => ({ platform: process.platform, appVersion: app.getVersion(), homeDir: homedir() })
|
(): AppInfo => ({ platform: process.platform, appVersion: app.getVersion(), homeDir: homedir() })
|
||||||
)
|
)
|
||||||
|
|
||||||
ipcMain.handle(Ipc.PTY_CREATE, (_event, opts?: PtyCreateOptions) => createPty(opts))
|
ipcMain.handle(Ipc.PTY_CREATE, (event, opts?: PtyCreateOptions) => createPty(opts, event.sender.id))
|
||||||
ipcMain.handle(Ipc.SESSION_OPEN, (_event, opts: SessionOpenOptions) => openSession(opts))
|
ipcMain.handle(Ipc.SESSION_OPEN, (event, opts: SessionOpenOptions) => openSession(opts, event.sender.id))
|
||||||
ipcMain.handle(Ipc.SESSION_REPLAY, (_event, id: string) => getSessionReplay(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_WRITE, (_event, id: string, data: string) => writePty(id, data))
|
||||||
ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) =>
|
ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) =>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHash, randomUUID } from 'crypto'
|
import { createHash, randomUUID } from 'crypto'
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { dirname } from 'path'
|
import { writeJson } from './store'
|
||||||
|
|
||||||
export interface KnownHostEntry {
|
export interface KnownHostEntry {
|
||||||
/** uuid; addedAt is split out so fingerprint clash updates can be precise */
|
/** uuid; addedAt is split out so fingerprint clash updates can be precise */
|
||||||
@@ -63,8 +63,7 @@ export class KnownHostsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private save(shape: KnownHostsStoreShape): void {
|
private save(shape: KnownHostsStoreShape): void {
|
||||||
mkdirSync(dirname(this.filePath), { recursive: true })
|
writeJson(this.filePath, shape)
|
||||||
writeFileSync(this.filePath, JSON.stringify(shape, null, 2), 'utf8')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+19
-2
@@ -1,7 +1,13 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
|
import { mkdirSync, readFileSync, readdirSync, rmSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import type { LayoutMeta } from '../shared/ipc'
|
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 layoutsDir = (): string => {
|
||||||
const dir = join(app.getPath('userData'), 'layouts')
|
const dir = join(app.getPath('userData'), 'layouts')
|
||||||
@@ -9,6 +15,10 @@ const layoutsDir = (): string => {
|
|||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidLayoutId(id: unknown): id is string {
|
||||||
|
return typeof id === 'string' && LAYOUT_ID.test(id)
|
||||||
|
}
|
||||||
|
|
||||||
function layoutPath(id: string): string {
|
function layoutPath(id: string): string {
|
||||||
return join(layoutsDir(), `${id}.json`)
|
return join(layoutsDir(), `${id}.json`)
|
||||||
}
|
}
|
||||||
@@ -39,6 +49,7 @@ export function listLayouts(): LayoutMeta[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getLayout(id: string): string | null {
|
export function getLayout(id: string): string | null {
|
||||||
|
if (!isValidLayoutId(id)) return null
|
||||||
try {
|
try {
|
||||||
const file = JSON.parse(readFileSync(layoutPath(id), 'utf8')) as Partial<LayoutFile>
|
const file = JSON.parse(readFileSync(layoutPath(id), 'utf8')) as Partial<LayoutFile>
|
||||||
return typeof file.json === 'string' ? file.json : null
|
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 {
|
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 }
|
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 {
|
export function deleteLayout(id: string): void {
|
||||||
|
if (!isValidLayoutId(id)) {
|
||||||
|
throw new Error(`[layouts] invalid layout id: ${String(id)}`)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
rmSync(layoutPath(id))
|
rmSync(layoutPath(id))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export class LogSanitizer {
|
|||||||
this.cr = true
|
this.cr = true
|
||||||
} else if (c === '\n') {
|
} else if (c === '\n') {
|
||||||
out += this.emitLine()
|
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
|
// printable + tab; DEL and C0 controls (bell etc.) are dropped
|
||||||
if (this.alt) this.altDirty = true
|
if (this.alt) this.altDirty = true
|
||||||
else this.line += c
|
else this.line += c
|
||||||
@@ -97,6 +97,10 @@ export class LogSanitizer {
|
|||||||
this.mode = 'text'
|
this.mode = 'text'
|
||||||
} else if (this.seq.length < 1024) {
|
} else if (this.seq.length < 1024) {
|
||||||
this.seq += c
|
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
|
break
|
||||||
case 'osc':
|
case 'osc':
|
||||||
@@ -124,10 +128,16 @@ export class LogSanitizer {
|
|||||||
this.cr = false
|
this.cr = false
|
||||||
// A trailing \r at stop: treat as line ending rather than overwrite.
|
// A trailing \r at stop: treat as line ending rather than overwrite.
|
||||||
out += this.emitLine()
|
out += this.emitLine()
|
||||||
} else if (this.line && !this.alt) {
|
} else if (this.line) {
|
||||||
|
if (this.alt) {
|
||||||
|
// Stopped mid-TUI: route through emitLine so the suppressed span gets
|
||||||
|
// its [TUI output omitted] marker via altDirty below.
|
||||||
|
out += this.emitLine()
|
||||||
|
} else {
|
||||||
out += this.line
|
out += this.line
|
||||||
this.line = ''
|
this.line = ''
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (this.altDirty) {
|
if (this.altDirty) {
|
||||||
this.altDirty = false
|
this.altDirty = false
|
||||||
out += MARKER()
|
out += MARKER()
|
||||||
|
|||||||
+55
-12
@@ -2,6 +2,7 @@ import { t } from '../shared/i18n'
|
|||||||
import { spawn, type IPty } from '@lydell/node-pty'
|
import { spawn, type IPty } from '@lydell/node-pty'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
|
import { StringDecoder } from 'string_decoder'
|
||||||
import { Ipc, type PtyCreateOptions, type PtyCreateResult } from '../shared/ipc'
|
import { Ipc, type PtyCreateOptions, type PtyCreateResult } from '../shared/ipc'
|
||||||
import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../shared/connections'
|
import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../shared/connections'
|
||||||
import { broadcast } from './broadcast'
|
import { broadcast } from './broadcast'
|
||||||
@@ -55,7 +56,7 @@ function safeStopLog(id: string): void {
|
|||||||
* shell; the generic PTY_* (data plane) channels address both. PTY_DATA /
|
* shell; the generic PTY_* (data plane) channels address both. PTY_DATA /
|
||||||
* PTY_EXIT broadcasts are identical for both kinds.
|
* 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. */
|
/** The live ssh2 client behind an ssh session, or undefined. */
|
||||||
export function getSshClient(id: string): SshSessionHandle['client'] | 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 REPLAY_CAP = 64 * 1024
|
||||||
const replayBuffers = new Map<string, string>()
|
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 {
|
function appendReplay(id: string, data: string): void {
|
||||||
const prev = replayBuffers.get(id) ?? ''
|
const prev = replayBuffers.get(id) ?? ''
|
||||||
const next = prev.length + data.length > REPLAY_CAP
|
const combined = prev + data
|
||||||
? (prev + data).slice(prev.length + data.length - REPLAY_CAP)
|
let next = combined.length > REPLAY_CAP ? combined.slice(combined.length - REPLAY_CAP) : combined
|
||||||
: prev + data
|
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)
|
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 id = randomUUID()
|
||||||
const shell = opts.shell ?? defaultShell()
|
const shell = opts.shell ?? defaultShell()
|
||||||
// A remembered directory can be gone by the next launch (renamed, unmounted,
|
// 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 })
|
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, '')
|
replayBuffers.set(id, '')
|
||||||
|
|
||||||
pty.onData((data) => {
|
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
|
* Open a session. `local` reuses createPty; `ssh` goes through the ssh service
|
||||||
* and resolves only once the shell stream is ready to stream data.
|
* 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') {
|
if (opts.kind === 'local') {
|
||||||
return { id: createPty().id }
|
return { id: createPty(undefined, owner).id }
|
||||||
}
|
}
|
||||||
|
|
||||||
const deps = runtimeDeps
|
const deps = runtimeDeps
|
||||||
@@ -248,7 +266,8 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
|
|||||||
broadcast: deps.broadcast,
|
broadcast: deps.broadcast,
|
||||||
promptHostKey: deps.promptHostKey
|
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
|
// 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
|
// 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).
|
// writePty drops user keystrokes while isZmodemActive is true).
|
||||||
attachZmodem(handle.id, {
|
attachZmodem(handle.id, {
|
||||||
broadcast: (channel, ...args) => deps.broadcast(channel, ...args),
|
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)
|
appendReplay(id, text)
|
||||||
safeLog(id, text)
|
safeLog(id, text)
|
||||||
deps.broadcast(Ipc.PTY_DATA, { id, data: 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)
|
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', () => {
|
handle.stream.on('close', () => {
|
||||||
try {
|
try {
|
||||||
stopPolling(handle.id)
|
stopPolling(handle.id)
|
||||||
@@ -290,7 +316,8 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
|
|||||||
safeStopLog(handle.id)
|
safeStopLog(handle.id)
|
||||||
sessions.delete(handle.id)
|
sessions.delete(handle.id)
|
||||||
replayBuffers.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 {
|
} catch {
|
||||||
// never crash the event loop
|
// 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)
|
stopPolling(id)
|
||||||
closeSftp(id)
|
closeSftp(id)
|
||||||
detachZmodem(id)
|
detachZmodem(id)
|
||||||
safeStopLog(id)
|
safeStopLog(id)
|
||||||
replayBuffers.delete(id)
|
replayBuffers.delete(id)
|
||||||
|
sshDecoders.delete(id)
|
||||||
const session = sessions.get(id)
|
const session = sessions.get(id)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (session.kind === 'ssh') {
|
if (session.kind === 'ssh') {
|
||||||
@@ -354,6 +382,20 @@ export function killPty(id: string): void {
|
|||||||
sessions.delete(id)
|
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 {
|
export function killAllPtys(): void {
|
||||||
for (const id of sessions.keys()) {
|
for (const id of sessions.keys()) {
|
||||||
stopPolling(id)
|
stopPolling(id)
|
||||||
@@ -382,4 +424,5 @@ export function killAllPtys(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sessions.clear()
|
sessions.clear()
|
||||||
|
sshDecoders.clear()
|
||||||
}
|
}
|
||||||
+75
-17
@@ -10,7 +10,7 @@ import {
|
|||||||
type SystemSettings,
|
type SystemSettings,
|
||||||
type TerminalSettings
|
type TerminalSettings
|
||||||
} from '../shared/settings'
|
} 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 { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n'
|
||||||
import { broadcast } from './broadcast'
|
import { broadcast } from './broadcast'
|
||||||
import { applyGlobalShortcut } from './globalShortcuts'
|
import { applyGlobalShortcut } from './globalShortcuts'
|
||||||
@@ -18,16 +18,12 @@ import { applyWindowChrome } from './windowChrome'
|
|||||||
|
|
||||||
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
|
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
|
||||||
|
|
||||||
const DEFAULT_SYSTEM: SystemSettings = {
|
/**
|
||||||
launchAtLogin: false,
|
* Snapshot of the shared system defaults, derived rather than retyped so the two
|
||||||
preventSleep: false,
|
* cannot drift (a hand-written copy once shipped closeAction 'ask', which made a
|
||||||
globalShowHide: '',
|
* fresh install prompt on close while the docs and UI promised the tray).
|
||||||
// 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.
|
const DEFAULT_SYSTEM: SystemSettings = { ...DEFAULT_SETTINGS.system }
|
||||||
closeAction: 'tray',
|
|
||||||
autoCheckUpdate: true,
|
|
||||||
language: DEFAULT_LANGUAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[])
|
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
|
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[] } {
|
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
|
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') {
|
if (raw !== null && typeof raw === 'object') {
|
||||||
const sys = (raw as { system?: unknown }).system
|
const sys = (raw as { system?: unknown }).system
|
||||||
if (sys !== null && typeof sys === 'object') {
|
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 {
|
return {
|
||||||
settings: {
|
settings: {
|
||||||
@@ -306,13 +354,23 @@ export function mutateSettings(mutate: (settings: AppSettings) => AppSettings):
|
|||||||
return run
|
return run
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace the persisted settings with `next` (serialized). */
|
/** Merge a (partial) settings patch into the persisted settings. */
|
||||||
export function saveSettings(next: AppSettings): Promise<AppSettings> {
|
export function saveSettings(next: Partial<AppSettings>): Promise<AppSettings> {
|
||||||
return mutateSettings((current) => ({ ...current, ...next }))
|
// 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 {
|
export function registerSettingsIpc(): void {
|
||||||
migrateDefaultsOnce()
|
migrateDefaultsOnce()
|
||||||
ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings())
|
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'],
|
[0o040, 'r'], [0o020, 'w'], [0o010, 'x'],
|
||||||
[0o004, 'r'], [0o002, 'w'], [0o001, 'x']
|
[0o004, 'r'], [0o002, 'w'], [0o001, 'x']
|
||||||
]
|
]
|
||||||
const perm = groups.map(([bit, ch]) => ((mode & bit) !== 0 ? ch : '-')).join('')
|
const perm = groups.map(([bit, ch]) => ((mode & bit) !== 0 ? ch : '-'))
|
||||||
return kind + perm
|
// 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) => {
|
const sftp = await new Promise<SFTPWrapper>((resolve, reject) => {
|
||||||
client.sftp((err, sftp_) => (err != null ? reject(err) : resolve(sftp_)))
|
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)
|
sftpCache.set(sessionId, sftp)
|
||||||
return sftp
|
return sftp
|
||||||
}
|
}
|
||||||
|
|
||||||
function evictSftp(sessionId: string): void {
|
function evictSftp(sessionId: string): void {
|
||||||
|
const sftp = sftpCache.get(sessionId)
|
||||||
sftpCache.delete(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 {
|
function isTransportError(err: unknown): boolean {
|
||||||
if (err instanceof SessionGoneError) return true
|
if (err instanceof SessionGoneError) return true
|
||||||
const msg = (err as Error | undefined)?.message ?? ''
|
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. */
|
/** 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}`)
|
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('; ') }))
|
throw new Error(t('main.sftp.deleteFailed', { detail: failures.join('; ') }))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -235,13 +252,25 @@ function execQuiet(sessionId: string, cmd: string): Promise<void> {
|
|||||||
reject(err)
|
reject(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let stdout = ''
|
||||||
let stderr = ''
|
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) => {
|
stream.stderr?.on('data', (d: Buffer) => {
|
||||||
stderr += d.toString('utf8')
|
stderr += d.toString('utf8')
|
||||||
})
|
})
|
||||||
|
stream.on('error', (e: Error) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(e)
|
||||||
|
})
|
||||||
stream.on('close', (code: number) => {
|
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()
|
else resolve()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -299,7 +328,6 @@ export function uploadRemote(
|
|||||||
try {
|
try {
|
||||||
let pos = 0
|
let pos = 0
|
||||||
let lastEmit = 0
|
let lastEmit = 0
|
||||||
const buf = Buffer.alloc(CHUNK)
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
|
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
|
||||||
if (firstError) throw firstError
|
if (firstError) throw firstError
|
||||||
@@ -307,6 +335,9 @@ export function uploadRemote(
|
|||||||
await Promise.race(inflight)
|
await Promise.race(inflight)
|
||||||
if (firstError) throw firstError
|
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)
|
const { bytesRead } = await localHandle.read(buf, 0, CHUNK, pos)
|
||||||
if (bytesRead === 0) break
|
if (bytesRead === 0) break
|
||||||
const offset = pos
|
const offset = pos
|
||||||
@@ -408,6 +439,8 @@ export function downloadRemote(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
await fsp.rm(local, { force: true })
|
await fsp.rm(local, { force: true })
|
||||||
throw err
|
throw err
|
||||||
|
} finally {
|
||||||
|
await pVoid(cb => sftp.close(handle, cb)).catch(() => undefined)
|
||||||
}
|
}
|
||||||
if (transfer.cancelled) {
|
if (transfer.cancelled) {
|
||||||
await fsp.rm(local, { force: true })
|
await fsp.rm(local, { force: true })
|
||||||
|
|||||||
+19
-3
@@ -28,6 +28,11 @@ export interface SshSessionHandle {
|
|||||||
client: Client
|
client: Client
|
||||||
/** open interactive shell channel (ClientChannel, a Duplex) */
|
/** open interactive shell channel (ClientChannel, a Duplex) */
|
||||||
stream: ClientChannel
|
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 {
|
export interface SshServiceDeps {
|
||||||
@@ -80,6 +85,7 @@ export async function connectSsh(
|
|||||||
let settled = false
|
let settled = false
|
||||||
let connectTimer: NodeJS.Timeout | undefined
|
let connectTimer: NodeJS.Timeout | undefined
|
||||||
let verifierErr: string | undefined
|
let verifierErr: string | undefined
|
||||||
|
let sessionHandle: SshSessionHandle | undefined
|
||||||
let resolvePromise!: (handle: SshSessionHandle) => void
|
let resolvePromise!: (handle: SshSessionHandle) => void
|
||||||
let rejectPromise!: (err: Error) => void
|
let rejectPromise!: (err: Error) => void
|
||||||
|
|
||||||
@@ -161,6 +167,9 @@ export async function connectSsh(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Session already established: surface as a session exit and clean up.
|
// 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 {
|
try {
|
||||||
deps.broadcast(Ipc.PTY_EXIT, { id: sessionId, exitCode: 1 })
|
deps.broadcast(Ipc.PTY_EXIT, { id: sessionId, exitCode: 1 })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -201,7 +210,8 @@ export async function connectSsh(
|
|||||||
} catch {
|
} catch {
|
||||||
// store write failure must not break the session
|
// 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
|
hostVerifier
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password auth
|
// Password auth. A stored password is offered only when the bookmark is
|
||||||
const password = secretOverride?.password ?? deps.connections.getSecret(conn, 'password')
|
// 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
|
if (password !== undefined) cfg.password = password
|
||||||
|
|
||||||
// Private key auth (keyPath takes precedence over stored keyContent)
|
// 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 {
|
export function writeJson(file: string, value: unknown): void {
|
||||||
mkdirSync(dirname(file), { recursive: true })
|
mkdirSync(dirname(file), { recursive: true })
|
||||||
const tmp = tmpPath(file)
|
const tmp = tmpPath(file)
|
||||||
try {
|
try {
|
||||||
writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
|
writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
|
||||||
renameSync(tmp, file)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Clean up the temp file so a failed write does not leave litter behind.
|
// Clean up the temp file so a failed write does not leave litter behind.
|
||||||
try {
|
try {
|
||||||
@@ -50,6 +53,26 @@ export function writeJson(file: string, value: unknown): void {
|
|||||||
}
|
}
|
||||||
throw err
|
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. */
|
/** Convenience for stores whose file lives under a directory. */
|
||||||
|
|||||||
+31
-6
@@ -129,10 +129,26 @@ function pollOnce(id: string, state: PollState): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let out = ''
|
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) => {
|
stream.on('data', (d: Buffer) => {
|
||||||
out += d.toString('utf8')
|
out += d.toString('utf8')
|
||||||
})
|
})
|
||||||
const onEnd = (): void => {
|
const onEnd = (): void => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
clearTimeout(watchdog)
|
||||||
if (state.stopped) return
|
if (state.stopped) return
|
||||||
try {
|
try {
|
||||||
stream.close()
|
stream.close()
|
||||||
@@ -142,7 +158,18 @@ function pollOnce(id: string, state: PollState): void {
|
|||||||
handleOutput(id, state, out)
|
handleOutput(id, state, out)
|
||||||
}
|
}
|
||||||
stream.on('close', onEnd)
|
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) {
|
} catch (err) {
|
||||||
handleError(id, state, (err as Error).message)
|
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 irq = f[5] ?? 0
|
||||||
const softirq = f[6] ?? 0
|
const softirq = f[6] ?? 0
|
||||||
const steal = f[7] ?? 0
|
const steal = f[7] ?? 0
|
||||||
const guest = f[8] ?? 0
|
// guest/guest_nice (f[8]/f[9]) are already included in user/nice per
|
||||||
const guestNice = f[9] ?? 0
|
// proc(5) — summing them in would double-count and understate CPU%.
|
||||||
idle = idleTicks + iowait
|
idle = idleTicks + iowait
|
||||||
total =
|
total =
|
||||||
user +
|
user +
|
||||||
@@ -314,9 +341,7 @@ function parseProc(blob: string): { cpu: SysinfoSample['cpu']; cpuIdle: number;
|
|||||||
idle +
|
idle +
|
||||||
irq +
|
irq +
|
||||||
softirq +
|
softirq +
|
||||||
steal +
|
steal
|
||||||
guest +
|
|
||||||
guestNice
|
|
||||||
} else {
|
} else {
|
||||||
cores += 1
|
cores += 1
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -67,10 +67,12 @@ function wireEvents(): void {
|
|||||||
|
|
||||||
/** Check the active feed; on failure switch Gitea → GitHub and retry once. */
|
/** Check the active feed; on failure switch Gitea → GitHub and retry once. */
|
||||||
async function checkWithFallback(): Promise<void> {
|
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 {
|
try {
|
||||||
await autoUpdater.checkForUpdates()
|
await autoUpdater.checkForUpdates()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (activeFeed !== 'gitea') throw err
|
|
||||||
console.warn('[updater] gitea feed failed, falling back to github:', err)
|
console.warn('[updater] gitea feed failed, falling back to github:', err)
|
||||||
const giteaErr = err instanceof Error ? err.message : String(err)
|
const giteaErr = err instanceof Error ? err.message : String(err)
|
||||||
useFeed('github')
|
useFeed('github')
|
||||||
@@ -164,11 +166,15 @@ export function registerUpdateIpc(): void {
|
|||||||
ipcMain.handle(Ipc.UPDATE_CHECK, handleCheck)
|
ipcMain.handle(Ipc.UPDATE_CHECK, handleCheck)
|
||||||
ipcMain.handle(Ipc.UPDATE_DOWNLOAD, handleDownload)
|
ipcMain.handle(Ipc.UPDATE_DOWNLOAD, handleDownload)
|
||||||
ipcMain.handle(Ipc.UPDATE_INSTALL, () => {
|
ipcMain.handle(Ipc.UPDATE_INSTALL, () => {
|
||||||
|
if (!app.isPackaged) return
|
||||||
// The close interceptor (tray flow) would swallow this quit — mark first.
|
// The close interceptor (tray flow) would swallow this quit — mark first.
|
||||||
markQuitting()
|
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_CHANGELOG, fetchChangelog)
|
||||||
|
ipcMain.handle(Ipc.UPDATE_STATE_GET, () => state)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configureAutoUpdater(): void {
|
export function configureAutoUpdater(): void {
|
||||||
|
|||||||
+26
-7
@@ -37,10 +37,12 @@ export interface ZmodemDeps {
|
|||||||
broadcast(channel: string, ...args: unknown[]): void
|
broadcast(channel: string, ...args: unknown[]): void
|
||||||
/**
|
/**
|
||||||
* Forward NON-ZMODEM octets back through the normal terminal data path
|
* Forward NON-ZMODEM octets back through the normal terminal data path
|
||||||
* (utf8 + replay + session log + PTY_DATA). pty.ts injects this; the engine
|
* (utf8 decode + replay + session log + PTY_DATA). pty.ts injects this and
|
||||||
* only calls it when no transfer is active.
|
* 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). */
|
/** Write bytes out to the ssh stream (zmodem frames + CAN abort sequence). */
|
||||||
writeStream(sessionId: string, data: Buffer): void
|
writeStream(sessionId: string, data: Buffer): void
|
||||||
}
|
}
|
||||||
@@ -129,7 +131,17 @@ function finalize(
|
|||||||
}
|
}
|
||||||
engine.receiveStream = null
|
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
|
engine.detection = null
|
||||||
|
|
||||||
if (!engine.progressEmitted) {
|
if (!engine.progressEmitted) {
|
||||||
@@ -300,7 +312,7 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
|
|||||||
// Analysis: also defensive against active http-parsing leftovers.
|
// Analysis: also defensive against active http-parsing leftovers.
|
||||||
if (engine.active) return // suppress binary terminal output during transfer
|
if (engine.active) return // suppress binary terminal output during transfer
|
||||||
try {
|
try {
|
||||||
deps.toTerminal(sessionId, Buffer.from(octets).toString('utf8'))
|
deps.toTerminal(sessionId, Buffer.from(octets))
|
||||||
} catch {
|
} catch {
|
||||||
// never crash the event loop
|
// never crash the event loop
|
||||||
}
|
}
|
||||||
@@ -321,6 +333,8 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
|
|||||||
engine.dir = null
|
engine.dir = null
|
||||||
engine.session = null
|
engine.session = null
|
||||||
engine.receiveStream = null
|
engine.receiveStream = null
|
||||||
|
engine.bytes = 0
|
||||||
|
engine.totalBytes = 0
|
||||||
engine.transferId = `zm-${sessionId}`
|
engine.transferId = `zm-${sessionId}`
|
||||||
emitProgress(engine, { file: '', bytes: 0, totalBytes: 0 })
|
emitProgress(engine, { file: '', bytes: 0, totalBytes: 0 })
|
||||||
try {
|
try {
|
||||||
@@ -328,7 +342,6 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// never crash the event loop
|
// never crash the event loop
|
||||||
}
|
}
|
||||||
touchActivity(engine)
|
|
||||||
engine.offerTimer = setTimeout(() => {
|
engine.offerTimer = setTimeout(() => {
|
||||||
if (engine.detection && !engine.confirmed) {
|
if (engine.detection && !engine.confirmed) {
|
||||||
abortWithoutSession(engine, t('main.zmodem.offerTimedOut'))
|
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.
|
* the CAN abort sequence directly so the remote program exits.
|
||||||
*/
|
*/
|
||||||
function abortWithoutSession(engine: Engine, message: string): void {
|
function abortWithoutSession(engine: Engine, message: string): void {
|
||||||
|
try {
|
||||||
|
engine.detection?.deny()
|
||||||
|
} catch {
|
||||||
|
// already retracted or confirmed
|
||||||
|
}
|
||||||
|
engine.detection = null
|
||||||
try {
|
try {
|
||||||
engine.deps.writeStream(engine.id, ABORT_BUFFER)
|
engine.deps.writeStream(engine.id, ABORT_BUFFER)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -425,7 +444,7 @@ export function isZmodemActive(sessionId: string): boolean {
|
|||||||
*/
|
*/
|
||||||
export function respondZmodem(resp: ZmodemResponse): void {
|
export function respondZmodem(resp: ZmodemResponse): void {
|
||||||
const engine = current(resp.id)
|
const engine = current(resp.id)
|
||||||
if (!engine || engine.confirmed) return
|
if (!engine || !engine.active || engine.confirmed) return
|
||||||
|
|
||||||
if (resp.cancelled) {
|
if (resp.cancelled) {
|
||||||
abortWithoutSession(engine, t('main.zmodem.cancelled'))
|
abortWithoutSession(engine, t('main.zmodem.cancelled'))
|
||||||
|
|||||||
Reference in new issue
Block a user