CI / typecheck + test + build (windows) (push) Canceled after 0s
P1: - settingsStore: back up an unparseable settings.json to .bak before falling back to defaults, so the next mutation can no longer silently wipe custom themes/highlight rules - ptyDispatcher: fan out per-session data/exit handlers (Set instead of a single slot) so SSH split panes stop stealing each other's stream - FilePanel: monotonic refresh token keeps stale listings from painting over a newer navigation; upload finish no longer yanks the panel back - settings.css: active settings-tab label derives from --chrome-fg so it stays visible on the shipped light themes P2 (main/renderer): - paste guard: a paste ending in a newline always confirms - Workspace: closing an SSH pane no longer seeds the local cwd with a remote path - tray: skip close-dialog continuation on a destroyed window - commands: close zombie 'in-progress' session logs at hydrate - zmodem: clear the stale offer timer before arming a new one - connectionsStore: coerce/validate renderer input before persisting - sftp: OperationError marker class keeps translated errors out of the transport-retry classifier - CommandsPanel: surface save failures inside the dialog - ConnectionSidebar: drop a tautological tooltip condition P2 (i18n/tooling/tests): - localize the 16 ANSI color labels and the highlight sample text (21 new keys across zh-CN/zh-TW/en/ja) - sync-changelog: keep ### subheadings, normalize CRLF notes - release.cjs: GitHub release reuse-by-tag (idempotent re-runs); fail loudly on a failed Gitea asset listing - commands-store test: absent historyEnabled now truly tests absence
518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
/**
|
|
* ZMODEM engine (M6) - lives in the MAIN process and only ever runs over an SSH
|
|
* session stream, using zmodem.js@0.1.10.
|
|
*
|
|
* LOCAL PTY SESSIONS ARE DELIBERATELY NOT SUPPORTED: node-pty / Windows ConPTY
|
|
* only hand us UTF-8 strings and are lossy for arbitrary bytes, so a binary
|
|
* ZMODEM stream would be corrupted (and there is no file dialog on the local
|
|
* side anyway). The engine is therefore only ever attached to an ssh2 channel,
|
|
* where we get raw Buffers.
|
|
*
|
|
* Design mirrors pty.ts's dependency-injection pattern: this module holds no
|
|
* Electron import. pty.ts injects `broadcast`/`toTerminal`/`writeStream`, and
|
|
* routes the ssh stream's 'data' through `feedZmodem`. While a transfer is
|
|
* active we suppress the normal terminal data-plane (binary frames must never
|
|
* reach the terminal), and pty.ts's writePty drops user keystrokes via
|
|
* isZmodemActive.
|
|
*/
|
|
import { basename, join } from 'path'
|
|
import { createReadStream, createWriteStream } from 'fs'
|
|
import { stat } from 'fs/promises'
|
|
import type { Writable } from 'stream'
|
|
import * as Zmodem from 'zmodem.js'
|
|
import { Ipc } from '../shared/ipc'
|
|
import type { ZmodemDoneEvent, ZmodemResponse } from '../shared/ipc'
|
|
import type { TransferProgressEvent } from '../shared/sftp'
|
|
import { t } from '../shared/i18n'
|
|
|
|
/** How long to wait for the renderer to answer a ZMODEM_OFFER (ms). */
|
|
const OFFER_TIMEOUT_MS = 120_000
|
|
/** Abort a transfer that makes no byte progress for this long (ms). */
|
|
const STALL_TIMEOUT_MS = 90_000
|
|
|
|
const ABORT_BUFFER = Buffer.from(Zmodem.ZMLIB.ABORT_SEQUENCE)
|
|
|
|
export interface ZmodemDeps {
|
|
/** Emit a main->renderer broadcast (ZMODEM_OFFER / ZMODEM_DONE / TRANSFER_PROGRESS). */
|
|
broadcast(channel: string, ...args: unknown[]): void
|
|
/**
|
|
* Forward NON-ZMODEM octets back through the normal terminal data path
|
|
* (utf8 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, data: Buffer): void
|
|
/** Write bytes out to the ssh stream (zmodem frames + CAN abort sequence). */
|
|
writeStream(sessionId: string, data: Buffer): void
|
|
}
|
|
|
|
interface Engine {
|
|
id: string
|
|
deps: ZmodemDeps
|
|
sentry: Zmodem.Sentry
|
|
detection: Zmodem.Detection | null
|
|
session: Zmodem.SendSession | Zmodem.ReceiveSession | null
|
|
/** True from header detection until the session ends / teardown. */
|
|
active: boolean
|
|
mode: 'send' | 'receive' | null
|
|
dir: string | null
|
|
/** True once the detection is confirmed and files start flowing. */
|
|
confirmed: boolean
|
|
transferId: string
|
|
/** one progress terminal event at most */
|
|
progressEmitted: boolean
|
|
doneEmitted: boolean
|
|
offerTimer: NodeJS.Timeout | null
|
|
stallTimer: NodeJS.Timeout | null
|
|
receiveStream: Writable | null
|
|
bytes: number
|
|
totalBytes: number
|
|
}
|
|
|
|
const engines = new Map<string, Engine>()
|
|
|
|
function current(sessionId: string): Engine | undefined {
|
|
return engines.get(sessionId)
|
|
}
|
|
|
|
function cancelTimers(engine: Engine): void {
|
|
if (engine.offerTimer) clearTimeout(engine.offerTimer)
|
|
if (engine.stallTimer) clearTimeout(engine.stallTimer)
|
|
engine.offerTimer = null
|
|
engine.stallTimer = null
|
|
}
|
|
|
|
function emitProgress(engine: Engine, patch: Partial<TransferProgressEvent>): void {
|
|
const evt: TransferProgressEvent = {
|
|
transferId: engine.transferId,
|
|
kind: engine.mode === 'receive' ? 'zmodem-download' : 'zmodem-upload',
|
|
state: 'running',
|
|
file: '',
|
|
bytes: engine.bytes,
|
|
totalBytes: engine.totalBytes,
|
|
...patch
|
|
}
|
|
try {
|
|
engine.deps.broadcast(Ipc.TRANSFER_PROGRESS, evt)
|
|
} catch {
|
|
// never crash the event loop
|
|
}
|
|
}
|
|
|
|
function emitDone(engine: Engine, ok: boolean, message?: string): void {
|
|
if (engine.doneEmitted) return
|
|
engine.doneEmitted = true
|
|
const evt: ZmodemDoneEvent = { id: engine.id, ok, message }
|
|
try {
|
|
engine.deps.broadcast(Ipc.ZMODEM_DONE, evt)
|
|
} catch {
|
|
// never crash the event loop
|
|
}
|
|
}
|
|
|
|
/**
|
|
* End-of-session / cancel / error common path: stop suppressing the terminal
|
|
* data plane, drop timers, release streams, and emit the terminal events once.
|
|
*/
|
|
function finalize(
|
|
engine: Engine,
|
|
ok: boolean,
|
|
message?: string,
|
|
failState: 'cancelled' | 'error' = 'error'
|
|
): void {
|
|
engine.active = false
|
|
cancelTimers(engine)
|
|
if (engine.receiveStream) {
|
|
try {
|
|
engine.receiveStream.end()
|
|
} catch {
|
|
// already closed
|
|
}
|
|
engine.receiveStream = null
|
|
}
|
|
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) {
|
|
emitProgress(engine, { state: ok ? 'done' : failState, file: message ?? '' })
|
|
}
|
|
engine.progressEmitted = true
|
|
|
|
if (ok) emitDone(engine, true)
|
|
else emitDone(engine, false, message ?? t('main.zmodem.transferFailed'))
|
|
}
|
|
|
|
/** Called by the zsession's own 'session_end' event (its `this` is the session). */
|
|
function makeSessionEnd(engine: Engine): () => void {
|
|
return () => finalize(engine, true)
|
|
}
|
|
|
|
function touchActivity(engine: Engine): void {
|
|
if (engine.stallTimer) clearTimeout(engine.stallTimer)
|
|
const timer = setTimeout(() => {
|
|
if (engine.stallTimer === null) return // already finalized
|
|
finalize(engine, false, t('main.zmodem.transferTimeout'))
|
|
}, STALL_TIMEOUT_MS)
|
|
engine.stallTimer = timer
|
|
}
|
|
|
|
function wireSession(engine: Engine): void {
|
|
const session = engine.session
|
|
if (!session) return
|
|
const sendToPeer = (octets: number[]): void => {
|
|
try {
|
|
engine.deps.writeStream(engine.id, Buffer.from(octets))
|
|
} catch {
|
|
// stream gone mid-transfer
|
|
}
|
|
}
|
|
session.set_sender(sendToPeer)
|
|
;(session as unknown as { on: (event: string, cb: () => void) => void }).on('session_end', makeSessionEnd(engine))
|
|
touchActivity(engine)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sending (remote `rz` -> we upload local files)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function sendOneFile(
|
|
engine: Engine,
|
|
session: Zmodem.SendSession,
|
|
file: { path: string; name: string; size: number; mtimeMs: number }
|
|
): Promise<void> {
|
|
const xfer = await session.send_offer({
|
|
name: file.name,
|
|
size: file.size,
|
|
mtime: new Date(file.mtimeMs),
|
|
mode: undefined
|
|
})
|
|
if (!xfer) return // receiver skipped the file
|
|
emitProgress(engine, { file: file.name, bytes: engine.bytes, totalBytes: engine.totalBytes })
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const rs = createReadStream(file.path)
|
|
rs.on('data', (chunk: string | Buffer) => {
|
|
rs.pause()
|
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
try {
|
|
xfer.send(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength))
|
|
} catch (err) {
|
|
rs.destroy()
|
|
reject(err instanceof Error ? err : new Error(t('main.zmodem.sendFailed')))
|
|
return
|
|
}
|
|
engine.bytes += buf.length
|
|
emitProgress(engine, { file: file.name, bytes: engine.bytes, totalBytes: engine.totalBytes })
|
|
touchActivity(engine)
|
|
rs.resume()
|
|
})
|
|
rs.on('end', () => {
|
|
xfer
|
|
.end()
|
|
.then(() => resolve())
|
|
.catch(reject)
|
|
})
|
|
rs.on('error', reject)
|
|
})
|
|
}
|
|
|
|
async function runSend(engine: Engine, paths: string[]): Promise<void> {
|
|
try {
|
|
const session = engine.session as Zmodem.SendSession
|
|
const files = await Promise.all(
|
|
paths.map(async (p) => {
|
|
const s = await stat(p)
|
|
return { path: p, name: basename(p), size: s.size, mtimeMs: s.mtimeMs }
|
|
})
|
|
)
|
|
engine.totalBytes = files.reduce((acc, f) => acc + f.size, 0)
|
|
|
|
for (const f of files) {
|
|
emitProgress(engine, { file: f.name, bytes: engine.bytes, totalBytes: engine.totalBytes })
|
|
await sendOneFile(engine, session, f)
|
|
}
|
|
emitProgress(engine, { file: '', bytes: engine.totalBytes, totalBytes: engine.totalBytes })
|
|
// Final state + ZMODEM_DONE come from the session_end event fired by close().
|
|
await session.close()
|
|
} catch (err) {
|
|
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.uploadFailed'))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Receiving (remote `sz` -> we download into a chosen local directory)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function handleOffer(engine: Engine, offer: Zmodem.Offer): void {
|
|
const details = offer.get_details()
|
|
const name = basename(String(details.name ?? 'file'))
|
|
const dest = join(engine.dir ?? '.', name)
|
|
if (details.size) engine.totalBytes += details.size
|
|
|
|
const stream = createWriteStream(dest)
|
|
engine.receiveStream = stream
|
|
emitProgress(engine, { file: name, bytes: engine.bytes, totalBytes: engine.totalBytes })
|
|
|
|
offer
|
|
.accept({
|
|
on_input: (payload: Uint8Array | number[]) => {
|
|
// zmodem.js delivers the payload as a plain octet Array (not a
|
|
// Uint8Array); coerce defensively to a Buffer before writing.
|
|
const buf = Array.isArray(payload) ? Buffer.from(payload) : Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)
|
|
if (!stream.destroyed && !stream.closed) {
|
|
stream.write(buf)
|
|
}
|
|
engine.bytes += buf.byteLength
|
|
emitProgress(engine, { file: name, bytes: engine.bytes, totalBytes: engine.totalBytes })
|
|
touchActivity(engine)
|
|
}
|
|
})
|
|
.then(() => {
|
|
stream.end(() => {
|
|
if (engine.receiveStream === stream) engine.receiveStream = null
|
|
emitProgress(engine, { file: name, bytes: engine.bytes, totalBytes: engine.totalBytes })
|
|
})
|
|
})
|
|
.catch((err: unknown) => {
|
|
try {
|
|
stream.destroy()
|
|
} catch {
|
|
// already gone
|
|
}
|
|
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.receiveFailed'))
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API (used by pty.ts / ipc.ts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Attach a ZMODEM Sentry to an ssh session. Only call for ssh sessions (see
|
|
* module docs: local ptys are unsupported by design). deps are injected by
|
|
* pty.ts so this module stays Electron-free.
|
|
*/
|
|
export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
|
|
const engine: Engine = {
|
|
id: sessionId,
|
|
deps,
|
|
sentry: new Zmodem.Sentry({
|
|
to_terminal: (octets: number[]) => {
|
|
// Analysis: also defensive against active http-parsing leftovers.
|
|
if (engine.active) return // suppress binary terminal output during transfer
|
|
try {
|
|
deps.toTerminal(sessionId, Buffer.from(octets))
|
|
} catch {
|
|
// never crash the event loop
|
|
}
|
|
},
|
|
on_detect: (detection: Zmodem.Detection) => {
|
|
const role = detection.get_session_role()
|
|
engine.mode = role === 'receive' ? 'receive' : 'send'
|
|
engine.detection = detection
|
|
engine.active = true
|
|
// A detect is the start of a fresh transfer on this session, so the
|
|
// one-shot flags of the previous one must go with it. Leaving `confirmed`
|
|
// set made the *second* `sz`/`rz` on a session a no-op: respondZmodem
|
|
// bailed out, the engine stayed `active` (swallowing every keystroke and
|
|
// all terminal data) until the stall timer finally fired.
|
|
engine.confirmed = false
|
|
engine.progressEmitted = false
|
|
engine.doneEmitted = false
|
|
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 {
|
|
deps.broadcast(Ipc.ZMODEM_OFFER, { id: sessionId, mode: engine.mode })
|
|
} catch {
|
|
// never crash the event loop
|
|
}
|
|
// Clear any previous offer timer first: a re-detect while an earlier
|
|
// offer is still pending overwrites engine.offerTimer, and the orphaned
|
|
// timer would fire at its original deadline and abort a legitimate
|
|
// pending offer ahead of its own timeout.
|
|
if (engine.offerTimer) clearTimeout(engine.offerTimer)
|
|
engine.offerTimer = setTimeout(() => {
|
|
if (engine.detection && !engine.confirmed) {
|
|
abortWithoutSession(engine, t('main.zmodem.offerTimedOut'))
|
|
}
|
|
}, OFFER_TIMEOUT_MS)
|
|
},
|
|
on_retract: () => {
|
|
// The detected "session" turned out not to be ZMODEM; return to normal.
|
|
engine.detection = null
|
|
engine.mode = null
|
|
engine.active = false
|
|
cancelTimers(engine)
|
|
},
|
|
sender: (octets: number[]) => {
|
|
try {
|
|
deps.writeStream(sessionId, Buffer.from(octets))
|
|
} catch {
|
|
// stream gone mid-transfer
|
|
}
|
|
}
|
|
}),
|
|
detection: null,
|
|
session: null,
|
|
active: false,
|
|
mode: null,
|
|
dir: null,
|
|
confirmed: false,
|
|
transferId: `zm-${sessionId}`,
|
|
progressEmitted: false,
|
|
doneEmitted: false,
|
|
offerTimer: null,
|
|
stallTimer: null,
|
|
receiveStream: null,
|
|
bytes: 0,
|
|
totalBytes: 0
|
|
}
|
|
engines.set(sessionId, engine)
|
|
}
|
|
|
|
/**
|
|
* Abort an unconfirmed (or pre-session) offer. No live zsession yet, so we write
|
|
* the CAN abort sequence directly so the remote program exits.
|
|
*/
|
|
function abortWithoutSession(engine: Engine, message: string): void {
|
|
try {
|
|
engine.detection?.deny()
|
|
} catch {
|
|
// already retracted or confirmed
|
|
}
|
|
engine.detection = null
|
|
try {
|
|
engine.deps.writeStream(engine.id, ABORT_BUFFER)
|
|
} catch {
|
|
// stream may be gone
|
|
}
|
|
engine.active = false
|
|
cancelTimers(engine)
|
|
if (!engine.progressEmitted) emitProgress(engine, { state: 'cancelled', file: message })
|
|
engine.progressEmitted = true
|
|
emitDone(engine, false, message)
|
|
}
|
|
|
|
/** Abort a confirmed session cleanly (sends CAN so remote rz/sz exits). */
|
|
function abortSession(engine: Engine, message: string): void {
|
|
try {
|
|
engine.session?.abort()
|
|
} catch {
|
|
// fall through to write the abort sequence ourselves
|
|
}
|
|
try {
|
|
engine.deps.writeStream(engine.id, ABORT_BUFFER)
|
|
} catch {
|
|
// stream may be gone
|
|
}
|
|
finalize(engine, false, message, 'cancelled')
|
|
}
|
|
|
|
/** Feed raw ssh-stream bytes into the engine. Only call when isZmodemActive(). */
|
|
export function feedZmodem(sessionId: string, data: Buffer): void {
|
|
const engine = current(sessionId)
|
|
if (!engine) return
|
|
try {
|
|
engine.sentry.consume(data)
|
|
} catch (err) {
|
|
// A zmodem.js Error (peer abort / corrupt frame) ends the session.
|
|
if (!engine.doneEmitted) {
|
|
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.abnormal'))
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Whether a zmodem transfer is active (pty.ts suppresses user writes while true). */
|
|
export function isZmodemActive(sessionId: string): boolean {
|
|
return current(sessionId)?.active ?? false
|
|
}
|
|
|
|
/**
|
|
* The renderer answered an offer (via ipc.ts). `cancelled` aborts; otherwise
|
|
* the flow proceeds according to the detected mode.
|
|
*/
|
|
export function respondZmodem(resp: ZmodemResponse): void {
|
|
const engine = current(resp.id)
|
|
if (!engine || !engine.active || engine.confirmed) return
|
|
|
|
if (resp.cancelled) {
|
|
abortWithoutSession(engine, t('main.zmodem.cancelled'))
|
|
return
|
|
}
|
|
|
|
const detection = engine.detection
|
|
if (!detection || !detection.is_valid()) {
|
|
abortWithoutSession(engine, t('main.zmodem.sessionInvalid'))
|
|
return
|
|
}
|
|
|
|
engine.confirmed = true
|
|
// The offer was answered — stop the offer watchdog; the stall timer takes over.
|
|
if (engine.offerTimer) {
|
|
clearTimeout(engine.offerTimer)
|
|
engine.offerTimer = null
|
|
}
|
|
engine.session = detection.confirm()
|
|
if (!engine.session) {
|
|
abortWithoutSession(engine, t('main.zmodem.sessionCreateFailed'))
|
|
return
|
|
}
|
|
wireSession(engine)
|
|
|
|
if (engine.mode === 'receive') {
|
|
if (!resp.dir) {
|
|
abortSession(engine, t('main.zmodem.noSaveDir'))
|
|
return
|
|
}
|
|
engine.dir = resp.dir
|
|
const session = engine.session as Zmodem.ReceiveSession
|
|
session.on('offer', (offer) => handleOffer(engine, offer))
|
|
void session
|
|
.start()
|
|
.catch((err) =>
|
|
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.receiveFailed'))
|
|
)
|
|
} else {
|
|
const paths = resp.paths ?? []
|
|
if (paths.length === 0) {
|
|
abortSession(engine, t('main.zmodem.noFiles'))
|
|
return
|
|
}
|
|
void runSend(engine, paths)
|
|
}
|
|
}
|
|
|
|
/** Detach + clean up (session close / kill). Safe to call any number of times. */
|
|
export function detachZmodem(sessionId: string): void {
|
|
const engine = current(sessionId)
|
|
if (!engine) return
|
|
engine.active = false
|
|
cancelTimers(engine)
|
|
if (engine.receiveStream) {
|
|
try {
|
|
engine.receiveStream.end()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
engine.receiveStream = null
|
|
}
|
|
engine.session = null
|
|
engine.detection = null
|
|
engines.delete(sessionId)
|
|
} |