diff --git a/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts index 72df349e..ed909b10 100644 --- a/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts @@ -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); diff --git a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/decodeInstallerProcessOutput.test.ts b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/decodeInstallerProcessOutput.test.ts index 60f310c4..288061c1 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/decodeInstallerProcessOutput.test.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/decodeInstallerProcessOutput.test.ts @@ -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( + 'Требуемая операция выполнена успешно' + ); + }); }); diff --git a/src/features/tmux-installer/main/infrastructure/runtime/decodeInstallerProcessOutput.ts b/src/features/tmux-installer/main/infrastructure/runtime/decodeInstallerProcessOutput.ts index 18bf7faa..95455a2f 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/decodeInstallerProcessOutput.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/decodeInstallerProcessOutput.ts @@ -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 { diff --git a/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts b/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts index c5f7734e..46bc8f8f 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts @@ -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 : ''), }); } ); diff --git a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts index 72adbc1b..56c3019d 100644 --- a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts +++ b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts @@ -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(); + } + }); }); diff --git a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts index e927442e..32f11c61 100644 --- a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts +++ b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts @@ -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 = diff --git a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts index d4be4a04..c74d4758 100644 --- a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts +++ b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts @@ -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(); diff --git a/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx b/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx index 9f1e9c21..8f6cc321 100644 --- a/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx +++ b/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx @@ -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((resolve) => { + resolveStatus = resolve; + }) + ); + mockApi.tmux.getInstallerSnapshot.mockResolvedValueOnce(idleSnapshot).mockImplementationOnce( + () => + new Promise((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((resolve) => { + resolveStatus = resolve; + }) + ); + mockApi.tmux.getInstallerSnapshot.mockResolvedValueOnce(idleSnapshot).mockImplementationOnce( + () => + new Promise((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')); diff --git a/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts b/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts index fb312fce..20ab51f4 100644 --- a/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts +++ b/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts @@ -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; install: () => Promise; @@ -36,32 +44,50 @@ export function useTmuxInstallerBanner(): { const [loading, setLoading] = useState(electronMode); const [error, setError] = useState(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]);