Skip to content

Commit 6805569

Browse files
authored
Merge pull request dotnet#2373 from nagilson/nagilson-mac-installer-op
Simplify Mac Installation Process
2 parents a2f94db + 741cdd4 commit 6805569

File tree

6 files changed

+268
-68
lines changed

6 files changed

+268
-68
lines changed

vscode-dotnet-runtime-library/src/Acquisition/IInstallationValidator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ export abstract class IInstallationValidator
1010
{
1111
constructor(protected readonly eventStream: IEventStream) {}
1212

13-
public abstract validateDotnetInstall(install: DotnetInstall, dotnetPath: string, isDotnetFolder?: boolean, failOnErr?: boolean): void;
13+
public abstract validateDotnetInstall(install: DotnetInstall, dotnetPath: string, validateDirectory?: boolean, failOnErr?: boolean): boolean;
1414
}

vscode-dotnet-runtime-library/src/Acquisition/InstallationValidator.ts

Lines changed: 175 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,69 +3,209 @@
33
* The .NET Foundation licenses this file to you under the MIT license.
44
*--------------------------------------------------------------------------------------------*/
55
import * as fs from 'fs';
6-
import * as path from 'path';
76
import * as os from 'os';
8-
import {
7+
import * as path from 'path';
8+
import
9+
{
910
DotnetInstallationValidated,
1011
DotnetInstallationValidationError,
11-
EventBasedError,
12-
DotnetInstallationValidationMissed
12+
DotnetInstallationValidationMissed,
13+
EventBasedError
1314
} from '../EventStream/EventStreamEvents';
14-
import { IInstallationValidator } from './IInstallationValidator';
1515
import { DotnetInstall } from './DotnetInstall';
16+
import { IInstallationValidator } from './IInstallationValidator';
17+
18+
/**
19+
* Context object containing information needed for validation operations.
20+
*/
21+
interface ValidationContext
22+
{
23+
install: DotnetInstall;
24+
dotnetPath: string;
25+
baseErrorMessage: string;
26+
}
27+
28+
/**
29+
* Validates .NET installations by checking for the existence and validity of .NET executables or directories.
30+
* Provides options to either throw errors or return false on validation failure.
31+
*/
32+
export class InstallationValidator extends IInstallationValidator
33+
{
34+
/**
35+
* Validates a .NET installation by checking either an executable file or installation directory.
36+
*
37+
* @param install - The DotnetInstall object containing installation details
38+
* @param dotnetPath - Path to the .NET executable or installation directory to validate
39+
* @param validateDirectory - If true, validates the path as a directory; if false, validates as an executable file
40+
* @param failOnErr - If true, throws an error on validation failure; if false, returns false and posts a validation missed event
41+
* @returns true if validation passes, false if validation fails. Throws if failOnErr is true and validation fails.
42+
* @remarks Validation is not completely exhaustive. Runtime files besides the executable may be missing.
43+
* @throws EventBasedError if validation fails and failOnErr is true
44+
*/
45+
public validateDotnetInstall(install: DotnetInstall, dotnetPath: string, validateDirectory = false, failOnErr = true): boolean
46+
{
47+
const validationContext = {
48+
install,
49+
dotnetPath,
50+
baseErrorMessage: `Validation of .dotnet installation for version ${JSON.stringify(install)} failed:`
51+
};
1652

17-
export class InstallationValidator extends IInstallationValidator {
18-
public validateDotnetInstall(install: DotnetInstall, dotnetPath: string, isDotnetFolder = false, failOnErr = true): void {
19-
const dotnetValidationFailed = `Validation of .dotnet installation for version ${JSON.stringify(install)} failed:`;
20-
const folder = path.dirname(dotnetPath);
53+
const isValid = validateDirectory
54+
? this.validateDotnetDirectory(validationContext, failOnErr)
55+
: this.validateDotnetExecutable(validationContext, failOnErr);
2156

22-
if(!isDotnetFolder)
57+
if (isValid)
2358
{
24-
this.assertOrThrowError(failOnErr, fs.existsSync(folder),
25-
`${dotnetValidationFailed} Expected installation folder ${folder} does not exist.`, install, dotnetPath);
59+
this.eventStream.post(new DotnetInstallationValidated(install));
60+
}
61+
62+
return isValid;
63+
}
2664

27-
this.assertOrThrowError(failOnErr, fs.existsSync(dotnetPath),
28-
`${dotnetValidationFailed} Expected executable does not exist at "${dotnetPath}"`, install, dotnetPath);
65+
/**
66+
* Validates a .NET executable file by checking:
67+
* 1. Parent directory exists
68+
* 2. Executable file exists
69+
* 3. Path points to a file (not a directory)
70+
*
71+
* @param context - Validation context containing install details and paths
72+
* @param failOnErr - Whether to throw on validation failure or return false
73+
* @returns true if all validations pass, false otherwise
74+
*/
75+
private validateDotnetExecutable(context: ValidationContext, failOnErr: boolean): boolean
76+
{
77+
const { dotnetPath, baseErrorMessage } = context;
78+
const parentDirectory = path.dirname(dotnetPath);
2979

30-
this.assertOrThrowError(failOnErr, fs.lstatSync(dotnetPath).isFile(),
31-
`${dotnetValidationFailed} Expected executable file exists but is not a file: "${dotnetPath}"`, install, dotnetPath);
80+
// Check if parent directory exists
81+
if (!this.validateCondition(
82+
fs.existsSync(parentDirectory),
83+
`${baseErrorMessage} Expected installation folder ${parentDirectory} does not exist.`,
84+
context,
85+
failOnErr
86+
))
87+
{
88+
return false;
3289
}
33-
else
90+
91+
// Check if executable exists
92+
if (!this.validateCondition(
93+
fs.existsSync(dotnetPath),
94+
`${baseErrorMessage} Expected executable does not exist at "${dotnetPath}"`,
95+
context,
96+
failOnErr
97+
))
3498
{
35-
this.assertOrThrowError(failOnErr, fs.existsSync(folder),
36-
`${dotnetValidationFailed} Expected dotnet folder ${dotnetPath} does not exist.`, install, dotnetPath);
99+
return false;
100+
}
37101

38-
try
102+
// Check if path points to a file (not a directory)
103+
try
104+
{
105+
if (!this.validateCondition(
106+
fs.lstatSync(dotnetPath).isFile(),
107+
`${baseErrorMessage} Expected executable file exists but is not a file: "${dotnetPath}"`,
108+
context,
109+
failOnErr
110+
))
39111
{
40-
this.assertOrThrowError(failOnErr, fs.readdirSync(folder).length !== 0,
41-
`${dotnetValidationFailed} The dotnet folder is empty "${dotnetPath}"`, install, dotnetPath);
112+
return false;
42113
}
43-
catch(error : any) // fs.readdirsync throws ENOENT so we need to recall the function
114+
} catch (error)
115+
{
116+
return this.handleValidationError(
117+
`${baseErrorMessage} Unable to verify that "${dotnetPath}" is a file: ${JSON.stringify(error ?? '')}`,
118+
context,
119+
failOnErr
120+
);
121+
}
122+
123+
return true;
124+
}
125+
126+
private validateDotnetDirectory(context: ValidationContext, failOnErr: boolean): boolean
127+
{
128+
const { dotnetPath, baseErrorMessage } = context;
129+
130+
// Check if directory exists
131+
if (!this.validateCondition(
132+
fs.existsSync(dotnetPath),
133+
`${baseErrorMessage} Expected dotnet folder ${dotnetPath} does not exist.`,
134+
context,
135+
failOnErr
136+
))
137+
{
138+
return false;
139+
}
140+
141+
// Check if directory is not empty
142+
try
143+
{
144+
const directoryContents = fs.readdirSync(dotnetPath);
145+
if (!this.validateCondition(
146+
directoryContents.length > 0,
147+
`${baseErrorMessage} The dotnet folder is empty "${dotnetPath}"`,
148+
context,
149+
failOnErr
150+
))
44151
{
45-
this.assertOrThrowError(failOnErr, false,
46-
`${dotnetValidationFailed} The dotnet file dne "${dotnetPath}"`, install, dotnetPath);
152+
return false;
47153
}
48154
}
155+
catch (error)
156+
{
157+
return this.handleValidationError(
158+
`${baseErrorMessage} Unable to read dotnet directory "${dotnetPath}": ${JSON.stringify(error ?? '')}`,
159+
context,
160+
failOnErr
161+
);
162+
}
49163

50-
this.eventStream.post(new DotnetInstallationValidated(install));
164+
return true;
51165
}
52166

53-
private assertOrThrowError(failOnErr : boolean, passedValidation: boolean, message: string, install: DotnetInstall, dotnetPath: string) {
54-
if (!passedValidation && failOnErr)
167+
private validateCondition(
168+
condition: boolean,
169+
errorMessage: string,
170+
context: ValidationContext,
171+
failOnErr: boolean
172+
): boolean
173+
{
174+
if (!condition)
55175
{
56-
this.eventStream.post(new DotnetInstallationValidationError(new Error(message), install, dotnetPath));
57-
throw new EventBasedError('DotnetInstallationValidationError', message);
176+
return this.handleValidationError(errorMessage, context, failOnErr);
58177
}
178+
return true;
179+
}
59180

60-
if(os.platform() === 'darwin')
181+
private handleValidationError(
182+
message: string,
183+
context: ValidationContext,
184+
failOnErr: boolean
185+
): boolean
186+
{
187+
const { install, dotnetPath } = context;
188+
189+
if (failOnErr)
61190
{
62-
message = `Did you close the .NET Installer, cancel the installation, or refuse the password prompt? If you want to install the .NET SDK, please try again. If you are facing an error, please report it at https://github.com/dotnet/vscode-dotnet-runtime/issues.
63-
${message}`;
191+
this.eventStream.post(new DotnetInstallationValidationError(new Error(message), install, dotnetPath));
192+
throw new EventBasedError('DotnetInstallationValidationError', this.enhanceErrorMessageForMacOS(message));
64193
}
194+
else
195+
{
196+
this.eventStream?.post(new DotnetInstallationValidationMissed(new Error(message), message));
197+
}
198+
199+
return false;
200+
}
65201

66-
if(!passedValidation && !failOnErr)
202+
private enhanceErrorMessageForMacOS(message: string): string
203+
{
204+
if (os.platform() === 'darwin')
67205
{
68-
this.eventStream?.post(new DotnetInstallationValidationMissed(new Error(message), message))
206+
return `Did you close the .NET Installer, cancel the installation, or refuse the password prompt? If you want to install the .NET SDK, please try again. If you are facing an error, please report it at https://github.com/dotnet/vscode-dotnet-runtime/issues.
207+
${message}`;
69208
}
209+
return message;
70210
}
71211
}

vscode-dotnet-runtime-library/src/Acquisition/WinMacGlobalInstaller.ts

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import
1818
DotnetUnexpectedInstallerOSError,
1919
EventBasedError,
2020
EventCancellationError,
21+
MacInstallerBackupFailure,
22+
MacInstallerBackupSuccess,
23+
MacInstallerFailure,
2124
NetInstallerBeginExecutionEvent,
2225
NetInstallerEndExecutionEvent,
2326
OSXOpenNotAvailableError,
@@ -36,6 +39,7 @@ import { IFileUtilities } from '../Utils/IFileUtilities';
3639
import { IUtilityContext } from '../Utils/IUtilityContext';
3740
import { executeWithLock, getOSArch } from '../Utils/TypescriptUtilities';
3841
import { GLOBAL_LOCK_PING_DURATION_MS, SYSTEM_INFORMATION_CACHE_DURATION_MS } from './CacheTimeConstants';
42+
import { DotnetCoreAcquisitionWorker } from './DotnetCoreAcquisitionWorker';
3943
import { DotnetInstall } from './DotnetInstall';
4044
import { IAcquisitionWorkerContext } from './IAcquisitionWorkerContext';
4145
import { IGlobalInstaller } from './IGlobalInstaller';
@@ -416,6 +420,39 @@ If you were waiting for the install to succeed, please extend the timeout settin
416420
return arm64EmulationHostPath;
417421
}
418422

423+
private async darwinInstallBackup(installerPath: string): Promise<string>
424+
{
425+
// The -W flag makes it so we wait for the installer .pkg to exit, though we are unable to get the exit code.
426+
const possibleCommands =
427+
[
428+
CommandExecutor.makeCommand(`command`, [`-v`, `open`]),
429+
CommandExecutor.makeCommand(`/usr/bin/open`, [])
430+
];
431+
432+
let workingCommand = await this.commandRunner.tryFindWorkingCommand(possibleCommands);
433+
if (!workingCommand)
434+
{
435+
const error = new EventBasedError('OSXOpenNotAvailableError',
436+
`The 'open' command on OSX was not detected. This is likely due to the PATH environment variable on your system being clobbered by another program.
437+
Please correct your PATH variable or make sure the 'open' utility is installed so .NET can properly execute.`);
438+
this.acquisitionContext.eventStream.post(new OSXOpenNotAvailableError(error, getInstallFromContext(this.acquisitionContext)));
439+
throw error;
440+
}
441+
else if (workingCommand.commandRoot === 'command')
442+
{
443+
workingCommand = CommandExecutor.makeCommand(`open`, [`-W`, `"${path.resolve(installerPath)}"`]);
444+
}
445+
446+
this.acquisitionContext.eventStream.post(new NetInstallerBeginExecutionEvent(`The OS X .NET Installer has been launched.`));
447+
448+
const commandResult = await this.commandRunner.execute(workingCommand, { timeout: this.acquisitionContext.timeoutSeconds * 1000 }, false);
449+
450+
this.acquisitionContext.eventStream.post(new NetInstallerEndExecutionEvent(`The OS X .NET Installer has closed.`));
451+
this.handleTimeout(commandResult);
452+
453+
return commandResult.status;
454+
}
455+
419456
/**
420457
*
421458
* @param installerPath The path to the installer file to run.
@@ -426,36 +463,37 @@ If you were waiting for the install to succeed, please extend the timeout settin
426463
if (os.platform() === 'darwin')
427464
{
428465
// For Mac:
429-
// We don't rely on the installer because it doesn't allow us to run without sudo, and we don't want to handle the user password.
430-
// The -W flag makes it so we wait for the installer .pkg to exit, though we are unable to get the exit code.
431-
const possibleCommands =
432-
[
433-
CommandExecutor.makeCommand(`command`, [`-v`, `open`]),
434-
CommandExecutor.makeCommand(`/usr/bin/open`, [])
435-
];
436-
437-
let workingCommand = await this.commandRunner.tryFindWorkingCommand(possibleCommands);
438-
if (!workingCommand)
439-
{
440-
const error = new EventBasedError('OSXOpenNotAvailableError',
441-
`The 'open' command on OSX was not detected. This is likely due to the PATH environment variable on your system being clobbered by another program.
442-
Please correct your PATH variable or make sure the 'open' utility is installed so .NET can properly execute.`);
443-
this.acquisitionContext.eventStream.post(new OSXOpenNotAvailableError(error, getInstallFromContext(this.acquisitionContext)));
444-
throw error;
445-
}
446-
else if (workingCommand.commandRoot === 'command')
447-
{
448-
workingCommand = CommandExecutor.makeCommand(`open`, [`-W`, `"${path.resolve(installerPath)}"`]);
449-
}
450-
466+
const sudoInstallerCommand = CommandExecutor.makeCommand('installer', ['-pkg', `"${path.resolve(installerPath)}"`, '-target', '/'], true);
451467
this.acquisitionContext.eventStream.post(new NetInstallerBeginExecutionEvent(`The OS X .NET Installer has been launched.`));
452-
453-
const commandResult = await this.commandRunner.execute(workingCommand, { timeout: this.acquisitionContext.timeoutSeconds * 1000 }, false);
454-
468+
const installerResult = await this.commandRunner.execute(sudoInstallerCommand);
455469
this.acquisitionContext.eventStream.post(new NetInstallerEndExecutionEvent(`The OS X .NET Installer has closed.`));
456-
this.handleTimeout(commandResult);
470+
this.handleTimeout(installerResult);
457471

458-
return commandResult.status;
472+
if (installerResult.status !== '0')
473+
{
474+
// Try to have the user manually go through the installation process
475+
// Osascript has some issues running the installer for a pkg, and open does not have an exit code besides 0 if the user cancels/denies.
476+
// For understanding the success rates, we can subtract the MacInstallerFailure amount but add back in MacInstallerBackupSuccess for when the user does succeed.
477+
// This prevents counting a user who leaves the PC as a failure.
478+
// The error code from the installer will give us a better understanding of the installer issues and not count them as an issue in the VS Code setup logic
479+
this.acquisitionContext.eventStream.post(new MacInstallerFailure(`The installer failed.`, installerResult.status, installerResult.stderr, installerResult.stdout));
480+
await this.darwinInstallBackup(installerPath);
481+
482+
const expectedDotnetHostPath = await this.getExpectedGlobalSDKPath(this.acquisitionContext.acquisitionContext.version, this.acquisitionContext.acquisitionContext.architecture ?? DotnetCoreAcquisitionWorker.defaultArchitecture());
483+
const expectedInstall = getInstallFromContext(this.acquisitionContext);
484+
const validatedInstall = this.acquisitionContext.installationValidator.validateDotnetInstall(expectedInstall, expectedDotnetHostPath, false, false);
485+
if (validatedInstall)
486+
{
487+
this.acquisitionContext.eventStream.post(new MacInstallerBackupSuccess(`The installer succeeded when invoked manually.`));
488+
return '0';
489+
}
490+
else
491+
{
492+
// Add this back to the failure count as it gets accounted for in the platform agnostic logic
493+
this.acquisitionContext.eventStream.post(new MacInstallerBackupFailure(`The installer also failed when invoked manually.`));
494+
}
495+
}
496+
return installerResult.status;
459497
}
460498
else
461499
{

0 commit comments

Comments
 (0)