feat(audit): Batch 10 — CC conventions (shared line-buffer, cleanError)

The contained consistency consolidations; the sweeping ones are deferred with
their standard documented (they're the audit's own post-1.0 refactors, and
several would be reckless to do unattended).

Landed:
- src/main/lib/lineBuffer.ts (createLineBuffer): one newline line-splitter for
  streamed child-process output, replacing the duplicated buffer loop in
  download.ts and terminal.ts (CC3/CC4/SIMP5). Pure + unit-tested
  (test/lineBuffer.test.ts, 7 cases: partial lines, CRLF, empty lines, flush,
  multi-line chunks). download.ts keeps byte-identical marker parsing and no
  close-flush (yt-dlp template lines are always newline-terminated).
- ytdlp.ts: getYtdlpVersion/updateYtdlp now run stderr through cleanError, the
  same `cleanError(r.stderr) || r.error?.message || ''` idiom already used by
  download/probe/indexer (CC6) — a failed check shows the trailing error line.
- CL6 resolved: the one real redundancy was L15; clearDir/openFile/showInFolder
  are deliberate view-model wrappers (CC13), left as-is.

Deferred with documented standard + rationale (CODE-AUDIT.md): CC1 (breaking
persisted-key rename → migration), CC5+CL4 (security-critical updater
net.request, not live-verifiable here), CC7 (cosmetic arg-order), CC9 (zod dep),
CC10 (electron-store↔jsonStore split is deliberate for DPAPI), CC11 (config.ts
move), CC12 (file-tree reorg), CC13→L93, CC14→L142.

Verified: typecheck (node+web) + 275 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 16:48:54 -04:00
parent 3d4e574916
commit 814ecac287
6 changed files with 224 additions and 43 deletions
+21 -21
View File
@@ -15,6 +15,7 @@ import {
} from './binaries'
import { getSettings } from './settings'
import { execFileAsync } from './lib/exec'
import { createLineBuffer } from './lib/lineBuffer'
import { getDownloadArchivePath, getDefaultMediaDir } from './paths'
import { ensureManagedYtdlp } from './ytdlp'
import { materializeCookies, hasStoredCookies } from './cookies'
@@ -372,7 +373,6 @@ function wireChildProcess(params: {
const { wc, opts, rec, cleanup, getTitle } = params
const child = rec.child
let stdoutBuf = ''
let stderrTail = ''
let filePath: string | undefined
// Latched once the first download stream reports 'finished'; flags later
@@ -415,28 +415,28 @@ function wireChildProcess(params: {
}
bumpWatchdog()
// Split stdout into lines (shared helper, CC3/CC4) and parse our --print markers.
// No flush on close: yt-dlp's progress/path template lines are always
// newline-terminated, so a trailing partial is never a real marker.
const stdoutLines = createLineBuffer((line) => {
if (line.startsWith(PROGRESS_MARKER)) {
const p = parseProgress(line.slice(PROGRESS_MARKER.length))
if (p) {
// SR7: yt-dlp reports a 'finished' status when each download stream
// completes. A video+audio download has two streams, so the bar would
// otherwise fill 0→100% twice. Latch on the first 'finished' and flag
// every later tick as "finishing" so the renderer shows an indeterminate
// merge state instead of a visible restart.
if (p.status === 'finished') finishing = true
send(wc, { type: 'progress', id: opts.id, progress: { ...p, finishing } })
}
} else if (line.startsWith(FILEPATH_MARKER)) {
filePath = line.slice(FILEPATH_MARKER.length).trim()
}
})
child.stdout?.on('data', (chunk: Buffer) => {
bumpWatchdog()
stdoutBuf += chunk.toString()
let nl: number
while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1)
if (line.startsWith(PROGRESS_MARKER)) {
const p = parseProgress(line.slice(PROGRESS_MARKER.length))
if (p) {
// SR7: yt-dlp reports a 'finished' status when each download stream
// completes. A video+audio download has two streams, so the bar would
// otherwise fill 0→100% twice. Latch on the first 'finished' and flag
// every later tick as "finishing" so the renderer shows an indeterminate
// merge state instead of a visible restart.
if (p.status === 'finished') finishing = true
send(wc, { type: 'progress', id: opts.id, progress: { ...p, finishing } })
}
} else if (line.startsWith(FILEPATH_MARKER)) {
filePath = line.slice(FILEPATH_MARKER.length).trim()
}
}
stdoutLines.push(chunk.toString())
})
child.stderr?.on('data', (chunk: Buffer) => {
+37
View File
@@ -0,0 +1,37 @@
/**
* A newline line-splitter for streamed child-process output (audit CC3/CC4/SIMP5).
*
* `download.ts` and `terminal.ts` each had the same hand-rolled loop: accumulate
* chunk strings, emit every complete `\n`-terminated line (stripping a trailing
* `\r`), and keep the partial remainder for the next chunk. This is that logic in
* one place, and — being pure and synchronous — it's unit-tested directly.
*
* Usage: feed decoded chunks with `push()`, and call `flush()` on stream close to
* emit any trailing partial line that never saw a newline.
*/
export interface LineBuffer {
/** Accept a decoded chunk; invokes the sink for each newly-completed line. */
push: (chunk: string) => void
/** Emit any buffered remainder (a final line with no trailing newline). */
flush: () => void
}
export function createLineBuffer(onLine: (line: string) => void): LineBuffer {
let buf = ''
return {
push(chunk: string): void {
buf += chunk
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
onLine(buf.slice(0, nl).replace(/\r$/, ''))
buf = buf.slice(nl + 1)
}
},
flush(): void {
if (buf) {
onLine(buf)
buf = ''
}
}
}
}
+8 -14
View File
@@ -14,6 +14,7 @@ import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
import { getSettings } from './settings'
import { ensureManagedYtdlp } from './ytdlp'
import { parseExtraArgs } from './buildArgs'
import { createLineBuffer } from './lib/lineBuffer'
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc'
const active = new Map<string, ChildProcess>()
@@ -47,18 +48,12 @@ export function runTerminal(wc: WebContents, id: string, argsRaw: string): Termi
active.set(id, child)
// Buffer each stream and emit on newline boundaries (flush the remainder on close).
const buffers: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' }
function feed(stream: 'stdout' | 'stderr', chunk: string): void {
buffers[stream] += chunk
let nl: number
while ((nl = buffers[stream].indexOf('\n')) >= 0) {
const line = buffers[stream].slice(0, nl).replace(/\r$/, '')
buffers[stream] = buffers[stream].slice(nl + 1)
send(wc, { type: 'output', id, line, stream })
}
const lineBufs = {
stdout: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stdout' })),
stderr: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stderr' }))
}
child.stdout?.on('data', (c: Buffer) => feed('stdout', c.toString()))
child.stderr?.on('data', (c: Buffer) => feed('stderr', c.toString()))
child.stdout?.on('data', (c: Buffer) => lineBufs.stdout.push(c.toString()))
child.stderr?.on('data', (c: Buffer) => lineBufs.stderr.push(c.toString()))
let settled = false
child.on('error', (err) => {
@@ -72,9 +67,8 @@ export function runTerminal(wc: WebContents, id: string, argsRaw: string): Termi
settled = true
active.delete(id)
// Flush any trailing partial lines that never hit a newline.
for (const stream of ['stdout', 'stderr'] as const) {
if (buffers[stream]) send(wc, { type: 'output', id, line: buffers[stream], stream })
}
lineBufs.stdout.flush()
lineBufs.stderr.flush()
send(wc, { type: 'done', id, code })
})
+5 -2
View File
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, copyFileSync } from 'fs'
import { dirname } from 'path'
import { getYtdlpPath, getBundledYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
import { execFileAsync } from './lib/exec'
import { cleanError } from './log'
import { VERSION_TIMEOUT_MS, YTDLP_UPDATE_TIMEOUT_MS } from './constants'
import { getSettings, setSettings } from './settings'
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
@@ -43,9 +44,11 @@ export async function getYtdlpVersion(): Promise<YtdlpVersionResult> {
const r = await execFileAsync(ytdlpPath, ['--version'], { timeout: VERSION_TIMEOUT_MS })
if (!r.ok) {
// Run stderr through the shared cleaner so yt-dlp's noisy output collapses to
// its trailing error line, consistent with download/probe/indexer (CC6).
const msg = r.timedOut
? 'Timed out running yt-dlp.'
: (r.stderr || r.error?.message || '').trim()
: cleanError(r.stderr) || r.error?.message || ''
return { ok: false, error: msg }
}
return { ok: true, version: r.stdout.trim() }
@@ -81,7 +84,7 @@ export async function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpd
if (!r.ok) {
const msg = r.timedOut
? 'Timed out updating yt-dlp.'
: (r.stderr || r.error?.message || '').trim()
: cleanError(r.stderr) || r.error?.message || ''
return { ok: false, error: msg }
}
return { ok: true, output: r.stdout.trim() }