fix(tmux): tighten windows installer flow
This commit is contained in:
parent
65da1b8429
commit
1062fe3b65
11 changed files with 401 additions and 63 deletions
|
|
@ -15,6 +15,8 @@ import type { TmuxInstallPlan } from '@features/tmux-installer/main/infrastructu
|
||||||
const MAX_LOG_LINES = 400;
|
const MAX_LOG_LINES = 400;
|
||||||
const RETRY_WITH_UPDATE_PATTERNS = ['unable to locate package', 'failed to fetch'];
|
const RETRY_WITH_UPDATE_PATTERNS = ['unable to locate package', 'failed to fetch'];
|
||||||
const RECOMMENDED_WSL_DISTRO_NAME = 'Ubuntu';
|
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 {
|
class TmuxInstallCancelledError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -33,6 +35,7 @@ export class TmuxInstallerRunnerAdapter
|
||||||
readonly #wslService: TmuxWslService;
|
readonly #wslService: TmuxWslService;
|
||||||
readonly #windowsElevatedStepRunner: WindowsElevatedStepRunner;
|
readonly #windowsElevatedStepRunner: WindowsElevatedStepRunner;
|
||||||
readonly #presenter: TmuxInstallerProgressPresenter;
|
readonly #presenter: TmuxInstallerProgressPresenter;
|
||||||
|
readonly #sleep: (ms: number) => Promise<void>;
|
||||||
#cancelRequested = false;
|
#cancelRequested = false;
|
||||||
#snapshot: TmuxInstallerSnapshot = {
|
#snapshot: TmuxInstallerSnapshot = {
|
||||||
phase: 'idle',
|
phase: 'idle',
|
||||||
|
|
@ -55,7 +58,8 @@ export class TmuxInstallerRunnerAdapter
|
||||||
commandRunner = new TmuxCommandRunner(),
|
commandRunner = new TmuxCommandRunner(),
|
||||||
terminalSession = new TmuxInstallTerminalSession(),
|
terminalSession = new TmuxInstallTerminalSession(),
|
||||||
wslService = new TmuxWslService(),
|
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.#statusSource = statusSource;
|
||||||
this.#presenter = presenter;
|
this.#presenter = presenter;
|
||||||
|
|
@ -64,6 +68,7 @@ export class TmuxInstallerRunnerAdapter
|
||||||
this.#terminalSession = terminalSession;
|
this.#terminalSession = terminalSession;
|
||||||
this.#wslService = wslService;
|
this.#wslService = wslService;
|
||||||
this.#windowsElevatedStepRunner = windowsElevatedStepRunner;
|
this.#windowsElevatedStepRunner = windowsElevatedStepRunner;
|
||||||
|
this.#sleep = sleep;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSnapshot(): TmuxInstallerSnapshot {
|
getSnapshot(): TmuxInstallerSnapshot {
|
||||||
|
|
@ -412,15 +417,15 @@ export class TmuxInstallerRunnerAdapter
|
||||||
inputPrompt: null,
|
inputPrompt: null,
|
||||||
inputSecret: false,
|
inputSecret: false,
|
||||||
});
|
});
|
||||||
const status = await this.#refreshStatus();
|
const status = await this.#waitForWindowsDistroStatus();
|
||||||
if (!status.wsl?.distroName) {
|
if (status.wsl?.rebootRequired) {
|
||||||
this.#setSnapshot({
|
this.#setSnapshot({
|
||||||
phase: 'needs_manual_step',
|
phase: 'needs_restart',
|
||||||
strategy: 'wsl',
|
strategy: 'wsl',
|
||||||
message: 'WSL distro install still needs a manual step',
|
message: 'Restart Windows before continuing with tmux setup',
|
||||||
detail:
|
detail:
|
||||||
status.wsl?.statusDetail ??
|
status.wsl.statusDetail ??
|
||||||
'The app could not confirm that a WSL distro is ready yet. Finish the distro install manually, then re-check.',
|
'Windows still needs a restart before the installed WSL distro can be finalized.',
|
||||||
error: null,
|
error: null,
|
||||||
canCancel: false,
|
canCancel: false,
|
||||||
acceptsInput: false,
|
acceptsInput: false,
|
||||||
|
|
@ -429,6 +434,33 @@ export class TmuxInstallerRunnerAdapter
|
||||||
});
|
});
|
||||||
return status;
|
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;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -366,4 +366,105 @@ describe('TmuxInstallerRunnerAdapter', () => {
|
||||||
expect(runner.getSnapshot().phase).toBe('waiting_for_external_step');
|
expect(runner.getSnapshot().phase).toBe('waiting_for_external_step');
|
||||||
expect(runner.getSnapshot().message).toContain('Ubuntu');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
import { killProcessTree } from '@main/utils/childProcess';
|
import { killProcessTree } from '@main/utils/childProcess';
|
||||||
|
|
||||||
|
import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput';
|
||||||
|
|
||||||
import type { ChildProcessByStdio } from 'node:child_process';
|
import type { ChildProcessByStdio } from 'node:child_process';
|
||||||
import type { Readable } from 'node:stream';
|
import type { Readable } from 'node:stream';
|
||||||
|
|
||||||
|
|
@ -45,7 +47,8 @@ export class TmuxCommandRunner {
|
||||||
return {
|
return {
|
||||||
push: (chunk: string): void => {
|
push: (chunk: string): void => {
|
||||||
pending += chunk;
|
pending += chunk;
|
||||||
const lines = pending.split(/\r?\n/);
|
const normalizedPending = pending.replace(/\r(?!\n)/g, '\n');
|
||||||
|
const lines = normalizedPending.split('\n');
|
||||||
pending = lines.pop() ?? '';
|
pending = lines.pop() ?? '';
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
emitLine(line);
|
emitLine(line);
|
||||||
|
|
@ -64,8 +67,12 @@ export class TmuxCommandRunner {
|
||||||
const stdoutWriter = createBufferedLineWriter();
|
const stdoutWriter = createBufferedLineWriter();
|
||||||
const stderrWriter = createBufferedLineWriter();
|
const stderrWriter = createBufferedLineWriter();
|
||||||
|
|
||||||
child.stdout.on('data', (chunk: Buffer | string) => stdoutWriter.push(String(chunk)));
|
child.stdout.on('data', (chunk: Buffer | string) =>
|
||||||
child.stderr.on('data', (chunk: Buffer | string) => stderrWriter.push(String(chunk)));
|
stdoutWriter.push(decodeInstallerProcessOutput(chunk))
|
||||||
|
);
|
||||||
|
child.stderr.on('data', (chunk: Buffer | string) =>
|
||||||
|
stderrWriter.push(decodeInstallerProcessOutput(chunk))
|
||||||
|
);
|
||||||
child.on('error', (error) => {
|
child.on('error', (error) => {
|
||||||
this.#activeChild = null;
|
this.#activeChild = null;
|
||||||
reject(error);
|
reject(error);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput';
|
||||||
|
|
||||||
import { TmuxWslPreferenceStore } from './TmuxWslPreferenceStore';
|
import { TmuxWslPreferenceStore } from './TmuxWslPreferenceStore';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -373,37 +375,7 @@ export class TmuxWslService {
|
||||||
}
|
}
|
||||||
|
|
||||||
#decodeOutput(output: string | Buffer): string {
|
#decodeOutput(output: string | Buffer): string {
|
||||||
if (typeof output === 'string') {
|
return decodeInstallerProcessOutput(output);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#parseWslDistros(stdout: string): string[] {
|
#parseWslDistros(stdout: string): string[] {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
formatTmuxInstallerProgress,
|
formatTmuxInstallerProgress,
|
||||||
formatTmuxInstallerTitle,
|
formatTmuxInstallerTitle,
|
||||||
formatTmuxLocationLabel,
|
formatTmuxLocationLabel,
|
||||||
|
formatTmuxOptionalBenefits,
|
||||||
formatTmuxPlatformLabel,
|
formatTmuxPlatformLabel,
|
||||||
} from '@features/tmux-installer/renderer/utils/formatTmuxInstallerText';
|
} from '@features/tmux-installer/renderer/utils/formatTmuxInstallerText';
|
||||||
|
|
||||||
|
|
@ -17,6 +18,7 @@ export interface TmuxInstallerBannerViewModel {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
benefitsBody: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
platformLabel: string | null;
|
platformLabel: string | null;
|
||||||
locationLabel: string | null;
|
locationLabel: string | null;
|
||||||
|
|
@ -31,6 +33,8 @@ export interface TmuxInstallerBannerViewModel {
|
||||||
installSupported: boolean;
|
installSupported: boolean;
|
||||||
installDisabled: boolean;
|
installDisabled: boolean;
|
||||||
installLabel: string;
|
installLabel: string;
|
||||||
|
installButtonPrimary: boolean;
|
||||||
|
showRefreshButton: boolean;
|
||||||
canCancel: boolean;
|
canCancel: boolean;
|
||||||
acceptsInput: boolean;
|
acceptsInput: boolean;
|
||||||
inputPrompt: string | null;
|
inputPrompt: string | null;
|
||||||
|
|
@ -60,6 +64,12 @@ export class TmuxInstallerBannerAdapter {
|
||||||
const title =
|
const title =
|
||||||
snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady
|
snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady
|
||||||
? 'tmux needs one more step'
|
? '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);
|
: formatTmuxInstallerTitle(snapshot.phase);
|
||||||
const primaryGuideUrl =
|
const primaryGuideUrl =
|
||||||
status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null;
|
status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null;
|
||||||
|
|
@ -71,6 +81,8 @@ export class TmuxInstallerBannerAdapter {
|
||||||
status?.effective.detail ??
|
status?.effective.detail ??
|
||||||
status?.wsl?.statusDetail ??
|
status?.wsl?.statusDetail ??
|
||||||
'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.';
|
'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
|
const runtimeReadyLabel = status
|
||||||
? status.effective.runtimeReady
|
? status.effective.runtimeReady
|
||||||
? 'Ready for persistent teammates'
|
? 'Ready for persistent teammates'
|
||||||
|
|
@ -93,12 +105,27 @@ export class TmuxInstallerBannerAdapter {
|
||||||
? 'Install Ubuntu in WSL'
|
? 'Install Ubuntu in WSL'
|
||||||
: 'Install tmux in WSL'
|
: 'Install tmux in WSL'
|
||||||
: formatInstallButtonLabel(snapshot.phase);
|
: 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 {
|
return {
|
||||||
visible,
|
visible,
|
||||||
loading: input.loading,
|
loading: input.loading,
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
|
benefitsBody,
|
||||||
error: input.error ?? snapshot.error ?? status?.error ?? null,
|
error: input.error ?? snapshot.error ?? status?.error ?? null,
|
||||||
platformLabel: formatTmuxPlatformLabel(status?.platform ?? null),
|
platformLabel: formatTmuxPlatformLabel(status?.platform ?? null),
|
||||||
locationLabel: formatTmuxLocationLabel(status?.effective.location ?? null),
|
locationLabel: formatTmuxLocationLabel(status?.effective.location ?? null),
|
||||||
|
|
@ -111,16 +138,10 @@ export class TmuxInstallerBannerAdapter {
|
||||||
manualHintsCollapsible,
|
manualHintsCollapsible,
|
||||||
primaryGuideUrl,
|
primaryGuideUrl,
|
||||||
installSupported: status?.autoInstall.supported ?? false,
|
installSupported: status?.autoInstall.supported ?? false,
|
||||||
installDisabled:
|
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',
|
|
||||||
installLabel,
|
installLabel,
|
||||||
|
installButtonPrimary,
|
||||||
|
showRefreshButton,
|
||||||
canCancel: snapshot.canCancel,
|
canCancel: snapshot.canCancel,
|
||||||
acceptsInput: snapshot.acceptsInput,
|
acceptsInput: snapshot.acceptsInput,
|
||||||
inputPrompt: snapshot.inputPrompt,
|
inputPrompt: snapshot.inputPrompt,
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,9 @@ describe('TmuxInstallerBannerAdapter', () => {
|
||||||
expect(result.manualHints).toHaveLength(1);
|
expect(result.manualHints).toHaveLength(1);
|
||||||
expect(result.manualHintsCollapsible).toBe(false);
|
expect(result.manualHintsCollapsible).toBe(false);
|
||||||
expect(result.body).toContain('persistent teammate reliability');
|
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', () => {
|
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.title).toBe('Installing tmux');
|
||||||
expect(result.body).toBe('Renderer bridge failed');
|
expect(result.body).toBe('Renderer bridge failed');
|
||||||
|
expect(result.benefitsBody).toContain('Optional, but recommended');
|
||||||
expect(result.error).toBe('Renderer bridge failed');
|
expect(result.error).toBe('Renderer bridge failed');
|
||||||
expect(result.installDisabled).toBe(true);
|
expect(result.installDisabled).toBe(true);
|
||||||
expect(result.canCancel).toBe(true);
|
expect(result.canCancel).toBe(true);
|
||||||
expect(result.acceptsInput).toBe(false);
|
expect(result.acceptsInput).toBe(false);
|
||||||
expect(result.progressPercent).toBe(68);
|
expect(result.progressPercent).toBe(68);
|
||||||
expect(result.logs).toEqual(['Downloading bottle...']);
|
expect(result.logs).toEqual(['Downloading bottle...']);
|
||||||
|
expect(result.installButtonPrimary).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes a manual guide url when auto install is unavailable', () => {
|
it('exposes a manual guide url when auto install is unavailable', () => {
|
||||||
|
|
@ -143,8 +148,10 @@ describe('TmuxInstallerBannerAdapter', () => {
|
||||||
|
|
||||||
expect(result.platformLabel).toBe('Windows');
|
expect(result.platformLabel).toBe('Windows');
|
||||||
expect(result.primaryGuideUrl).toBe('https://learn.microsoft.com/en-us/windows/wsl/install');
|
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.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', () => {
|
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.locationLabel).toBe('Host runtime');
|
||||||
expect(result.runtimeReadyLabel).toBe('Installed, but not active yet');
|
expect(result.runtimeReadyLabel).toBe('Installed, but not active yet');
|
||||||
expect(result.versionLabel).toBe('tmux 3.4');
|
expect(result.versionLabel).toBe('tmux 3.4');
|
||||||
|
expect(result.benefitsBody).toContain('With tmux in WSL');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes installer input metadata for interactive privilege flows', () => {
|
it('exposes installer input metadata for interactive privilege flows', () => {
|
||||||
|
|
@ -259,5 +267,39 @@ describe('TmuxInstallerBannerAdapter', () => {
|
||||||
|
|
||||||
expect(installWslResult.installLabel).toBe('Install WSL');
|
expect(installWslResult.installLabel).toBe('Install WSL');
|
||||||
expect(installUbuntuResult.installLabel).toBe('Install Ubuntu in 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,18 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
|
||||||
>
|
>
|
||||||
{viewModel.body}
|
{viewModel.body}
|
||||||
</p>
|
</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.platformLabel ||
|
||||||
viewModel.locationLabel ||
|
viewModel.locationLabel ||
|
||||||
viewModel.runtimeReadyLabel ||
|
viewModel.runtimeReadyLabel ||
|
||||||
|
|
@ -155,8 +167,18 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void install()}
|
onClick={() => void install()}
|
||||||
disabled={viewModel.installDisabled}
|
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"
|
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 ${
|
||||||
style={{ borderColor: 'var(--color-border)' }}
|
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" />
|
<Wrench className="size-4" />
|
||||||
{viewModel.installLabel}
|
{viewModel.installLabel}
|
||||||
|
|
@ -201,15 +223,17 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
|
||||||
: `Show setup steps (${viewModel.manualHints.length})`}
|
: `Show setup steps (${viewModel.manualHints.length})`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{viewModel.showRefreshButton && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void refresh()}
|
type="button"
|
||||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5"
|
onClick={() => void refresh()}
|
||||||
style={{ borderColor: 'var(--color-border)' }}
|
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
|
<RefreshCw className="size-4" />
|
||||||
</button>
|
Re-check
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ const baseViewModel: TmuxInstallerBannerViewModel = {
|
||||||
loading: false,
|
loading: false,
|
||||||
title: 'tmux is not installed',
|
title: 'tmux is not installed',
|
||||||
body: 'WSL is available, but no Linux distribution is installed yet.',
|
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,
|
error: null,
|
||||||
platformLabel: 'Windows',
|
platformLabel: 'Windows',
|
||||||
locationLabel: null,
|
locationLabel: null,
|
||||||
|
|
@ -45,6 +47,8 @@ const baseViewModel: TmuxInstallerBannerViewModel = {
|
||||||
installSupported: true,
|
installSupported: true,
|
||||||
installDisabled: false,
|
installDisabled: false,
|
||||||
installLabel: 'Install Ubuntu in WSL',
|
installLabel: 'Install Ubuntu in WSL',
|
||||||
|
installButtonPrimary: true,
|
||||||
|
showRefreshButton: true,
|
||||||
canCancel: false,
|
canCancel: false,
|
||||||
acceptsInput: false,
|
acceptsInput: false,
|
||||||
inputPrompt: null,
|
inputPrompt: null,
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export function formatTmuxInstallerProgress(phase: TmuxInstallerPhase): number |
|
||||||
if (phase === 'verifying') return 90;
|
if (phase === 'verifying') return 90;
|
||||||
if (phase === 'needs_restart') return 96;
|
if (phase === 'needs_restart') return 96;
|
||||||
if (phase === 'completed') return 100;
|
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 === 'error') return 100;
|
||||||
if (phase === 'cancelled') return 0;
|
if (phase === 'cancelled') return 0;
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -61,3 +61,15 @@ export function formatTmuxLocationLabel(location: 'host' | 'wsl' | null): string
|
||||||
if (location === 'wsl') return 'WSL runtime';
|
if (location === 'wsl') return 'WSL runtime';
|
||||||
return null;
|
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.';
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue