Files
EDDYCRAZY-CC 8ad234c0dc fix(dsh-web): resolve issues #1587, #1588, #1589, #1592, #1597, #1600
- task-board: pick the newest SETTLED execution for session reuse; the
  launch path appends this run's open record before the rule reads it
  (#1587)
- plugin-manager: resolve the packaged desktop runtime's own lib/bin.js
  and run it through the host interpreter when no PATH entry or .bin shim
  exists (#1588)
- plugin cards: contribute to whichever plugin-card seat the host
  declares (family list seat, else the official keyed settings.plugin.item
  seat) and log a refused registration instead of swallowing it (#1589)
- usage: sidebar entry + collapsible panel over the same overview
  document, with the family pairing fence on the host routes (#1592)
- usage: serve the zai route, key plan windows by the provider unit,
  use the exact credit ratio, and skip TIME_LIMIT/unknown units (#1597)
- plugin-manager + remote-web-ui: accumulate child output as bytes and
  decode once, so a Windows CP936 console renders readable text (#1600)
2026-09-17 00:58:01 +08:00

97 lines
3.6 KiB
TypeScript

/**
* Console-output capture shared by the family's Host halves.
*
* A child process's stdout/stderr are raw bytes, and on Windows a console
* program writes the OEM code page (CP936/GBK on a zh-CN system), not UTF-8.
* Decoding each chunk on its own corrupts the text twice over: the
* encoding is guessed wrong, and a multi-byte character split across two reads
* decodes into two replacement characters. This capture accumulates raw bytes
* under a byte budget and decodes exactly once, at read time.
*/
/** Bounded byte accumulator for one child process's interleaved stdout/stderr. */
export interface OutputCapture {
/** Append one raw chunk (the bytes are copied into the budget). */
push(chunk: Uint8Array): void
/**
* Decode everything captured so far. Idempotent, so a caller may read the
* tail both after a failure branch and at settlement.
*/
read(): string
}
/** Count the U+FFFD replacement characters one candidate decode produced. */
function replacementCount(text: string): number {
let count = 0
for (const character of text) {
if (character === '\uFFFD') count += 1
}
return count
}
/**
* Decode one console byte buffer. Strict UTF-8 wins whenever it is valid;
* otherwise the bytes are not UTF-8 at all and the console wrote its own code
* page, so the candidate that loses the least text is taken.
* @param bytes - the captured bytes.
* @returns the decoded text.
*/
export function decodeConsoleBytes(bytes: Uint8Array): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch {
// Not valid UTF-8. Prefer whichever candidate leaves the fewest
// replacement characters so real UTF-8 text carrying one bad byte is not
// rewritten wholesale as GBK gibberish; without the legacy code page
// (small-ICU builds) the lenient UTF-8 pass is all that is left.
let best: string | undefined
let bestLoss = Number.POSITIVE_INFINITY
for (const label of ['gbk', 'utf-8']) {
let text: string
try {
text = new TextDecoder(label).decode(bytes)
} catch {
continue
}
const loss = replacementCount(text)
if (loss < bestLoss) {
best = text
bestLoss = loss
}
}
/* v8 ignore next 3 -- reachable only without the legacy code page AND without the lenient pass */
if (best === undefined) return Buffer.from(bytes).toString('utf8')
return best
}
}
/**
* Create a capture bounded to a byte budget. Overflow drops from the FRONT
* (the tail is what carries a failure reason), and the cut never lands inside
* a character: leading UTF-8 continuation bytes are dropped with it, and a
* legacy code page's dangling lead byte — indistinguishable from a real byte
* without knowing the encoding until the whole buffer is decoded — is removed
* with the one replacement character it produced, so an over-long stream still
* reads cleanly at the seam.
* @param maxBytes - maximum retained bytes.
* @returns the capture.
*/
export function createOutputCapture(maxBytes: number): OutputCapture {
let buffer = Buffer.alloc(0)
let trimmed = false
return {
push(chunk: Uint8Array): void {
buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, Buffer.from(chunk)])
if (buffer.length <= maxBytes) return
let start = buffer.length - maxBytes
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1
buffer = Buffer.from(buffer.subarray(start))
trimmed = true
},
read(): string {
const text = decodeConsoleBytes(buffer)
return trimmed && text.startsWith('\uFFFD') ? text.slice(1) : text
},
}
}