fix(tmux): tighten windows reboot flow

This commit is contained in:
777genius 2026-04-14 21:58:55 +03:00
parent 80221884ed
commit 898a795182
3 changed files with 140 additions and 11 deletions

View file

@ -190,10 +190,7 @@ export class TmuxInstallerRunnerAdapter
let status = currentStatus;
if (!status.wsl?.wslInstalled) {
status = await this.#installWindowsWslCore();
if (!status.wsl?.wslInstalled) {
return;
}
status = await this.#installWindowsWslCore(status);
}
if (status.wsl?.rebootRequired) {
@ -213,6 +210,10 @@ export class TmuxInstallerRunnerAdapter
return;
}
if (!status.wsl?.wslInstalled) {
return;
}
if (!status.wsl?.distroName) {
status = await this.#installWindowsDistro();
if (!status.wsl?.distroName) {
@ -277,7 +278,7 @@ export class TmuxInstallerRunnerAdapter
}
}
async #installWindowsWslCore(): Promise<TmuxStatus> {
async #installWindowsWslCore(currentStatus: TmuxStatus): Promise<TmuxStatus> {
this.#appendLog('Starting the elevated WSL core install step...');
this.#setSnapshot({
phase: 'pending_external_elevation',
@ -297,6 +298,28 @@ export class TmuxInstallerRunnerAdapter
this.#appendLog(elevationResult.detail);
}
const immediateRebootRequired =
elevationResult.restartRequired || this.#looksLikeRestartRequired(elevationResult.detail);
if (immediateRebootRequired) {
const rebootStatus = this.#markWindowsStatusAsRebootRequired(currentStatus, elevationResult.detail);
this.#statusSource.invalidateStatus();
this.#setSnapshot({
phase: 'needs_restart',
strategy: 'wsl',
message: 'Restart Windows before continuing with tmux setup',
detail:
elevationResult.detail ??
rebootStatus.wsl?.statusDetail ??
'WSL was installed, but Windows still needs a restart before tmux setup can continue.',
error: null,
canCancel: false,
acceptsInput: false,
inputPrompt: null,
inputSecret: false,
});
return rebootStatus;
}
this.#setSnapshot({
phase: 'waiting_for_external_step',
strategy: 'wsl',
@ -485,6 +508,34 @@ export class TmuxInstallerRunnerAdapter
return RESTART_REQUIRED_PATTERNS.some((pattern) => lowered.includes(pattern));
}
#markWindowsStatusAsRebootRequired(
status: TmuxStatus,
detail: string | null | undefined
): TmuxStatus {
return {
...status,
autoInstall: {
...status.autoInstall,
requiresRestart: true,
},
wsl: {
wslInstalled: status.wsl?.wslInstalled ?? false,
rebootRequired: true,
distroName: status.wsl?.distroName ?? null,
distroVersion: status.wsl?.distroVersion ?? null,
distroBootstrapped: status.wsl?.distroBootstrapped ?? false,
innerPackageManager: status.wsl?.innerPackageManager ?? null,
tmuxAvailableInsideWsl: status.wsl?.tmuxAvailableInsideWsl ?? false,
tmuxVersion: status.wsl?.tmuxVersion ?? null,
tmuxBinaryPath: status.wsl?.tmuxBinaryPath ?? null,
statusDetail:
detail ??
status.wsl?.statusDetail ??
'Windows still needs a restart before tmux setup can continue.',
},
};
}
async #runResolvedPlan(plan: TmuxInstallPlan, resetLogs = true): Promise<void> {
this.#setSnapshot({
phase: 'preparing',

View file

@ -559,8 +559,80 @@ describe('TmuxInstallerRunnerAdapter', () => {
await expect(runner.install()).resolves.toBeUndefined();
expect(statusCallCount).toBe(1);
expect(runner.getSnapshot().phase).toBe('needs_restart');
expect(runner.getSnapshot().message).toContain('Restart Windows');
expect(runner.getSnapshot().detail).toContain('WSL core installation command completed');
});
it('switches immediately into needs_restart when the elevated step only returns a localized restart message', async () => {
const presenter = createPresenter();
const initialStatus = createBaseStatus({
platform: 'win32',
autoInstall: {
supported: true,
strategy: 'wsl',
packageManagerLabel: 'WSL',
requiresTerminalInput: false,
requiresAdmin: true,
requiresRestart: true,
mayOpenExternalWindow: true,
reasonIfUnsupported: null,
manualHints: [],
},
wsl: {
wslInstalled: false,
rebootRequired: false,
distroName: null,
distroVersion: null,
distroBootstrapped: false,
innerPackageManager: null,
tmuxAvailableInsideWsl: false,
tmuxVersion: null,
tmuxBinaryPath: null,
statusDetail: 'WSL is not installed yet.',
},
});
const statusSource = {
getStatus: vi.fn(async () => initialStatus),
invalidateStatus: vi.fn(),
};
const runner = new TmuxInstallerRunnerAdapter(
statusSource as never,
presenter as never,
{
resolve: vi.fn(async () => {
throw new Error('resolve() should not run before restart');
}),
} as never,
{
run: vi.fn(),
cancel: vi.fn(),
} as never,
{
run: vi.fn(),
writeLine: vi.fn(),
cancel: vi.fn(),
} as never,
{
persistPreferredDistro: vi.fn(async () => undefined),
} as never,
{
runWslCoreInstall: vi.fn(async () => ({
outcome: 'elevated_unknown_outcome',
detail:
'Требуемая операция выполнена успешно. Чтобы сделанные изменения вступили в силу, следует перезагрузить систему.',
restartRequired: false,
featureStates: [],
resultFilePath: null,
})),
} as never
);
await expect(runner.install()).resolves.toBeUndefined();
expect(statusSource.getStatus).toHaveBeenCalledTimes(1);
expect(runner.getSnapshot().phase).toBe('needs_restart');
expect(runner.getSnapshot().detail).toContain('перезагрузить систему');
});
});

View file

@ -13,6 +13,7 @@ import {
import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner';
const SUMMARY_TITLE = 'tmux is not installed';
const BANNER_MIN_H = 'min-h-[4.25rem]';
const SourceLink = ({
label,
@ -78,7 +79,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
return (
<div
className="mb-6 rounded-lg border-l-4 px-4 py-4"
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
style={{
borderLeftColor: viewModel.error ? '#ef4444' : '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.08)',
@ -89,17 +90,22 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
className="flex w-full items-center justify-between gap-3 text-left"
className="flex min-h-[1.75rem] w-full items-center justify-between gap-3 text-left"
>
<span className="flex min-w-0 items-start gap-2 text-base font-semibold leading-6">
<span className="pt-0.5">
<span className="flex min-w-0 items-center gap-3">
<span className="shrink-0">
{viewModel.error ? (
<AlertTriangle className="size-4 text-red-300" />
) : (
<Wrench className="size-4 text-amber-300" />
)}
</span>
<span className="truncate">{SUMMARY_TITLE}</span>
<span
className="truncate text-sm leading-5"
style={{ color: 'var(--color-text)' }}
>
{SUMMARY_TITLE}
</span>
</span>
{expanded ? (
<ChevronUp className="size-4 shrink-0" />
@ -109,7 +115,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
</button>
{expanded && (
<div className="mt-4 space-y-3">
<div className="mt-3 space-y-3">
<div className="min-w-0 max-w-4xl">
{viewModel.title !== SUMMARY_TITLE && (
<div