fix(tmux): tighten windows installer flow

This commit is contained in:
777genius 2026-04-14 21:02:24 +03:00
parent 65da1b8429
commit 1062fe3b65
11 changed files with 401 additions and 63 deletions

View file

@ -15,6 +15,8 @@ import type { TmuxInstallPlan } from '@features/tmux-installer/main/infrastructu
const MAX_LOG_LINES = 400;
const RETRY_WITH_UPDATE_PATTERNS = ['unable to locate package', 'failed to fetch'];
const RECOMMENDED_WSL_DISTRO_NAME = 'Ubuntu';
const WINDOWS_DISTRO_APPEAR_RETRY_DELAY_MS = 2_000;
const WINDOWS_DISTRO_APPEAR_RETRY_ATTEMPTS = 6;
class TmuxInstallCancelledError extends Error {
constructor() {
@ -33,6 +35,7 @@ export class TmuxInstallerRunnerAdapter
readonly #wslService: TmuxWslService;
readonly #windowsElevatedStepRunner: WindowsElevatedStepRunner;
readonly #presenter: TmuxInstallerProgressPresenter;
readonly #sleep: (ms: number) => Promise<void>;
#cancelRequested = false;
#snapshot: TmuxInstallerSnapshot = {
phase: 'idle',
@ -55,7 +58,8 @@ export class TmuxInstallerRunnerAdapter
commandRunner = new TmuxCommandRunner(),
terminalSession = new TmuxInstallTerminalSession(),
wslService = new TmuxWslService(),
windowsElevatedStepRunner = new WindowsElevatedStepRunner()
windowsElevatedStepRunner = new WindowsElevatedStepRunner(),
sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
) {
this.#statusSource = statusSource;
this.#presenter = presenter;
@ -64,6 +68,7 @@ export class TmuxInstallerRunnerAdapter
this.#terminalSession = terminalSession;
this.#wslService = wslService;
this.#windowsElevatedStepRunner = windowsElevatedStepRunner;
this.#sleep = sleep;
}
getSnapshot(): TmuxInstallerSnapshot {
@ -412,15 +417,15 @@ export class TmuxInstallerRunnerAdapter
inputPrompt: null,
inputSecret: false,
});
const status = await this.#refreshStatus();
if (!status.wsl?.distroName) {
const status = await this.#waitForWindowsDistroStatus();
if (status.wsl?.rebootRequired) {
this.#setSnapshot({
phase: 'needs_manual_step',
phase: 'needs_restart',
strategy: 'wsl',
message: 'WSL distro install still needs a manual step',
message: 'Restart Windows before continuing with tmux setup',
detail:
status.wsl?.statusDetail ??
'The app could not confirm that a WSL distro is ready yet. Finish the distro install manually, then re-check.',
status.wsl.statusDetail ??
'Windows still needs a restart before the installed WSL distro can be finalized.',
error: null,
canCancel: false,
acceptsInput: false,
@ -429,6 +434,33 @@ export class TmuxInstallerRunnerAdapter
});
return status;
}
if (!status.wsl?.distroName) {
this.#setSnapshot({
phase: 'waiting_for_external_step',
strategy: 'wsl',
message: 'Finish Ubuntu setup in WSL',
detail:
'Ubuntu installation was started, but Windows has not exposed the distro to the app yet. Wait a moment, then click Re-check. If Ubuntu appears in the Start menu, open it once and complete the first Linux user setup.',
error: null,
canCancel: false,
acceptsInput: false,
inputPrompt: null,
inputSecret: false,
});
return status;
}
return status;
}
async #waitForWindowsDistroStatus(): Promise<TmuxStatus> {
let status = await this.#refreshStatus();
for (let attempt = 0; attempt < WINDOWS_DISTRO_APPEAR_RETRY_ATTEMPTS; attempt += 1) {
if (status.wsl?.distroName || status.wsl?.rebootRequired) {
return status;
}
await this.#sleep(WINDOWS_DISTRO_APPEAR_RETRY_DELAY_MS);
status = await this.#refreshStatus();
}
return status;
}

View file

@ -366,4 +366,105 @@ describe('TmuxInstallerRunnerAdapter', () => {
expect(runner.getSnapshot().phase).toBe('waiting_for_external_step');
expect(runner.getSnapshot().message).toContain('Ubuntu');
});
it('keeps Windows distro install in an external-step state when Ubuntu is still being provisioned', async () => {
const presenter = createPresenter();
const initialStatus = createBaseStatus({
platform: 'win32',
nativeSupported: false,
autoInstall: {
supported: true,
strategy: 'wsl',
packageManagerLabel: 'WSL',
requiresTerminalInput: false,
requiresAdmin: false,
requiresRestart: false,
mayOpenExternalWindow: true,
reasonIfUnsupported: null,
manualHints: [],
},
wsl: {
wslInstalled: true,
rebootRequired: false,
distroName: null,
distroVersion: null,
distroBootstrapped: false,
innerPackageManager: null,
tmuxAvailableInsideWsl: false,
tmuxVersion: null,
tmuxBinaryPath: null,
statusDetail: 'No distro is configured yet.',
},
wslPreference: null,
});
const pendingStatus = createBaseStatus({
platform: 'win32',
nativeSupported: false,
autoInstall: initialStatus.autoInstall,
effective: {
available: false,
location: null,
version: null,
binaryPath: null,
runtimeReady: false,
detail: 'WSL is available, but no Linux distribution is installed yet.',
},
wsl: {
wslInstalled: true,
rebootRequired: false,
distroName: null,
distroVersion: null,
distroBootstrapped: false,
innerPackageManager: null,
tmuxAvailableInsideWsl: false,
tmuxVersion: null,
tmuxBinaryPath: null,
statusDetail: 'WSL is available, but no Linux distribution is installed yet.',
},
wslPreference: {
preferredDistroName: 'Ubuntu',
source: 'persisted',
},
});
let statusCallCount = 0;
const statusSource = {
getStatus: vi.fn(async () => {
statusCallCount += 1;
return statusCallCount === 1 ? initialStatus : pendingStatus;
}),
invalidateStatus: vi.fn(),
};
const runner = new TmuxInstallerRunnerAdapter(
statusSource as never,
presenter as never,
{
resolve: vi.fn(async () => {
throw new Error('resolve() should not run while distro is still provisioning');
}),
} as never,
{
run: vi.fn(async () => ({ exitCode: 0 })),
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(),
} as never,
async () => undefined
);
await expect(runner.install()).resolves.toBeUndefined();
expect(statusCallCount).toBeGreaterThan(2);
expect(runner.getSnapshot().phase).toBe('waiting_for_external_step');
expect(runner.getSnapshot().message).toContain('Finish Ubuntu setup');
expect(runner.getSnapshot().detail).toContain('Wait a moment, then click Re-check');
});
});

View file

@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
import { killProcessTree } from '@main/utils/childProcess';
import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput';
import type { ChildProcessByStdio } from 'node:child_process';
import type { Readable } from 'node:stream';
@ -45,7 +47,8 @@ export class TmuxCommandRunner {
return {
push: (chunk: string): void => {
pending += chunk;
const lines = pending.split(/\r?\n/);
const normalizedPending = pending.replace(/\r(?!\n)/g, '\n');
const lines = normalizedPending.split('\n');
pending = lines.pop() ?? '';
for (const line of lines) {
emitLine(line);
@ -64,8 +67,12 @@ export class TmuxCommandRunner {
const stdoutWriter = createBufferedLineWriter();
const stderrWriter = createBufferedLineWriter();
child.stdout.on('data', (chunk: Buffer | string) => stdoutWriter.push(String(chunk)));
child.stderr.on('data', (chunk: Buffer | string) => stderrWriter.push(String(chunk)));
child.stdout.on('data', (chunk: Buffer | string) =>
stdoutWriter.push(decodeInstallerProcessOutput(chunk))
);
child.stderr.on('data', (chunk: Buffer | string) =>
stderrWriter.push(decodeInstallerProcessOutput(chunk))
);
child.on('error', (error) => {
this.#activeChild = null;
reject(error);

View file

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { decodeInstallerProcessOutput } from '../decodeInstallerProcessOutput';
describe('decodeInstallerProcessOutput', () => {
it('decodes cp866 Windows console output with Cyrillic text', () => {
const buffer = Buffer.from([0x8f, 0xe0, 0xa8, 0xa2, 0xa5, 0xe2, 0x20, 0x8c, 0xa8, 0xe0]);
expect(decodeInstallerProcessOutput(buffer, 'win32')).toBe('Привет Мир');
});
it('keeps utf8 output readable on non-Windows platforms', () => {
const buffer = Buffer.from('tmux is available\n', 'utf8');
expect(decodeInstallerProcessOutput(buffer, 'darwin')).toBe('tmux is available\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');
});
});

View file

@ -0,0 +1,100 @@
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');
export function decodeInstallerProcessOutput(
output: string | Buffer,
platform: NodeJS.Platform = process.platform
): string {
if (typeof output === 'string') {
return stripNulls(output);
}
if (output.length === 0) {
return '';
}
if (hasUtf16LeBom(output) || looksLikeUtf16Le(output)) {
return stripNulls(UTF16LE_DECODER.decode(output));
}
const utf8 = stripNulls(UTF8_DECODER.decode(output));
if (platform !== 'win32') {
return utf8;
}
const candidates = [
utf8,
stripNulls(IBM866_DECODER.decode(output)),
stripNulls(WINDOWS_1251_DECODER.decode(output)),
];
return candidates.slice(1).reduce(
(best, candidate) => (scoreDecodedText(candidate) > scoreDecodedText(best) ? candidate : best),
candidates[0] ?? utf8
);
}
function stripNulls(value: string): string {
return value.replace(/\0/g, '');
}
function hasUtf16LeBom(buffer: Buffer): boolean {
return buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe;
}
function looksLikeUtf16Le(buffer: Buffer): boolean {
const sampleSize = Math.min(buffer.length, 512);
if (sampleSize < 2) {
return false;
}
let pairs = 0;
let nullsAtOddIndex = 0;
for (let i = 0; i + 1 < sampleSize; i += 2) {
pairs += 1;
if (buffer[i + 1] === 0) {
nullsAtOddIndex += 1;
}
}
return pairs > 0 && nullsAtOddIndex / pairs >= 0.3;
}
function scoreDecodedText(value: string): number {
let score = 0;
for (const char of value) {
const codePoint = char.codePointAt(0) ?? 0;
if (char === '\uFFFD') {
score -= 25;
continue;
}
if (char === '\n' || char === '\r' || char === '\t') {
score += 0.5;
continue;
}
if (codePoint >= 0x20 && codePoint <= 0x7e) {
score += 1;
continue;
}
if (
(codePoint >= 0x0400 && codePoint <= 0x04ff) ||
(codePoint >= 0x0500 && codePoint <= 0x052f)
) {
score += 4;
continue;
}
if (codePoint >= 0x2500 && codePoint <= 0x257f) {
score += 0.4;
continue;
}
if (codePoint < 0x20) {
score -= 5;
continue;
}
score += 0.1;
}
return score;
}

View file

@ -1,6 +1,8 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput';
import { TmuxWslPreferenceStore } from './TmuxWslPreferenceStore';
import type {
@ -373,37 +375,7 @@ export class TmuxWslService {
}
#decodeOutput(output: string | Buffer): string {
if (typeof output === 'string') {
return output.replace(/\0/g, '');
}
if (output.length === 0) {
return '';
}
const hasUtf16LeBom = output.length >= 2 && output[0] === 0xff && output[1] === 0xfe;
const decoded =
hasUtf16LeBom || this.#looksLikeUtf16Le(output)
? output.toString('utf16le')
: output.toString('utf8');
return decoded.replace(/\0/g, '');
}
#looksLikeUtf16Le(buffer: Buffer): boolean {
const sampleSize = Math.min(buffer.length, 512);
if (sampleSize < 2) {
return false;
}
let pairs = 0;
let nullsAtOddIndex = 0;
for (let i = 0; i + 1 < sampleSize; i += 2) {
pairs += 1;
if (buffer[i + 1] === 0) {
nullsAtOddIndex += 1;
}
}
return pairs > 0 && nullsAtOddIndex / pairs >= 0.3;
return decodeInstallerProcessOutput(output);
}
#parseWslDistros(stdout: string): string[] {

View file

@ -3,6 +3,7 @@ import {
formatTmuxInstallerProgress,
formatTmuxInstallerTitle,
formatTmuxLocationLabel,
formatTmuxOptionalBenefits,
formatTmuxPlatformLabel,
} from '@features/tmux-installer/renderer/utils/formatTmuxInstallerText';
@ -17,6 +18,7 @@ export interface TmuxInstallerBannerViewModel {
loading: boolean;
title: string;
body: string;
benefitsBody: string | null;
error: string | null;
platformLabel: string | null;
locationLabel: string | null;
@ -31,6 +33,8 @@ export interface TmuxInstallerBannerViewModel {
installSupported: boolean;
installDisabled: boolean;
installLabel: string;
installButtonPrimary: boolean;
showRefreshButton: boolean;
canCancel: boolean;
acceptsInput: boolean;
inputPrompt: string | null;
@ -60,6 +64,12 @@ export class TmuxInstallerBannerAdapter {
const title =
snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady
? 'tmux needs one more step'
: snapshot.message &&
(snapshot.phase === 'pending_external_elevation' ||
snapshot.phase === 'waiting_for_external_step' ||
snapshot.phase === 'needs_restart' ||
snapshot.phase === 'needs_manual_step')
? snapshot.message
: formatTmuxInstallerTitle(snapshot.phase);
const primaryGuideUrl =
status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null;
@ -71,6 +81,8 @@ export class TmuxInstallerBannerAdapter {
status?.effective.detail ??
status?.wsl?.statusDetail ??
'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.';
const benefitsBody =
status && !status.effective.runtimeReady ? formatTmuxOptionalBenefits(status.platform) : null;
const runtimeReadyLabel = status
? status.effective.runtimeReady
? 'Ready for persistent teammates'
@ -93,12 +105,27 @@ export class TmuxInstallerBannerAdapter {
? 'Install Ubuntu in WSL'
: 'Install tmux in WSL'
: formatInstallButtonLabel(snapshot.phase);
const installDisabled =
input.loading ||
snapshot.phase === 'preparing' ||
snapshot.phase === 'checking' ||
snapshot.phase === 'requesting_privileges' ||
snapshot.phase === 'pending_external_elevation' ||
snapshot.phase === 'waiting_for_external_step' ||
snapshot.phase === 'installing' ||
snapshot.phase === 'verifying';
const installButtonPrimary =
!installDisabled && (installLabel.startsWith('Install') || installLabel.startsWith('Retry'));
const showRefreshButton =
!(status?.autoInstall.supported ?? false) ||
(installLabel !== 'Re-check' && installLabel !== 'Re-check after restart');
return {
visible,
loading: input.loading,
title,
body,
benefitsBody,
error: input.error ?? snapshot.error ?? status?.error ?? null,
platformLabel: formatTmuxPlatformLabel(status?.platform ?? null),
locationLabel: formatTmuxLocationLabel(status?.effective.location ?? null),
@ -111,16 +138,10 @@ export class TmuxInstallerBannerAdapter {
manualHintsCollapsible,
primaryGuideUrl,
installSupported: status?.autoInstall.supported ?? false,
installDisabled:
input.loading ||
snapshot.phase === 'preparing' ||
snapshot.phase === 'checking' ||
snapshot.phase === 'requesting_privileges' ||
snapshot.phase === 'pending_external_elevation' ||
snapshot.phase === 'waiting_for_external_step' ||
snapshot.phase === 'installing' ||
snapshot.phase === 'verifying',
installDisabled,
installLabel,
installButtonPrimary,
showRefreshButton,
canCancel: snapshot.canCancel,
acceptsInput: snapshot.acceptsInput,
inputPrompt: snapshot.inputPrompt,

View file

@ -73,6 +73,9 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.manualHints).toHaveLength(1);
expect(result.manualHintsCollapsible).toBe(false);
expect(result.body).toContain('persistent teammate reliability');
expect(result.benefitsBody).toContain('Optional, but recommended');
expect(result.installButtonPrimary).toBe(true);
expect(result.showRefreshButton).toBe(true);
});
it('prioritizes renderer errors and disables the install button while installing', () => {
@ -98,12 +101,14 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.title).toBe('Installing tmux');
expect(result.body).toBe('Renderer bridge failed');
expect(result.benefitsBody).toContain('Optional, but recommended');
expect(result.error).toBe('Renderer bridge failed');
expect(result.installDisabled).toBe(true);
expect(result.canCancel).toBe(true);
expect(result.acceptsInput).toBe(false);
expect(result.progressPercent).toBe(68);
expect(result.logs).toEqual(['Downloading bottle...']);
expect(result.installButtonPrimary).toBe(false);
});
it('exposes a manual guide url when auto install is unavailable', () => {
@ -143,8 +148,10 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.platformLabel).toBe('Windows');
expect(result.primaryGuideUrl).toBe('https://learn.microsoft.com/en-us/windows/wsl/install');
expect(result.progressPercent).toBe(100);
expect(result.progressPercent).toBe(82);
expect(result.manualHintsCollapsible).toBe(true);
expect(result.benefitsBody).toContain('With tmux in WSL');
expect(result.showRefreshButton).toBe(true);
});
it('keeps the banner visible when tmux is installed but runtime is not ready yet', () => {
@ -174,6 +181,7 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(result.locationLabel).toBe('Host runtime');
expect(result.runtimeReadyLabel).toBe('Installed, but not active yet');
expect(result.versionLabel).toBe('tmux 3.4');
expect(result.benefitsBody).toContain('With tmux in WSL');
});
it('exposes installer input metadata for interactive privilege flows', () => {
@ -259,5 +267,39 @@ describe('TmuxInstallerBannerAdapter', () => {
expect(installWslResult.installLabel).toBe('Install WSL');
expect(installUbuntuResult.installLabel).toBe('Install Ubuntu in WSL');
expect(installWslResult.installButtonPrimary).toBe(true);
expect(installUbuntuResult.installButtonPrimary).toBe(true);
});
it('uses a specific Windows external-step message as the title', () => {
const adapter = TmuxInstallerBannerAdapter.create();
const result = adapter.adapt({
status: {
...baseStatus,
platform: 'win32',
autoInstall: {
...baseStatus.autoInstall,
supported: true,
strategy: 'wsl',
},
},
snapshot: {
...idleSnapshot,
phase: 'waiting_for_external_step',
strategy: 'wsl',
message: 'Finish Ubuntu setup in WSL',
detail:
'Ubuntu installation was started, but Windows has not exposed the distro to the app yet.',
},
loading: false,
error: null,
detailsOpen: false,
});
expect(result.title).toBe('Finish Ubuntu setup in WSL');
expect(result.progressPercent).toBe(48);
expect(result.installDisabled).toBe(true);
expect(result.showRefreshButton).toBe(true);
});
});

View file

@ -84,6 +84,18 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
>
{viewModel.body}
</p>
{viewModel.benefitsBody && (
<div
className="mt-3 max-w-4xl rounded-md border px-3 py-2 text-[13px] leading-6"
style={{
borderColor: 'rgba(245, 158, 11, 0.18)',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
color: 'var(--color-text-secondary)',
}}
>
{viewModel.benefitsBody}
</div>
)}
{(viewModel.platformLabel ||
viewModel.locationLabel ||
viewModel.runtimeReadyLabel ||
@ -155,8 +167,18 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
type="button"
onClick={() => void install()}
disabled={viewModel.installDisabled}
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-60"
style={{ borderColor: 'var(--color-border)' }}
className={`inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
viewModel.installButtonPrimary ? 'hover:bg-emerald-500/20' : 'hover:bg-white/5'
}`}
style={
viewModel.installButtonPrimary
? {
borderColor: 'rgba(34, 197, 94, 0.75)',
backgroundColor: 'rgba(34, 197, 94, 0.16)',
color: '#dcfce7',
}
: { borderColor: 'var(--color-border)' }
}
>
<Wrench className="size-4" />
{viewModel.installLabel}
@ -201,15 +223,17 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
: `Show setup steps (${viewModel.manualHints.length})`}
</button>
)}
<button
type="button"
onClick={() => void refresh()}
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)' }}
>
<RefreshCw className="size-4" />
Re-check
</button>
{viewModel.showRefreshButton && (
<button
type="button"
onClick={() => void refresh()}
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)' }}
>
<RefreshCw className="size-4" />
Re-check
</button>
)}
</div>
</div>

View file

@ -20,6 +20,8 @@ const baseViewModel: TmuxInstallerBannerViewModel = {
loading: false,
title: 'tmux is not installed',
body: 'WSL is available, but no Linux distribution is installed yet.',
benefitsBody:
'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable.',
error: null,
platformLabel: 'Windows',
locationLabel: null,
@ -45,6 +47,8 @@ const baseViewModel: TmuxInstallerBannerViewModel = {
installSupported: true,
installDisabled: false,
installLabel: 'Install Ubuntu in WSL',
installButtonPrimary: true,
showRefreshButton: true,
canCancel: false,
acceptsInput: false,
inputPrompt: null,

View file

@ -42,7 +42,7 @@ export function formatTmuxInstallerProgress(phase: TmuxInstallerPhase): number |
if (phase === 'verifying') return 90;
if (phase === 'needs_restart') return 96;
if (phase === 'completed') return 100;
if (phase === 'needs_manual_step') return 100;
if (phase === 'needs_manual_step') return 82;
if (phase === 'error') return 100;
if (phase === 'cancelled') return 0;
return null;
@ -61,3 +61,15 @@ export function formatTmuxLocationLabel(location: 'host' | 'wsl' | null): string
if (location === 'wsl') return 'WSL runtime';
return null;
}
export function formatTmuxOptionalBenefits(platform: TmuxPlatform | null): string | null {
if (!platform) {
return null;
}
if (platform === 'win32') {
return 'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better.';
}
return 'Optional, but recommended. The app works without tmux. With tmux, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better.';
}