545 lines
16 KiB
TypeScript
545 lines
16 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import path from 'node:path';
|
|
|
|
import { decodeInstallerProcessOutput } from '../runtime/decodeInstallerProcessOutput';
|
|
|
|
import { TmuxWslPreferenceStore } from './TmuxWslPreferenceStore';
|
|
|
|
import type {
|
|
TmuxInstallStrategy,
|
|
TmuxWslPreference,
|
|
TmuxWslStatus,
|
|
} from '@features/tmux-installer/contracts';
|
|
|
|
interface ExecWslResult {
|
|
exitCode: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
interface WslVerboseDistroEntry {
|
|
name: string;
|
|
isDefault: boolean;
|
|
version: 1 | 2 | null;
|
|
}
|
|
|
|
interface WindowsOptionalFeatureState {
|
|
FeatureName?: string | null;
|
|
State?: string | null;
|
|
RestartRequired?: string | boolean | null;
|
|
}
|
|
|
|
interface WindowsOptionalFeatureProbe {
|
|
restartPending: boolean;
|
|
hasConfiguredWslFeature: boolean;
|
|
}
|
|
|
|
type ExecFileCallback = (
|
|
error: Error | null,
|
|
stdout: string | Buffer,
|
|
stderr: string | Buffer
|
|
) => void;
|
|
|
|
type ExecFileLike = (
|
|
command: string,
|
|
args: string[],
|
|
options: {
|
|
timeout: number;
|
|
windowsHide: boolean;
|
|
maxBuffer: number;
|
|
encoding: 'buffer';
|
|
},
|
|
callback: ExecFileCallback
|
|
) => void;
|
|
|
|
export interface TmuxWslProbeResult {
|
|
preference: TmuxWslPreference | null;
|
|
status: TmuxWslStatus;
|
|
}
|
|
|
|
const MAX_BUFFER_BYTES = 1024 * 1024;
|
|
const WSL_NOT_AVAILABLE_DETAIL = 'WSL is not available on this Windows machine yet.';
|
|
const WINDOWS_WSL_FEATURE_NAMES = ['Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform'];
|
|
const POWERSHELL_FEATURE_QUERY = [
|
|
'$features = Get-WindowsOptionalFeature -Online -FeatureName "Microsoft-Windows-Subsystem-Linux","VirtualMachinePlatform"',
|
|
'$features | Select-Object FeatureName, State, RestartRequired | ConvertTo-Json -Compress',
|
|
].join('; ');
|
|
|
|
export class TmuxWslService {
|
|
readonly #execFile: ExecFileLike;
|
|
readonly #preferenceStore: TmuxWslPreferenceStore;
|
|
|
|
constructor(
|
|
execFileImpl: ExecFileLike = execFile as ExecFileLike,
|
|
preferenceStore = new TmuxWslPreferenceStore()
|
|
) {
|
|
this.#execFile = execFileImpl;
|
|
this.#preferenceStore = preferenceStore;
|
|
}
|
|
|
|
async probe(): Promise<TmuxWslProbeResult> {
|
|
const statusProbe = await this.#run(['--status'], 4_000);
|
|
const distroListProbe = await this.#run(['--list', '--quiet'], 4_000);
|
|
const featureProbe = await this.#queryWindowsOptionalFeatures();
|
|
const persistedPreferredDistro = await this.#preferenceStore.getPreferredDistro();
|
|
const wslInstalled =
|
|
statusProbe.exitCode === 0 ||
|
|
distroListProbe.exitCode === 0 ||
|
|
featureProbe?.hasConfiguredWslFeature === true;
|
|
const rebootRequired =
|
|
featureProbe?.restartPending === true ||
|
|
this.#looksLikeRestartRequired(`${statusProbe.stdout}\n${statusProbe.stderr}`);
|
|
|
|
if (!wslInstalled) {
|
|
if (persistedPreferredDistro) {
|
|
await this.#preferenceStore.clearPreferredDistro();
|
|
}
|
|
return {
|
|
preference: null,
|
|
status: {
|
|
wslInstalled: false,
|
|
rebootRequired,
|
|
distroName: null,
|
|
distroVersion: null,
|
|
distroBootstrapped: false,
|
|
innerPackageManager: null,
|
|
tmuxAvailableInsideWsl: false,
|
|
tmuxVersion: null,
|
|
tmuxBinaryPath: null,
|
|
statusDetail: this.#firstNonEmpty(
|
|
statusProbe.stderr,
|
|
statusProbe.stdout,
|
|
WSL_NOT_AVAILABLE_DETAIL
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
const distros = this.#parseWslDistros(distroListProbe.stdout);
|
|
if (distros.length === 0) {
|
|
if (persistedPreferredDistro) {
|
|
await this.#preferenceStore.clearPreferredDistro();
|
|
}
|
|
return {
|
|
preference: null,
|
|
status: {
|
|
wslInstalled: true,
|
|
rebootRequired,
|
|
distroName: null,
|
|
distroVersion: null,
|
|
distroBootstrapped: false,
|
|
innerPackageManager: null,
|
|
tmuxAvailableInsideWsl: false,
|
|
tmuxVersion: null,
|
|
tmuxBinaryPath: null,
|
|
statusDetail: rebootRequired
|
|
? 'WSL was installed, but Windows still needs a restart before a Linux distro can be configured.'
|
|
: 'WSL is available, but no Linux distribution is installed yet.',
|
|
},
|
|
};
|
|
}
|
|
|
|
const verboseProbe = await this.#run(['--list', '--verbose'], 4_000);
|
|
const verboseEntries = this.#parseVerboseDistroEntries(verboseProbe.stdout, distros);
|
|
const preferredDistro = this.#resolvePreferredDistro({
|
|
distros,
|
|
verboseEntries,
|
|
persistedPreferredDistro,
|
|
});
|
|
const usingPersistedPreference =
|
|
Boolean(persistedPreferredDistro) && preferredDistro === persistedPreferredDistro;
|
|
if (persistedPreferredDistro && preferredDistro !== persistedPreferredDistro) {
|
|
await this.#preferenceStore.clearPreferredDistro();
|
|
}
|
|
const preferredVersion =
|
|
verboseEntries.find((entry) => entry.name === preferredDistro)?.version ?? null;
|
|
|
|
if (!preferredDistro) {
|
|
return {
|
|
preference: {
|
|
preferredDistroName: null,
|
|
source: usingPersistedPreference ? 'persisted' : null,
|
|
},
|
|
status: {
|
|
wslInstalled: true,
|
|
rebootRequired,
|
|
distroName: null,
|
|
distroVersion: null,
|
|
distroBootstrapped: false,
|
|
innerPackageManager: null,
|
|
tmuxAvailableInsideWsl: false,
|
|
tmuxVersion: null,
|
|
tmuxBinaryPath: null,
|
|
statusDetail:
|
|
distros.length > 1
|
|
? 'WSL has multiple Linux distributions, but no default or saved distro target is configured yet.'
|
|
: 'WSL is available, but the app could not determine which Linux distribution to target.',
|
|
},
|
|
};
|
|
}
|
|
|
|
const preference: TmuxWslPreference = {
|
|
preferredDistroName: preferredDistro,
|
|
source: usingPersistedPreference
|
|
? 'persisted'
|
|
: verboseEntries.some((entry) => entry.isDefault)
|
|
? 'default'
|
|
: 'manual',
|
|
};
|
|
|
|
const bootstrapProbe = await this.#run(
|
|
['-d', preferredDistro, '--', 'sh', '-lc', 'printf ready'],
|
|
5_000
|
|
);
|
|
const distroBootstrapped = bootstrapProbe.exitCode === 0;
|
|
if (!distroBootstrapped) {
|
|
return {
|
|
preference,
|
|
status: {
|
|
wslInstalled: true,
|
|
rebootRequired,
|
|
distroName: preferredDistro,
|
|
distroVersion: preferredVersion,
|
|
distroBootstrapped: false,
|
|
innerPackageManager: null,
|
|
tmuxAvailableInsideWsl: false,
|
|
tmuxVersion: null,
|
|
tmuxBinaryPath: null,
|
|
statusDetail: this.#firstNonEmpty(
|
|
bootstrapProbe.stderr,
|
|
bootstrapProbe.stdout,
|
|
`${preferredDistro} is installed in WSL, but its first Linux user setup is not finished yet. Open it once, complete the setup, then re-check.`
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
const innerPackageManager = await this.#resolveInnerPackageManager(preferredDistro);
|
|
const tmuxProbe = await this.#run(
|
|
[
|
|
'-d',
|
|
preferredDistro,
|
|
'--',
|
|
'sh',
|
|
'-lc',
|
|
'command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }',
|
|
],
|
|
5_000
|
|
);
|
|
const tmuxLines = tmuxProbe.stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
return {
|
|
preference,
|
|
status: {
|
|
wslInstalled: true,
|
|
rebootRequired,
|
|
distroName: preferredDistro,
|
|
distroVersion: preferredVersion,
|
|
distroBootstrapped: true,
|
|
innerPackageManager,
|
|
tmuxAvailableInsideWsl: tmuxProbe.exitCode === 0,
|
|
tmuxVersion: tmuxProbe.exitCode === 0 ? (tmuxLines[0] ?? null) : null,
|
|
tmuxBinaryPath: tmuxProbe.exitCode === 0 ? (tmuxLines[1] ?? null) : null,
|
|
statusDetail:
|
|
tmuxProbe.exitCode === 0
|
|
? `tmux is available inside ${preferredDistro} on Windows through WSL.`
|
|
: `tmux is not installed inside the ${preferredDistro} WSL distro yet.`,
|
|
},
|
|
};
|
|
}
|
|
|
|
async execTmux(
|
|
args: string[],
|
|
preferredDistroName?: string | null,
|
|
timeout = 5_000
|
|
): Promise<ExecWslResult> {
|
|
const distroName = preferredDistroName ?? (await this.probe()).preference?.preferredDistroName;
|
|
if (!distroName) {
|
|
return {
|
|
exitCode: 1,
|
|
stdout: '',
|
|
stderr: 'No WSL distribution is available for tmux.',
|
|
};
|
|
}
|
|
|
|
return this.#run(['-d', distroName, '-e', 'tmux', ...args], timeout);
|
|
}
|
|
|
|
getPersistedPreferredDistroSync(): string | null {
|
|
return this.#preferenceStore.getPreferredDistroSync();
|
|
}
|
|
|
|
async persistPreferredDistro(preferredDistroName: string | null): Promise<void> {
|
|
if (!preferredDistroName?.trim()) {
|
|
await this.#preferenceStore.clearPreferredDistro();
|
|
return;
|
|
}
|
|
await this.#preferenceStore.setPreferredDistro(preferredDistroName);
|
|
}
|
|
|
|
async #resolveInnerPackageManager(distro: string): Promise<TmuxInstallStrategy | null> {
|
|
const distroIdProbe = await this.#run(
|
|
['-d', distro, '--', 'sh', '-lc', '. /etc/os-release >/dev/null 2>&1 && printf %s "$ID"'],
|
|
4_000
|
|
);
|
|
const distroId = distroIdProbe.stdout.trim().toLowerCase();
|
|
if (distroId === 'arch') {
|
|
return 'pacman';
|
|
}
|
|
if (distroId === 'fedora') {
|
|
return 'dnf';
|
|
}
|
|
if (
|
|
distroId === 'ubuntu' ||
|
|
distroId === 'debian' ||
|
|
distroId === 'pop' ||
|
|
distroId === 'linuxmint' ||
|
|
distroId === 'kali'
|
|
) {
|
|
return 'apt';
|
|
}
|
|
if (distroId === 'opensuse-tumbleweed' || distroId === 'opensuse-leap' || distroId === 'sles') {
|
|
return 'zypper';
|
|
}
|
|
|
|
const candidateChecks: { binary: string; strategy: TmuxInstallStrategy }[] = [
|
|
{ binary: 'apt-get', strategy: 'apt' },
|
|
{ binary: 'dnf', strategy: 'dnf' },
|
|
{ binary: 'yum', strategy: 'yum' },
|
|
{ binary: 'zypper', strategy: 'zypper' },
|
|
{ binary: 'pacman', strategy: 'pacman' },
|
|
];
|
|
|
|
for (const candidate of candidateChecks) {
|
|
const probe = await this.#run(
|
|
['-d', distro, '--', 'sh', '-lc', `command -v ${candidate.binary} >/dev/null 2>&1`],
|
|
3_000
|
|
);
|
|
if (probe.exitCode === 0) {
|
|
return candidate.strategy;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async #run(args: string[], timeout: number): Promise<ExecWslResult> {
|
|
const candidates = this.#getExecutableCandidates();
|
|
let lastFailure: ExecWslResult | null = null;
|
|
|
|
for (const executable of candidates) {
|
|
const result = await this.#exec(executable, args, timeout);
|
|
if (result === null) {
|
|
continue;
|
|
}
|
|
lastFailure = result;
|
|
if (result.exitCode === 0) {
|
|
return result;
|
|
}
|
|
if (result.exitCode !== 0) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
return (
|
|
lastFailure ?? {
|
|
exitCode: 1,
|
|
stdout: '',
|
|
stderr: WSL_NOT_AVAILABLE_DETAIL,
|
|
}
|
|
);
|
|
}
|
|
|
|
async #exec(executable: string, args: string[], timeout: number): Promise<ExecWslResult | null> {
|
|
return new Promise((resolve) => {
|
|
this.#execFile(
|
|
executable,
|
|
args,
|
|
{
|
|
timeout,
|
|
windowsHide: true,
|
|
maxBuffer: MAX_BUFFER_BYTES,
|
|
encoding: 'buffer',
|
|
},
|
|
(error, stdout, stderr) => {
|
|
const errorCode =
|
|
typeof error === 'object' && error !== null && 'code' in error
|
|
? (error as NodeJS.ErrnoException).code
|
|
: undefined;
|
|
if (errorCode === 'ENOENT') {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
resolve({
|
|
exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0,
|
|
stdout: this.#decodeOutput(stdout),
|
|
stderr: this.#decodeOutput(stderr) || (error instanceof Error ? error.message : ''),
|
|
});
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
#getExecutableCandidates(): string[] {
|
|
const candidates = new Set<string>();
|
|
const windir = process.env.WINDIR;
|
|
if (windir) {
|
|
candidates.add(path.join(windir, 'System32', 'wsl.exe'));
|
|
candidates.add(path.join(windir, 'Sysnative', 'wsl.exe'));
|
|
}
|
|
candidates.add('wsl.exe');
|
|
return [...candidates];
|
|
}
|
|
|
|
#decodeOutput(output: string | Buffer): string {
|
|
return decodeInstallerProcessOutput(output);
|
|
}
|
|
|
|
#parseWslDistros(stdout: string): string[] {
|
|
return stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.replace(/\0/g, '').trim())
|
|
.map((line) => line.replace(/^\*\s*/, '').trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
#parseVerboseDistroEntries(stdout: string, distros: string[]): WslVerboseDistroEntry[] {
|
|
const sortedDistros = [...distros].sort((left, right) => right.length - left.length);
|
|
const entries: WslVerboseDistroEntry[] = [];
|
|
|
|
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
let line = rawLine.replace(/\0/g, '').trim();
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
const isDefault = line.startsWith('*');
|
|
if (isDefault) {
|
|
line = line.slice(1).trim();
|
|
}
|
|
|
|
const matchedName = sortedDistros.find((distro) => line.startsWith(distro));
|
|
if (!matchedName) {
|
|
continue;
|
|
}
|
|
|
|
const lineTokens = line.split(/\s+/);
|
|
const versionToken = lineTokens[lineTokens.length - 1];
|
|
const version = versionToken === '1' ? 1 : versionToken === '2' ? 2 : null;
|
|
entries.push({ name: matchedName, isDefault, version });
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
#resolvePreferredDistro(input: {
|
|
distros: string[];
|
|
verboseEntries: WslVerboseDistroEntry[];
|
|
persistedPreferredDistro: string | null;
|
|
}): string | null {
|
|
if (input.persistedPreferredDistro && input.distros.includes(input.persistedPreferredDistro)) {
|
|
return input.persistedPreferredDistro;
|
|
}
|
|
|
|
const defaultDistro = input.verboseEntries.find((entry) => entry.isDefault)?.name ?? null;
|
|
if (defaultDistro) {
|
|
return defaultDistro;
|
|
}
|
|
|
|
if (input.distros.length === 1) {
|
|
return input.distros[0] ?? null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
#looksLikeRestartRequired(output: string): boolean {
|
|
const lowered = output.toLowerCase();
|
|
return (
|
|
lowered.includes('restart') ||
|
|
lowered.includes('reboot') ||
|
|
lowered.includes('перезагруз') ||
|
|
lowered.includes('требуется перезагрузка')
|
|
);
|
|
}
|
|
|
|
async #queryWindowsOptionalFeatures(): Promise<WindowsOptionalFeatureProbe | null> {
|
|
const result = await this.#execPowerShell(
|
|
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', POWERSHELL_FEATURE_QUERY],
|
|
6_000
|
|
);
|
|
if (!result || result.exitCode !== 0 || !result.stdout.trim()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(result.stdout) as
|
|
| WindowsOptionalFeatureState
|
|
| WindowsOptionalFeatureState[];
|
|
const features = Array.isArray(parsed) ? parsed : [parsed];
|
|
const relevantFeatures = features.filter((feature) =>
|
|
WINDOWS_WSL_FEATURE_NAMES.includes(feature.FeatureName ?? '')
|
|
);
|
|
if (relevantFeatures.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
restartPending: relevantFeatures.some((feature) =>
|
|
String(feature.State ?? '')
|
|
.toLowerCase()
|
|
.includes('pending')
|
|
),
|
|
hasConfiguredWslFeature: relevantFeatures.some((feature) => {
|
|
const state = String(feature.State ?? '').toLowerCase();
|
|
return state.length > 0 && state !== 'disabled' && state !== 'disabledwithpayloadremoved';
|
|
}),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async #execPowerShell(args: string[], timeout: number): Promise<ExecWslResult | null> {
|
|
return new Promise((resolve) => {
|
|
this.#execFile(
|
|
'powershell.exe',
|
|
args,
|
|
{
|
|
timeout,
|
|
windowsHide: true,
|
|
maxBuffer: MAX_BUFFER_BYTES,
|
|
encoding: 'buffer',
|
|
},
|
|
(error, stdout, stderr) => {
|
|
const errorCode =
|
|
typeof error === 'object' && error !== null && 'code' in error
|
|
? (error as NodeJS.ErrnoException).code
|
|
: undefined;
|
|
if (errorCode === 'ENOENT') {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
resolve({
|
|
exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0,
|
|
stdout: this.#decodeOutput(stdout),
|
|
stderr: this.#decodeOutput(stderr) || (error instanceof Error ? error.message : ''),
|
|
});
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
#firstNonEmpty(...values: (string | null | undefined)[]): string {
|
|
for (const value of values) {
|
|
const trimmed = value?.trim();
|
|
if (trimmed) {
|
|
return trimmed;
|
|
}
|
|
}
|
|
return WSL_NOT_AVAILABLE_DETAIL;
|
|
}
|
|
}
|