fix(tmux): harden windows installer banner flow

This commit is contained in:
777genius 2026-04-14 21:28:08 +03:00
parent 1062fe3b65
commit 44f4af1756
9 changed files with 336 additions and 47 deletions

View file

@ -33,9 +33,14 @@ export class TmuxCommandRunner {
stdio: ['ignore', 'pipe', 'pipe'],
});
this.#activeChild = child;
const platform = process.platform;
const createBufferedLineWriter = (): { push: (chunk: string) => void; flush: () => void } => {
const createBufferedLineWriter = (): {
push: (chunk: Buffer | string) => void;
flush: () => void;
} => {
let pending = '';
let pendingBytes = Buffer.alloc(0);
const emitLine = (line: string): void => {
const normalizedLine = line.replace(/\r$/, '');
@ -45,8 +50,24 @@ export class TmuxCommandRunner {
};
return {
push: (chunk: string): void => {
pending += chunk;
push: (chunk: Buffer | string): void => {
let decodedChunk = '';
if (typeof chunk === 'string') {
decodedChunk = chunk;
} else {
let nextBuffer =
pendingBytes.length > 0 ? Buffer.concat([pendingBytes, chunk]) : chunk;
if (platform === 'win32' && nextBuffer.length % 2 === 1) {
pendingBytes = nextBuffer.subarray(nextBuffer.length - 1);
nextBuffer = nextBuffer.subarray(0, nextBuffer.length - 1);
} else {
pendingBytes = Buffer.alloc(0);
}
if (nextBuffer.length > 0) {
decodedChunk = decodeInstallerProcessOutput(nextBuffer, platform);
}
}
pending += decodedChunk;
const normalizedPending = pending.replace(/\r(?!\n)/g, '\n');
const lines = normalizedPending.split('\n');
pending = lines.pop() ?? '';
@ -55,6 +76,10 @@ export class TmuxCommandRunner {
}
},
flush: (): void => {
if (pendingBytes.length > 0) {
pending += decodeInstallerProcessOutput(pendingBytes, platform);
pendingBytes = Buffer.alloc(0);
}
if (!pending) {
return;
}
@ -67,12 +92,8 @@ export class TmuxCommandRunner {
const stdoutWriter = createBufferedLineWriter();
const stderrWriter = createBufferedLineWriter();
child.stdout.on('data', (chunk: Buffer | string) =>
stdoutWriter.push(decodeInstallerProcessOutput(chunk))
);
child.stderr.on('data', (chunk: Buffer | string) =>
stderrWriter.push(decodeInstallerProcessOutput(chunk))
);
child.stdout.on('data', (chunk: Buffer | string) => stdoutWriter.push(chunk));
child.stderr.on('data', (chunk: Buffer | string) => stderrWriter.push(chunk));
child.on('error', (error) => {
this.#activeChild = null;
reject(error);

View file

@ -15,9 +15,38 @@ describe('decodeInstallerProcessOutput', () => {
expect(decodeInstallerProcessOutput(buffer, 'darwin')).toBe('tmux is available\n');
});
it('keeps utf8 Cyrillic output readable on non-Windows platforms', () => {
const buffer = Buffer.from('Привет мир\n', 'utf8');
expect(decodeInstallerProcessOutput(buffer, 'darwin')).toBe('Привет мир\n');
});
it('keeps utf8 output readable on Windows too', () => {
const buffer = Buffer.from('tmux is available\n', 'utf8');
expect(decodeInstallerProcessOutput(buffer, 'win32')).toBe('tmux is available\n');
});
it('keeps utf8 Cyrillic output readable on Windows too', () => {
const buffer = Buffer.from('Привет мир\n', 'utf8');
expect(decodeInstallerProcessOutput(buffer, 'win32')).toBe('Привет мир\n');
});
it('decodes utf16le output when it contains a BOM', () => {
const utf16le = Buffer.from('\uFEFFWSL core installation command completed.', 'utf16le');
expect(decodeInstallerProcessOutput(utf16le, 'win32')).toContain('WSL core installation');
});
it('decodes utf16le Cyrillic output without a BOM', () => {
const utf16le = Buffer.from(
'Требуемая операция выполнена успешно. Чтобы заданные изменения вступили в силу, следует перезагрузить систему.',
'utf16le'
);
expect(decodeInstallerProcessOutput(utf16le, 'win32')).toContain(
'Требуемая операция выполнена успешно'
);
});
});

View file

@ -2,6 +2,7 @@ const UTF8_DECODER = new TextDecoder('utf-8');
const UTF16LE_DECODER = new TextDecoder('utf-16le');
const IBM866_DECODER = new TextDecoder('ibm866');
const WINDOWS_1251_DECODER = new TextDecoder('windows-1251');
const TEXT_ENCODER = new TextEncoder();
export function decodeInstallerProcessOutput(
output: string | Buffer,
@ -14,25 +15,48 @@ export function decodeInstallerProcessOutput(
return '';
}
const utf16le = stripNulls(UTF16LE_DECODER.decode(output));
if (hasUtf16LeBom(output) || looksLikeUtf16Le(output)) {
return stripNulls(UTF16LE_DECODER.decode(output));
return utf16le;
}
const utf8 = stripNulls(UTF8_DECODER.decode(output));
if (platform !== 'win32') {
return utf8;
}
if (isExactUtf8RoundTrip(output, utf8)) {
return utf8;
}
const candidates = [
utf8,
stripNulls(IBM866_DECODER.decode(output)),
stripNulls(WINDOWS_1251_DECODER.decode(output)),
];
if (platform === 'win32') {
candidates.push(utf16le);
}
return candidates.slice(1).reduce(
(best, candidate) => (scoreDecodedText(candidate) > scoreDecodedText(best) ? candidate : best),
candidates[0] ?? utf8
);
return candidates
.slice(1)
.reduce(
(best, candidate) =>
scoreDecodedText(candidate) > scoreDecodedText(best) ? candidate : best,
candidates[0] ?? utf16le
);
}
function isExactUtf8RoundTrip(buffer: Buffer, decoded: string): boolean {
const encoded = TEXT_ENCODER.encode(decoded);
if (encoded.length !== buffer.length) {
return false;
}
for (let index = 0; index < encoded.length; index += 1) {
if (encoded[index] !== buffer[index]) {
return false;
}
}
return true;
}
function stripNulls(value: string): string {
@ -51,14 +75,24 @@ function looksLikeUtf16Le(buffer: Buffer): boolean {
let pairs = 0;
let nullsAtOddIndex = 0;
let likelyUtf16OddBytes = 0;
for (let i = 0; i + 1 < sampleSize; i += 2) {
pairs += 1;
if (buffer[i + 1] === 0) {
const oddByte = buffer[i + 1];
const evenByte = buffer[i];
if (oddByte === 0) {
nullsAtOddIndex += 1;
}
if (oddByte === 0x04 || oddByte === 0x05) {
likelyUtf16OddBytes += 1;
continue;
}
if (oddByte === 0x00 && evenByte >= 0x20 && evenByte <= 0x7e) {
likelyUtf16OddBytes += 1;
}
}
return pairs > 0 && nullsAtOddIndex / pairs >= 0.3;
return pairs > 0 && (nullsAtOddIndex / pairs >= 0.3 || likelyUtf16OddBytes / pairs >= 0.3);
}
function scoreDecodedText(value: string): number {

View file

@ -3,6 +3,8 @@ import * as fsp from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput';
import { createLogger } from '@shared/utils/logger';
const logger = createLogger('Feature:tmux-installer:windows-elevation');
@ -31,6 +33,7 @@ type ExecFileLike = (
timeout: number;
windowsHide: boolean;
maxBuffer: number;
encoding: 'buffer';
},
callback: ExecFileCallback
) => void;
@ -113,6 +116,7 @@ export class WindowsElevatedStepRunner {
timeout,
windowsHide: true,
maxBuffer: MAX_BUFFER_BYTES,
encoding: 'buffer',
},
(error, stdout, stderr) => {
const errorCode =
@ -121,8 +125,10 @@ export class WindowsElevatedStepRunner {
: undefined;
resolve({
exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0,
stdout: String(stdout),
stderr: String(stderr) || (error instanceof Error ? error.message : ''),
stdout: decodeInstallerProcessOutput(stdout, 'win32'),
stderr:
decodeInstallerProcessOutput(stderr, 'win32') ||
(error instanceof Error ? error.message : ''),
});
}
);

View file

@ -2,7 +2,7 @@ import * as fsp from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { WindowsElevatedStepRunner } from '../WindowsElevatedStepRunner';
@ -68,4 +68,30 @@ describe('WindowsElevatedStepRunner', () => {
expect(result.detail).toContain('cancelled');
expect(result.resultFilePath).toBeNull();
});
it('decodes localized Windows stderr from the launcher process', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const runner = new WindowsElevatedStepRunner(
(_command, _args, _options, callback) => {
callback(
Object.assign(new Error('restart required'), { code: 1 }),
Buffer.alloc(0),
Buffer.from(
'Требуемая операция выполнена успешно. Чтобы заданные изменения вступили в силу, следует перезагрузить систему.',
'utf16le'
)
);
},
(prefix) => fsp.mkdtemp(path.join(tmpdir(), prefix))
);
const result = await runner.runWslCoreInstall();
expect(result.outcome).toBe('elevated_unknown_outcome');
expect(result.detail).toContain('Требуемая операция выполнена успешно');
} finally {
consoleWarnSpy.mockRestore();
}
});
});

View file

@ -58,9 +58,9 @@ export class TmuxInstallerBannerAdapter {
adapt(input: AdaptInput): TmuxInstallerBannerViewModel {
const status = input.status;
const snapshot = input.snapshot;
const visible = input.loading
? false
: (status ? !status.effective.runtimeReady : true) || snapshot.phase !== 'idle';
const visible =
snapshot.phase !== 'idle' ||
(!input.loading && (status ? !status.effective.runtimeReady : true));
const title =
snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady
? 'tmux needs one more step'
@ -70,7 +70,7 @@ export class TmuxInstallerBannerAdapter {
snapshot.phase === 'needs_restart' ||
snapshot.phase === 'needs_manual_step')
? snapshot.message
: formatTmuxInstallerTitle(snapshot.phase);
: formatTmuxInstallerTitle(snapshot.phase);
const primaryGuideUrl =
status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null;
const body =

View file

@ -111,6 +111,26 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.installButtonPrimary).toBe(false);
});
it('keeps the banner visible while loading if installer progress is already active', () => {
const adapter = TmuxInstallerBannerAdapter.create();
const result = adapter.adapt({
status: null,
snapshot: {
...idleSnapshot,
phase: 'waiting_for_external_step',
strategy: 'wsl',
message: 'Finish Ubuntu setup in WSL',
},
loading: true,
error: null,
detailsOpen: false,
});
expect(result.visible).toBe(true);
expect(result.title).toBe('Finish Ubuntu setup in WSL');
});
it('exposes a manual guide url when auto install is unavailable', () => {
const adapter = TmuxInstallerBannerAdapter.create();

View file

@ -208,6 +208,129 @@ describe('useTmuxInstallerBanner', () => {
});
});
it('keeps the banner visible during background refreshes after installer progress updates', async () => {
let resolveStatus: ((value: TmuxStatus) => void) | null = null;
let resolveSnapshot: ((value: TmuxInstallerSnapshot) => void) | null = null;
mockApi.tmux.getStatus.mockResolvedValueOnce(baseStatus).mockImplementationOnce(
() =>
new Promise<TmuxStatus>((resolve) => {
resolveStatus = resolve;
})
);
mockApi.tmux.getInstallerSnapshot.mockResolvedValueOnce(idleSnapshot).mockImplementationOnce(
() =>
new Promise<TmuxInstallerSnapshot>((resolve) => {
resolveSnapshot = resolve;
})
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
await Promise.resolve();
});
expect(capturedHook?.viewModel.visible).toBe(true);
await act(async () => {
progressListener?.(null, {
...idleSnapshot,
phase: 'waiting_for_external_step',
message: 'Finish Ubuntu setup in WSL',
});
await Promise.resolve();
});
expect(capturedHook?.viewModel.visible).toBe(true);
expect(capturedHook?.viewModel.phase).toBe('waiting_for_external_step');
await act(async () => {
resolveStatus?.(baseStatus);
resolveSnapshot?.({
...idleSnapshot,
phase: 'waiting_for_external_step',
message: 'Finish Ubuntu setup in WSL',
});
await Promise.resolve();
await Promise.resolve();
});
expect(capturedHook?.viewModel.visible).toBe(true);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not let an older refreshed snapshot overwrite newer live progress', async () => {
let resolveStatus: ((value: TmuxStatus) => void) | null = null;
let resolveSnapshot: ((value: TmuxInstallerSnapshot) => void) | null = null;
const olderSnapshot = {
...idleSnapshot,
phase: 'idle' as const,
updatedAt: '2099-04-14T10:00:00.000Z',
};
const newerProgress = {
...idleSnapshot,
phase: 'waiting_for_external_step' as const,
message: 'Finish Ubuntu setup in WSL',
updatedAt: '2099-04-14T10:00:05.000Z',
};
mockApi.tmux.getStatus.mockResolvedValueOnce(baseStatus).mockImplementationOnce(
() =>
new Promise<TmuxStatus>((resolve) => {
resolveStatus = resolve;
})
);
mockApi.tmux.getInstallerSnapshot.mockResolvedValueOnce(idleSnapshot).mockImplementationOnce(
() =>
new Promise<TmuxInstallerSnapshot>((resolve) => {
resolveSnapshot = resolve;
})
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
progressListener?.(null, newerProgress);
await Promise.resolve();
});
expect(capturedHook?.viewModel.phase).toBe('waiting_for_external_step');
await act(async () => {
resolveStatus?.({
...baseStatus,
checkedAt: '2099-04-14T10:00:00.000Z',
});
resolveSnapshot?.(olderSnapshot);
await Promise.resolve();
await Promise.resolve();
});
expect(capturedHook?.viewModel.phase).toBe('waiting_for_external_step');
expect(capturedHook?.viewModel.title).toBe('Finish Ubuntu setup in WSL');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('stores action errors instead of letting rejected installer calls disappear', async () => {
mockApi.tmux.install.mockRejectedValueOnce(new Error('bridge failed'));

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
@ -20,6 +20,14 @@ const IDLE_SNAPSHOT: TmuxInstallerSnapshot = {
updatedAt: new Date(0).toISOString(),
};
function getIsoTimestamp(value: string | null | undefined): number {
if (!value) {
return 0;
}
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : 0;
}
export function useTmuxInstallerBanner(): {
viewModel: ReturnType<TmuxInstallerBannerAdapter['adapt']>;
install: () => Promise<void>;
@ -36,32 +44,50 @@ export function useTmuxInstallerBanner(): {
const [loading, setLoading] = useState(electronMode);
const [error, setError] = useState<string | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const hasLoadedRef = useRef(!electronMode);
const getErrorMessage = useCallback((value: unknown, fallback: string): string => {
return value instanceof Error ? value.message : fallback;
}, []);
const refresh = useCallback(async () => {
if (!electronMode) {
setLoading(false);
return;
}
const refresh = useCallback(
async (options?: { background?: boolean }) => {
if (!electronMode) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [nextStatus, nextSnapshot] = await Promise.all([
api.tmux.getStatus(),
api.tmux.getInstallerSnapshot(),
]);
setStatus(nextStatus);
setSnapshot(nextSnapshot);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Failed to load tmux state');
} finally {
setLoading(false);
}
}, [electronMode]);
const background = options?.background ?? hasLoadedRef.current;
if (!background) {
setLoading(true);
}
setError(null);
try {
const [nextStatus, nextSnapshot] = await Promise.all([
api.tmux.getStatus(),
api.tmux.getInstallerSnapshot(),
]);
setStatus((current) =>
getIsoTimestamp(nextStatus.checkedAt) >= getIsoTimestamp(current?.checkedAt)
? nextStatus
: current
);
setSnapshot((current) =>
getIsoTimestamp(nextSnapshot.updatedAt) >= getIsoTimestamp(current.updatedAt)
? nextSnapshot
: current
);
hasLoadedRef.current = true;
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'Failed to load tmux state');
} finally {
if (!background) {
setLoading(false);
}
}
},
[electronMode]
);
useEffect(() => {
if (!electronMode) {
@ -69,10 +95,14 @@ export function useTmuxInstallerBanner(): {
return;
}
void refresh();
void refresh({ background: false });
return api.tmux.onProgress((_event, progress) => {
setSnapshot(progress);
setSnapshot((current) =>
getIsoTimestamp(progress.updatedAt) >= getIsoTimestamp(current.updatedAt)
? progress
: current
);
if (
progress.phase === 'completed' ||
progress.phase === 'needs_manual_step' ||
@ -81,7 +111,7 @@ export function useTmuxInstallerBanner(): {
progress.phase === 'error' ||
progress.phase === 'cancelled'
) {
void refresh();
void refresh({ background: true });
}
});
}, [electronMode, refresh]);