diff --git a/src/assets.ts b/src/assets.ts index 2062a0d92..ac1591013 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -130,11 +130,27 @@ export class AssetGenerator { throw new Error("Startup project not set"); } - let projectFileText = fs.readFileSync(this.startupProject.Path, 'utf8'); + return this.startupProject.IsWebProject; + } + + public computeProgramLaunchType(): ProgramLaunchType { + if (!this.startupProject) { + throw new Error("Startup project not set"); + } + + if (this.startupProject.IsBlazorWebAssemblyStandalone) { + return ProgramLaunchType.BlazorWebAssemblyStandalone; + } + + if (this.startupProject.IsBlazorWebAssemblyHosted) { + return ProgramLaunchType.BlazorWebAssemblyHosted; + } + + if (this.startupProject.IsWebProject) { + return ProgramLaunchType.Web; + } - // Assume that this is an MSBuild project. In that case, look for the 'Sdk="Microsoft.NET.Sdk.Web"' attribute. - // TODO: Have OmniSharp provide the list of SDKs used by a project and check that list instead. - return projectFileText.toLowerCase().indexOf('sdk="microsoft.net.sdk.web"') >= 0; + return ProgramLaunchType.Console; } private computeProgramPath() { @@ -160,24 +176,44 @@ export class AssetGenerator { return path.join('${workspaceFolder}', path.relative(this.workspaceFolder.uri.fsPath, startupProjectDir)); } - public createLaunchJson(isWebProject: boolean): string { - if (!isWebProject) { - const launchConfigurationsMassaged: string = indentJsonString(createLaunchConfiguration(this.computeProgramPath(), this.computeWorkingDirectory())); - const attachConfigurationsMassaged: string = indentJsonString(createAttachConfiguration()); - return ` + public createLaunchJsonConfigurations(programLaunchType: ProgramLaunchType): string { + switch (programLaunchType) { + case ProgramLaunchType.Console: { + const launchConfigurationsMassaged: string = indentJsonString(createLaunchConfiguration(this.computeProgramPath(), this.computeWorkingDirectory())); + const attachConfigurationsMassaged: string = indentJsonString(createAttachConfiguration()); + return ` [ ${launchConfigurationsMassaged}, ${attachConfigurationsMassaged} ]`; - } - else { - const webLaunchConfigurationsMassaged: string = indentJsonString(createWebLaunchConfiguration(this.computeProgramPath(), this.computeWorkingDirectory())); - const attachConfigurationsMassaged: string = indentJsonString(createAttachConfiguration()); - return ` + } + case ProgramLaunchType.Web: { + const webLaunchConfigurationsMassaged: string = indentJsonString(createWebLaunchConfiguration(this.computeProgramPath(), this.computeWorkingDirectory())); + const attachConfigurationsMassaged: string = indentJsonString(createAttachConfiguration()); + return ` [ ${webLaunchConfigurationsMassaged}, ${attachConfigurationsMassaged} ]`; + } + case ProgramLaunchType.BlazorWebAssemblyHosted: { + const chromeLaunchConfigurationsMassaged: string = indentJsonString(createBlazorWebAssemblyLaunchConfiguration(this.computeWorkingDirectory())); + const hostedLaunchConfigurationsMassaged: string = indentJsonString(createBlazorWebAssemblyHostedLaunchConfiguration(this.computeProgramPath(), this.computeWorkingDirectory())); + return ` +[ + ${hostedLaunchConfigurationsMassaged}, + ${chromeLaunchConfigurationsMassaged} +]`; + } + case ProgramLaunchType.BlazorWebAssemblyStandalone: { + const chromeLaunchConfigurationsMassaged: string = indentJsonString(createBlazorWebAssemblyLaunchConfiguration(this.computeWorkingDirectory())); + const devServerLaunchConfigurationMassaged: string = indentJsonString(createBlazorWebAssemblyDevServerLaunchConfiguration(this.computeWorkingDirectory())); + return ` +[ + ${devServerLaunchConfigurationMassaged}, + ${chromeLaunchConfigurationsMassaged} +]`; + } } } @@ -195,12 +231,12 @@ export class AssetGenerator { }; } - + private createPublishTaskDescription(): tasks.TaskDescription { let commandArgs = ['publish']; - + this.AddAdditionalCommandArgs(commandArgs); - + return { label: 'publish', command: 'dotnet', @@ -209,12 +245,12 @@ export class AssetGenerator { problemMatcher: '$msCompile' }; } - + private createWatchTaskDescription(): tasks.TaskDescription { - let commandArgs = ['watch','run']; - + let commandArgs = ['watch', 'run']; + this.AddAdditionalCommandArgs(commandArgs); - + return { label: 'watch', command: 'dotnet', @@ -223,7 +259,7 @@ export class AssetGenerator { problemMatcher: '$msCompile' }; } - + private AddAdditionalCommandArgs(commandArgs: string[]) { let buildProject = this.startupProject; if (!buildProject) { @@ -237,7 +273,7 @@ export class AssetGenerator { commandArgs.push("/property:GenerateFullPaths=true"); commandArgs.push("/consoleloggerparameters:NoSummary"); } - + public createTasksConfiguration(): tasks.TaskConfiguration { return { version: "2.0.0", @@ -246,6 +282,13 @@ export class AssetGenerator { } } +export enum ProgramLaunchType { + Console, + Web, + BlazorWebAssemblyHosted, + BlazorWebAssemblyStandalone, +} + export function createWebLaunchConfiguration(programPath: string, workingDirectory: string): string { return ` { @@ -272,6 +315,53 @@ export function createWebLaunchConfiguration(programPath: string, workingDirecto }`; } +export function createBlazorWebAssemblyHostedLaunchConfiguration(programPath: string, workingDirectory: string): string { + return ` +{ + "name": ".NET Core Launch (Blazor Hosted)", + "type": "coreclr", + "request": "launch", + // If you have changed target frameworks, make sure to update the program path. + "program": "${util.convertNativePathToPosix(programPath)}", + "args": [], + "cwd": "${util.convertNativePathToPosix(workingDirectory)}", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "preLaunchTask": "build" +}`; +} + +export function createBlazorWebAssemblyLaunchConfiguration(workingDirectory: string): string { + return ` +{ + "name": ".NET Core Debug Blazor Web Assembly in Chrome", + "type": "pwa-chrome", + "request": "launch", + "timeout": 30000, + // If you have changed the default port / launch URL make sure to update the expectation below + "url": "https://localhost:5001", + "webRoot": "${util.convertNativePathToPosix(workingDirectory)}", + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" +}`; +} + +export function createBlazorWebAssemblyDevServerLaunchConfiguration(workingDirectory: string): string { + return ` +{ + "name": ".NET Core Launch (Blazor Standalone)", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": ["run"], + "cwd": "${util.convertNativePathToPosix(workingDirectory)}", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + } +}`; +} + export function createLaunchConfiguration(programPath: string, workingDirectory: string): string { return ` { @@ -510,10 +600,10 @@ async function addLaunchJsonIfNecessary(generator: AssetGenerator, operations: A // already exists, and in the command case, we delete the launch.json file, but the VS // Code API will return old configurations anyway, which we do NOT want. - const isWebProject = generator.hasWebServerDependency(); - let launchJson: string = generator.createLaunchJson(isWebProject); + const programLaunchType = generator.computeProgramLaunchType(); + const launchJsonConfigurations: string = generator.createLaunchJsonConfigurations(programLaunchType); + const configurationsMassaged: string = indentJsonString(launchJsonConfigurations); - const configurationsMassaged: string = indentJsonString(launchJson); const launchJsonText = ` { // Use IntelliSense to find out which attributes exist for C# debugging diff --git a/src/configurationProvider.ts b/src/configurationProvider.ts index ed22e26b5..b9c3d1270 100644 --- a/src/configurationProvider.ts +++ b/src/configurationProvider.ts @@ -98,8 +98,8 @@ export class CSharpConfigurationProvider implements vscode.DebugConfigurationPro const buildOperations : AssetOperations = await getBuildOperations(generator); await addTasksJsonIfNecessary(generator, buildOperations); - const isWebProject = generator.hasWebServerDependency(); - const launchJson: string = generator.createLaunchJson(isWebProject); + const programLaunchType = generator.computeProgramLaunchType(); + const launchJson: string = generator.createLaunchJsonConfigurations(programLaunchType); // jsonc-parser's parse function parses a JSON string with comments into a JSON object. However, this removes the comments. return parse(launchJson); diff --git a/src/omnisharp/protocol.ts b/src/omnisharp/protocol.ts index 2b86ba1a3..3153534f5 100644 --- a/src/omnisharp/protocol.ts +++ b/src/omnisharp/protocol.ts @@ -334,6 +334,9 @@ export interface MSBuildProject { OutputPath: string; IsExe: boolean; IsUnityProject: boolean; + IsWebProject: boolean; + IsBlazorWebAssemblyStandalone: boolean; + IsBlazorWebAssemblyHosted: boolean; } export interface TargetFramework { @@ -790,10 +793,10 @@ export function findExecutableMSBuildProjects(projects: MSBuildProject[]) { let result: MSBuildProject[] = []; projects.forEach(project => { - if (project.IsExe && findNetCoreAppTargetFramework(project) !== undefined) { + if (project.IsExe && (findNetCoreAppTargetFramework(project) !== undefined || project.IsBlazorWebAssemblyStandalone)) { result.push(project); } }); return result; -} +} \ No newline at end of file diff --git a/src/omnisharp/utils.ts b/src/omnisharp/utils.ts index fd0991103..54193b4bb 100644 --- a/src/omnisharp/utils.ts +++ b/src/omnisharp/utils.ts @@ -3,9 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as fs from 'fs-extra'; +import * as glob from 'glob'; import { OmniSharpServer } from './server'; +import * as path from 'path'; import * as protocol from './protocol'; import * as vscode from 'vscode'; +import { MSBuildProject } from './protocol'; export async function autoComplete(server: OmniSharpServer, request: protocol.AutoCompleteRequest, token: vscode.CancellationToken) { return server.makeRequest(protocol.Requests.AutoComplete, request, token); @@ -64,7 +68,30 @@ export async function requestProjectInformation(server: OmniSharpServer, request } export async function requestWorkspaceInformation(server: OmniSharpServer) { - return server.makeRequest(protocol.Requests.Projects); + const response = await server.makeRequest(protocol.Requests.Projects); + if (response.MsBuild && response.MsBuild.Projects) { + const blazorDetectionEnabled = hasBlazorWebAssemblyDebugPrerequisites(); + + for (const project of response.MsBuild.Projects) { + project.IsWebProject = isWebProject(project); + project.IsBlazorWebAssemblyHosted = blazorDetectionEnabled && isBlazorWebAssemblyHosted(project); + project.IsBlazorWebAssemblyStandalone = blazorDetectionEnabled && !project.IsBlazorWebAssemblyHosted && isBlazorWebAssemblyProject(project); + } + + if (!blazorDetectionEnabled && response.MsBuild.Projects.some(project => isBlazorWebAssemblyProject(project))) { + // There's a Blazor Web Assembly project but VSCode isn't configured to debug the WASM code, show a notification + // to help the user configure their VSCode appropriately. + vscode.window.showInformationMessage('Additional setup is required to debug Blazor WebAssembly applications.', 'Learn more', 'Close') + .then(async result => { + if (result === 'Learn more') { + const uriToOpen = vscode.Uri.parse('https://aka.ms/blazordebugging#vscode'); + await vscode.commands.executeCommand('vscode.open', uriToOpen); + } + }); + } + } + + return response; } export async function runCodeAction(server: OmniSharpServer, request: protocol.V2.RunCodeActionRequest) { @@ -121,4 +148,79 @@ export async function debugTestStop(server: OmniSharpServer, request: protocol.V export async function isNetCoreProject(project: protocol.MSBuildProject) { return project.TargetFrameworks.find(tf => tf.ShortName.startsWith('netcoreapp') || tf.ShortName.startsWith('netstandard')) !== undefined; -} \ No newline at end of file +} + +function isBlazorWebAssemblyHosted(project: protocol.MSBuildProject): boolean { + if (!isBlazorWebAssemblyProject(project)) { + return false; + } + + if (!project.IsExe) { + return false; + } + + if (!project.IsWebProject) { + return false; + } + + if (protocol.findNetCoreAppTargetFramework(project) === undefined) { + return false; + } + + return true; +} + +function isBlazorWebAssemblyProject(project: MSBuildProject): boolean { + const projectDirectory = path.dirname(project.Path); + const launchSettings = glob.sync('**/launchSettings.json', { cwd: projectDirectory }); + if (!launchSettings) { + return false; + } + + for (const launchSetting of launchSettings) { + try { + const absoluteLaunchSetting = path.join(projectDirectory, launchSetting); + const launchSettingContent = fs.readFileSync(absoluteLaunchSetting); + if (!launchSettingContent) { + continue; + } + + if (launchSettingContent.indexOf('"inspectUri"') > 0) { + return true; + } + } catch { + // Swallow IO errors from reading the launchSettings.json files + } + } + + return false; +} + +function hasBlazorWebAssemblyDebugPrerequisites() { + const jsDebugExtension = vscode.extensions.getExtension('ms-vscode.js-debug-nightly'); + if (!jsDebugExtension) { + return false; + } + + const debugNodeConfigSection = vscode.workspace.getConfiguration('debug.node'); + const useV3NodeValue = debugNodeConfigSection.get('useV3'); + if (!useV3NodeValue) { + return false; + } + + const debugChromeConfigSection = vscode.workspace.getConfiguration('debug.chrome'); + const useV3ChromeValue = debugChromeConfigSection.get('useV3'); + if (!useV3ChromeValue) { + return false; + } + + return true; +} + +function isWebProject(project: MSBuildProject): boolean { + let projectFileText = fs.readFileSync(project.Path, 'utf8'); + + // Assume that this is an MSBuild project. In that case, look for the 'Sdk="Microsoft.NET.Sdk.Web"' attribute. + // TODO: Have OmniSharp provide the list of SDKs used by a project and check that list instead. + return projectFileText.toLowerCase().indexOf('sdk="microsoft.net.sdk.web"') >= 0; +} diff --git a/test/featureTests/assets.test.ts b/test/featureTests/assets.test.ts index 8a08e4796..e05211759 100644 --- a/test/featureTests/assets.test.ts +++ b/test/featureTests/assets.test.ts @@ -7,7 +7,7 @@ import * as path from 'path'; import * as protocol from '../../src/omnisharp/protocol'; import * as vscode from 'vscode'; -import { AssetGenerator } from '../../src/assets'; +import { AssetGenerator, ProgramLaunchType } from '../../src/assets'; import { parse } from 'jsonc-parser'; import { should } from 'chai'; @@ -65,7 +65,7 @@ suite("Asset generation: csproj", () => { let info = createMSBuildWorkspaceInformation(path.join(rootPath, 'testApp.csproj'), 'testApp', 'netcoreapp1.0'); let generator = new AssetGenerator(info, createMockWorkspaceFolder(rootPath)); generator.setStartupProject(0); - let launchJson = parse(generator.createLaunchJson(/*isWebProject*/ false), undefined, { disallowComments: true }); + let launchJson = parse(generator.createLaunchJsonConfigurations(ProgramLaunchType.Console), undefined, { disallowComments: true }); let programPath = launchJson[0].program; // ${workspaceFolder}/bin/Debug/netcoreapp1.0/testApp.dll @@ -78,7 +78,7 @@ suite("Asset generation: csproj", () => { let info = createMSBuildWorkspaceInformation(path.join(rootPath, 'nested', 'testApp.csproj'), 'testApp', 'netcoreapp1.0'); let generator = new AssetGenerator(info, createMockWorkspaceFolder(rootPath)); generator.setStartupProject(0); - let launchJson = parse(generator.createLaunchJson(/*isWebProject*/ false), undefined, { disallowComments: true }); + let launchJson = parse(generator.createLaunchJsonConfigurations(ProgramLaunchType.Console), undefined, { disallowComments: true }); let programPath = launchJson[0].program; // ${workspaceFolder}/nested/bin/Debug/netcoreapp1.0/testApp.dll @@ -86,12 +86,74 @@ suite("Asset generation: csproj", () => { segments.should.deep.equal(['${workspaceFolder}', 'nested', 'bin', 'Debug', 'netcoreapp1.0', 'testApp.dll']); }); + test("Create launch.json for Blazor web assembly standalone project opened in workspace", () => { + const rootPath = path.resolve('testRoot'); + const info = createMSBuildWorkspaceInformation(path.join(rootPath, 'testApp.csproj'), 'testApp', 'netstandard2.1', /*isExe*/ true, /*isWebProject*/ true, /*isBlazorWebAssemblyStandalone*/ true); + const generator = new AssetGenerator(info, createMockWorkspaceFolder(rootPath)); + generator.setStartupProject(0); + const launchJson = parse(generator.createLaunchJsonConfigurations(ProgramLaunchType.BlazorWebAssemblyStandalone), undefined, { disallowComments: true }); + const devServerLaunchConfig = launchJson[0]; + const chromeLaunchConfig = launchJson[1]; + const devProgram = devServerLaunchConfig.program; + const chromeWebRoot = chromeLaunchConfig.webRoot; + + devProgram.should.equal('dotnet'); + chromeWebRoot.should.equal('${workspaceFolder}'); + }); + + test("Create launch.json for nested Blazor web assembly standalone project opened in workspace", () => { + let rootPath = path.resolve('testRoot'); + let info = createMSBuildWorkspaceInformation(path.join(rootPath, 'nested', 'testApp.csproj'), 'testApp', 'netstandard2.1', /*isExe*/ true, /*isWebProject*/ true, /*isBlazorWebAssemblyStandalone*/ true); + let generator = new AssetGenerator(info, createMockWorkspaceFolder(rootPath)); + generator.setStartupProject(0); + let launchJson = parse(generator.createLaunchJsonConfigurations(ProgramLaunchType.BlazorWebAssemblyStandalone), undefined, { disallowComments: true }); + const devServerLaunchConfig = launchJson[0]; + const chromeLaunchConfig = launchJson[1]; + const devProgram = devServerLaunchConfig.program; + const chromeWebRoot = chromeLaunchConfig.webRoot; + + devProgram.should.equal('dotnet'); + chromeWebRoot.should.equal('${workspaceFolder}/nested'); + }); + + test("Create launch.json for Blazor web assembly hosted project opened in workspace", () => { + const rootPath = path.resolve('testRoot'); + const info = createMSBuildWorkspaceInformation(path.join(rootPath, 'testApp.csproj'), 'testApp', 'netcoreapp3.0', /*isExe*/ true, /*isWebProject*/ true, /*isBlazorWebAssemblyStandalone*/ false, /*isBlazorWebAssemblyHosted*/ true); + const generator = new AssetGenerator(info, createMockWorkspaceFolder(rootPath)); + generator.setStartupProject(0); + const launchJson = parse(generator.createLaunchJsonConfigurations(ProgramLaunchType.BlazorWebAssemblyHosted), undefined, { disallowComments: true }); + const hostedServerLaunchConfig = launchJson[0]; + const chromeLaunchConfig = launchJson[1]; + const programPath = hostedServerLaunchConfig.program; + const chromeWebRoot = chromeLaunchConfig.webRoot; + + let segments = programPath.split(path.posix.sep); + segments.should.deep.equal(['${workspaceFolder}', 'bin', 'Debug', 'netcoreapp3.0', 'testApp.dll']); + chromeWebRoot.should.equal('${workspaceFolder}'); + }); + + test("Create launch.json for nested Blazor web assembly hosted project opened in workspace", () => { + let rootPath = path.resolve('testRoot'); + let info = createMSBuildWorkspaceInformation(path.join(rootPath, 'nested', 'testApp.csproj'), 'testApp', 'netcoreapp3.0', /*isExe*/ true, /*isWebProject*/ true, /*isBlazorWebAssemblyStandalone*/ false, /*isBlazorWebAssemblyHosted*/ true); + let generator = new AssetGenerator(info, createMockWorkspaceFolder(rootPath)); + generator.setStartupProject(0); + let launchJson = parse(generator.createLaunchJsonConfigurations(ProgramLaunchType.BlazorWebAssemblyHosted), undefined, { disallowComments: true }); + const hostedServerLaunchConfig = launchJson[0]; + const chromeLaunchConfig = launchJson[1]; + const programPath = hostedServerLaunchConfig.program; + const chromeWebRoot = chromeLaunchConfig.webRoot; + + let segments = programPath.split(path.posix.sep); + segments.should.deep.equal(['${workspaceFolder}', 'nested', 'bin', 'Debug', 'netcoreapp3.0', 'testApp.dll']); + chromeWebRoot.should.equal('${workspaceFolder}/nested'); + }); + test("Create launch.json for web project opened in workspace", () => { let rootPath = path.resolve('testRoot'); - let info = createMSBuildWorkspaceInformation(path.join(rootPath, 'testApp.csproj'), 'testApp', 'netcoreapp1.0'); + let info = createMSBuildWorkspaceInformation(path.join(rootPath, 'testApp.csproj'), 'testApp', 'netcoreapp1.0', /*isExe*/ true, /*isWebProject*/ true); let generator = new AssetGenerator(info, createMockWorkspaceFolder(rootPath)); generator.setStartupProject(0); - let launchJson = parse(generator.createLaunchJson(/*isWebProject*/ true), undefined, { disallowComments: true }); + let launchJson = parse(generator.createLaunchJsonConfigurations(ProgramLaunchType.Web), undefined, { disallowComments: true }); let programPath = launchJson[0].program; // ${workspaceFolder}/bin/Debug/netcoreapp1.0/testApp.dll @@ -101,10 +163,10 @@ suite("Asset generation: csproj", () => { test("Create launch.json for nested web project opened in workspace", () => { let rootPath = path.resolve('testRoot'); - let info = createMSBuildWorkspaceInformation(path.join(rootPath, 'nested', 'testApp.csproj'), 'testApp', 'netcoreapp1.0'); + let info = createMSBuildWorkspaceInformation(path.join(rootPath, 'nested', 'testApp.csproj'), 'testApp', 'netcoreapp1.0', /*isExe*/ true, /*isWebProject*/ true); let generator = new AssetGenerator(info, createMockWorkspaceFolder(rootPath)); generator.setStartupProject(0); - let launchJson = parse(generator.createLaunchJson(/*isWebProject*/ true), undefined, { disallowComments: true }); + let launchJson = parse(generator.createLaunchJsonConfigurations(ProgramLaunchType.Web), undefined, { disallowComments: true }); let programPath = launchJson[0].program; // ${workspaceFolder}/nested/bin/Debug/netcoreapp1.0/testApp.dll @@ -121,7 +183,7 @@ function createMockWorkspaceFolder(rootPath: string): vscode.WorkspaceFolder { }; } -function createMSBuildWorkspaceInformation(projectPath: string, assemblyName: string, targetFrameworkShortName: string, isExe: boolean = true): protocol.WorkspaceInformationResponse { +function createMSBuildWorkspaceInformation(projectPath: string, assemblyName: string, targetFrameworkShortName: string, isExe: boolean = true, isWebProject: boolean = false, isBlazorWebAssemblyStandalone: boolean = false, isBlazorWebAssemblyHosted: boolean = false): protocol.WorkspaceInformationResponse { return { MsBuild: { SolutionPath: '', @@ -142,7 +204,10 @@ function createMSBuildWorkspaceInformation(projectPath: string, assemblyName: st ], OutputPath: '', IsExe: isExe, - IsUnityProject: false + IsUnityProject: false, + IsWebProject: isWebProject, + IsBlazorWebAssemblyHosted: isBlazorWebAssemblyHosted, + IsBlazorWebAssemblyStandalone: isBlazorWebAssemblyStandalone, } ], }