diff --git a/README.md b/README.md index 7dbdeab..63d163a 100644 --- a/README.md +++ b/README.md @@ -133,10 +133,12 @@ The extension provides a **custom `winapp` debug type** that launches your app w | `debuggerType` | Language | Required Extension | |----------------|----------|--------------------| -| `coreclr` (default) | C# / .NET | [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp) | +| `coreclr` | C# / .NET | [C#](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp) | | `cppvsdbg` | C / C++ | [C/C++](https://marketplace.visualstudio.com/items?itemName=ms-vscode.cpptools) | | `node` | Node.js / Electron | Built-in | +> On your first debug session, if the extension for the selected `debuggerType` isn't installed, WinApp offers to install it and continues the session automatically — no manual reload needed in most cases. If no `debuggerType` is set and none is installed yet, WinApp lets you pick the debugger that matches your project (C#, C/C++, or built-in Node.js/Electron). + **Example `launch.json`:** ```jsonc @@ -158,7 +160,7 @@ The extension provides a **custom `winapp` debug type** that launches your app w |----------|------|---------|-------------| | `inputFolder` | string | | Path to the build output folder containing your app binaries (e.g., `${workspaceFolder}/bin/Debug/net8.0-windows10.0.22621`). If not set, you will be prompted to select a folder. | | `manifest` | string | | Path to the `AppxManifest.xml` file. If not set, the CLI auto-detects from the input folder or current directory. | -| `debuggerType` | string | `coreclr` | Underlying debugger to use (`coreclr`, `cppvsdbg`, or `node`). | +| `debuggerType` | string | | Optional underlying debugger override (`coreclr`, `cppvsdbg`, or `node`). If omitted, WinApp reuses an installed debugger or prompts you to pick one. | | `workingDirectory` | string | workspace folder | Working directory for the application. | | `args` | string | | Command-line arguments to pass to the application. | | `outputAppxDirectory` | string | | Output directory for the loose-layout package. Defaults to an `AppX` folder inside the input folder. | diff --git a/package.json b/package.json index e28ef6b..a31cbc6 100644 --- a/package.json +++ b/package.json @@ -163,8 +163,12 @@ }, "debuggerType": { "type": "string", - "description": "The underlying debugger to use (e.g., 'coreclr', 'cppvsdbg')", - "default": "coreclr" + "enum": [ + "coreclr", + "cppvsdbg", + "node" + ], + "description": "Optional override for the underlying debugger to use. Omit to let WinApp reuse an installed debugger or prompt you to pick one ('coreclr', 'cppvsdbg', or 'node')." }, "workingDirectory": { "type": "string", @@ -195,8 +199,7 @@ "body": { "type": "winapp", "request": "launch", - "name": "WinApp: Launch and Attach", - "debuggerType": "coreclr" + "name": "WinApp: Launch and Attach" } } ] @@ -216,7 +219,7 @@ "clean": "node -e \"require('fs').rmSync('bin', {recursive: true, force: true}); require('fs').rmSync('out', {recursive: true, force: true}); require('fs').rmSync('dist', {recursive: true, force: true});\"", "download-cli": "pwsh -NoProfile -Command \"& ./scripts/download-cli.ps1\"", "download-cli:latest": "pwsh -NoProfile -Command \"& ./scripts/download-cli.ps1 -Tag latest\"", - "test:unit": "tsc -p ./ && node -e \"require('fs').cpSync('src/test/fixtures','out/test/fixtures',{recursive:true})\" && mocha out/test/project-detection.test.js && npx tsx --test src/test/extension-field-validator.test.ts src/test/extension-templates.test.ts src/test/manifest-edge-cases.test.ts src/test/manifest-parser.test.ts src/test/manifest-validator.test.ts src/test/project-resolver.test.ts src/test/redos-prevention.test.ts src/test/xml-utils.test.ts src/test/shell-escape.test.ts" + "test:unit": "tsc -p ./ && node -e \"require('fs').cpSync('src/test/fixtures','out/test/fixtures',{recursive:true})\" && mocha out/test/project-detection.test.js && npx tsx --test src/test/debugger-resolver.test.ts src/test/extension-field-validator.test.ts src/test/extension-templates.test.ts src/test/manifest-edge-cases.test.ts src/test/manifest-parser.test.ts src/test/manifest-validator.test.ts src/test/project-resolver.test.ts src/test/redos-prevention.test.ts src/test/xml-utils.test.ts src/test/shell-escape.test.ts" }, "dependencies": { "@xmldom/xmldom": "^0.9.9", diff --git a/src/debugger-resolver.ts b/src/debugger-resolver.ts new file mode 100644 index 0000000..2d66980 --- /dev/null +++ b/src/debugger-resolver.ts @@ -0,0 +1,55 @@ +export interface DebuggerExtensionRequirement { + id: string; + name: string; +} + +/** + * Maps debugger types to the VS Code extensions that provide them. + */ +export const DEBUGGER_EXTENSION_REQUIREMENTS: Record = { + 'coreclr': { id: 'ms-dotnettools.csharp', name: 'C# (ms-dotnettools.csharp)' }, + 'cppvsdbg': { id: 'ms-vscode.cpptools', name: 'C/C++ (ms-vscode.cpptools)' }, +}; + +/** + * Debugger types to consider (in preference order) when a launch configuration + * doesn't specify one, and we need to reuse an already-installed extension. + */ +export const DEFAULT_DEBUGGER_CANDIDATES: string[] = ['coreclr', 'cppvsdbg']; + +export const DEBUGGER_CHOICE_LABELS = { + installCsharp: 'Install C# (.NET)', + installCpp: 'Install C/C++', + useNode: 'Use Node.js / Electron (built-in)' +} as const; + +export function getDebuggerExtensionRequirement(debuggerType: string): DebuggerExtensionRequirement | undefined { + return DEBUGGER_EXTENSION_REQUIREMENTS[debuggerType]; +} + +export function chooseInstalledDebuggerType( + installedExtensionIds: Iterable, + candidates: readonly string[] = DEFAULT_DEBUGGER_CANDIDATES +): string | undefined { + const installed = new Set([...installedExtensionIds].map(id => id.toLowerCase())); + for (const candidate of candidates) { + const requirement = getDebuggerExtensionRequirement(candidate); + if (requirement && installed.has(requirement.id.toLowerCase())) { + return candidate; + } + } + return undefined; +} + +export function getDebuggerTypeFromChoice(choice: string | undefined): string | undefined { + if (choice === DEBUGGER_CHOICE_LABELS.installCsharp) { + return 'coreclr'; + } + if (choice === DEBUGGER_CHOICE_LABELS.installCpp) { + return 'cppvsdbg'; + } + if (choice === DEBUGGER_CHOICE_LABELS.useNode) { + return 'node'; + } + return undefined; +} diff --git a/src/extension.ts b/src/extension.ts index 2bdba26..e01941f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,25 +6,121 @@ import { detectProjects } from './project-detection'; import { resolveProjectDirectory as resolveProjectDirectoryCore } from './project-resolver'; import { glob } from 'glob'; import { ManifestEditorProvider } from './manifest-editor/manifest-editor-provider'; +import { + DEBUGGER_CHOICE_LABELS, + chooseInstalledDebuggerType, + getDebuggerExtensionRequirement, + getDebuggerTypeFromChoice +} from './debugger-resolver'; const WINAPP_DEBUG_TYPE = 'winapp'; /** - * Maps debugger types to the VS Code extensions that provide them. + * Output channel for debugger-related activity (e.g. auto-installed extensions), + * so the user has a durable record of why WinApp added an extension. Created lazily. */ -const DEBUGGER_EXTENSION_MAP: Record = { - 'coreclr': { id: 'ms-dotnettools.csharp', name: 'C# (ms-dotnettools.csharp)' }, - 'cppvsdbg': { id: 'ms-vscode.cpptools', name: 'C/C++ (ms-vscode.cpptools)' }, -}; +let debuggerLogChannel: vscode.OutputChannel | undefined; + +function logDebuggerActivity(message: string): void { + if (!debuggerLogChannel) { + debuggerLogChannel = vscode.window.createOutputChannel('WinApp Debugger'); + } + debuggerLogChannel.appendLine(`[${new Date().toISOString()}] ${message}`); + console.log(`[WinApp] ${message}`); +} + +/** + * Install a VS Code extension and activate it in this session so debugging can + * continue without a full window reload. If VS Code hasn't surfaced it to this + * extension host yet, fall back to prompting the user to reload. + * `reason` explains why the extension is being added and is written to the + * "WinApp Debugger" output channel so the change isn't a surprise to the user. + * Returns true if the extension is installed and usable now, false otherwise. + */ +async function installAndActivateExtension( + requirement: { id: string; name: string }, + reason: string +): Promise { + logDebuggerActivity(`Installing ${requirement.name} (${requirement.id}) because ${reason}.`); + try { + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `Installing ${requirement.name}…` }, + async () => { + await vscode.commands.executeCommand('workbench.extensions.installExtension', requirement.id); + } + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage( + `Failed to install ${requirement.name}: ${message}. Please install it manually and retry.` + ); + return false; + } + + const installed = vscode.extensions.getExtension(requirement.id); + if (!installed) { + vscode.window.showInformationMessage( + `${requirement.name} was installed. Reload VS Code to finish enabling it, then start debugging again.`, + 'Reload Window' + ).then((selection) => { + if (selection === 'Reload Window') { + vscode.commands.executeCommand('workbench.action.reloadWindow'); + } + }); + return false; + } + + if (!installed.isActive) { + try { + await installed.activate(); + } catch { + // Non-fatal: the debugger contribution is registered on install, so + // proceed and let the attach step surface any remaining issue. + } + } + + logDebuggerActivity(`${requirement.name} installed and ready.`); + vscode.window.showInformationMessage( + `WinApp installed ${requirement.name} to debug your app. See the "WinApp Debugger" output channel for details.` + ); + return true; +} + +/** + * Show a modal letting the user pick and install the debugger extension that + * matches their project (C#/.NET, C/C++, or Node/Electron). Used both on first run (no debuggerType) + * and when an attach fails because the installed debugger doesn't match the + * project. Returns the resolved debugger type, or undefined if cancelled. + */ +async function promptAndInstallDebuggerChoice(message: string, reason: string): Promise { + const choice = await vscode.window.showErrorMessage( + message, + { modal: true }, + DEBUGGER_CHOICE_LABELS.installCsharp, + DEBUGGER_CHOICE_LABELS.installCpp, + DEBUGGER_CHOICE_LABELS.useNode + ); + + const selected = getDebuggerTypeFromChoice(choice); + if (!selected) { + return undefined; + } + + const requirement = getDebuggerExtensionRequirement(selected); + if (!requirement) { + return selected; + } + return (await installAndActivateExtension(requirement, reason)) ? selected : undefined; +} /** * Check that the VS Code extension required for the given debugger type is installed. - * If it is not installed, show a clear error message with an option to install it. + * If it is not installed, show an actionable modal offering to install it. * Returns true if the extension is present (or the debugger type has no known requirement), - * false if the extension is missing. + * false if the extension is missing and wasn't installed. */ async function ensureDebuggerExtensionInstalled(debuggerType: string): Promise { - const requirement = DEBUGGER_EXTENSION_MAP[debuggerType]; + const requirement = getDebuggerExtensionRequirement(debuggerType); if (!requirement) { return true; } @@ -33,20 +129,50 @@ async function ensureDebuggerExtensionInstalled(debuggerType: string): Promise { + if (explicitType) { + return (await ensureDebuggerExtensionInstalled(explicitType)) ? explicitType : undefined; + } + + // No debuggerType specified: reuse an already-installed debugger extension. + const installedCandidate = chooseInstalledDebuggerType(vscode.extensions.all.map(extension => extension.id)); + if (installedCandidate) { + return installedCandidate; + } + + // First run with nothing installed and no debuggerType configured: let the + // user choose the debugger that matches their project rather than assuming C#. + return promptAndInstallDebuggerChoice( + 'Since no "debuggerType" is set, choose the debugger that matches your project type. ' + + 'C#/.NET and C/C++ projects require an extension; Node.js/Electron uses the built-in debugger:', + 'you selected it to debug this project' + ); } /** @@ -145,7 +271,7 @@ class WinAppDebugConfigurationProvider implements vscode.DebugConfigurationProvi } async resolveDebugConfiguration( - folder: vscode.WorkspaceFolder | undefined, + _folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, _token?: vscode.CancellationToken ): Promise { @@ -156,6 +282,17 @@ class WinAppDebugConfigurationProvider implements vscode.DebugConfigurationProvi config.request = 'launch'; } + // Ensure the extension backing the underlying debugger is installed before + // the session starts, so a first-run user isn't dropped into a half-started + // session (issue #32). When no debuggerType is configured, let the user + // choose the extension matching their project instead of assuming coreclr. + const debuggerType = await resolveDebuggerType(config.debuggerType); + if (!debuggerType) { + return undefined; + } + // Persist the resolved type so the attach step uses the matching debugger. + config.debuggerType = debuggerType; + return config; } @@ -234,23 +371,24 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory } const cliPath = getWinappCliPath(this.extensionPath); - const spawnArgs = ['run', inputFolder]; + const baseSpawnArgs = ['run', inputFolder]; // Optional explicit manifest path; when omitted the CLI // auto-detects from the input folder or current directory. if (config.manifest) { - spawnArgs.push('--manifest', config.manifest); + baseSpawnArgs.push('--manifest', config.manifest); } if (config.outputAppxDirectory) { - spawnArgs.push('--output-appx-directory', config.outputAppxDirectory); + baseSpawnArgs.push('--output-appx-directory', config.outputAppxDirectory); } // Determine the debugger type based on config or default to coreclr const debuggerType = config.debuggerType || 'coreclr'; - // Verify the required VS Code extension for this debugger type is installed - // before starting the app, so we don't launch the process only to fail on attach. + // Safety net: resolveDebugConfiguration already verifies the required + // extension before the session starts, but re-check here so we never + // launch the process only to fail on attach if resolution was bypassed. if (!await ensureDebuggerExtensionInstalled(debuggerType)) { return new vscode.DebugAdapterInlineImplementation(new NoOpDebugAdapter()); } @@ -261,10 +399,10 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory } if (args.trim()) { - spawnArgs.push('--args', args.trim()); + baseSpawnArgs.push('--args', args.trim()); } - spawnArgs.push('--json'); + baseSpawnArgs.push('--json'); // Spawn winapp run --json. The process stays alive while the app runs, // so we stream stdout to parse the JSON with the PID before waiting for exit. @@ -281,7 +419,7 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory } return new Promise<{ processId: number; runProcess: ReturnType }>((resolve, reject) => { - const child = spawn(cliPath, spawnArgs, { + const child = spawn(cliPath, baseSpawnArgs, { cwd, env: { ...process.env, WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE }, shell: false @@ -338,8 +476,23 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory debugConfiguration.processId = processId; } - // Start the real debug session as a child of the winapp session - await vscode.debug.startDebugging(folder, debugConfiguration, { parentSession: session }); + // Start the real debug session as a child of the winapp session. + // startDebugging resolves false when the child debugger can't attach — + // most commonly because the installed debugger extension doesn't match + // the project type (e.g. a C# extension was reused for a C/C++ app). + // Surface a clear, actionable error so the user can install the right one. + const started = await vscode.debug.startDebugging(folder, debugConfiguration, { parentSession: session }); + if (!started) { + runProcess.kill(); + const debuggerName = getDebuggerExtensionRequirement(debuggerType)?.name ?? `"${debuggerType}"`; + await promptAndInstallDebuggerChoice( + `The ${debuggerName} debugger couldn't attach to your app. ` + + `This usually means the installed debugger extension doesn't match your project type. ` + + `Install the debugger that matches your project, then start debugging again:`, + 'you selected it after the previous debugger failed to attach' + ); + return new vscode.DebugAdapterInlineImplementation(new NoOpDebugAdapter()); + } // When the child debug session ends, kill the winapp run process and stop the parent session const parentSession = session; @@ -915,4 +1068,6 @@ function parseProcessIdFromJson(output: string): number | undefined { } export function deactivate() { + debuggerLogChannel?.dispose(); + debuggerLogChannel = undefined; } diff --git a/src/test/debugger-resolver.test.ts b/src/test/debugger-resolver.test.ts new file mode 100644 index 0000000..95827d9 --- /dev/null +++ b/src/test/debugger-resolver.test.ts @@ -0,0 +1,68 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEBUGGER_CHOICE_LABELS, + chooseInstalledDebuggerType, + getDebuggerExtensionRequirement, + getDebuggerTypeFromChoice +} from '../debugger-resolver'; + +describe('debugger resolver helpers', () => { + describe('getDebuggerExtensionRequirement', () => { + it('returns the extension requirement for known extension-backed debugger types', () => { + assert.deepEqual(getDebuggerExtensionRequirement('coreclr'), { + id: 'ms-dotnettools.csharp', + name: 'C# (ms-dotnettools.csharp)' + }); + assert.deepEqual(getDebuggerExtensionRequirement('cppvsdbg'), { + id: 'ms-vscode.cpptools', + name: 'C/C++ (ms-vscode.cpptools)' + }); + }); + + it('returns undefined for debugger types with no extension requirement or unknown types', () => { + assert.equal(getDebuggerExtensionRequirement('node'), undefined); + assert.equal(getDebuggerExtensionRequirement('unknown'), undefined); + }); + }); + + describe('chooseInstalledDebuggerType', () => { + it('reuses coreclr first when both supported debugger extensions are installed', () => { + const result = chooseInstalledDebuggerType([ + 'ms-vscode.cpptools', + 'ms-dotnettools.csharp' + ]); + + assert.equal(result, 'coreclr'); + }); + + it('reuses cppvsdbg when only the C/C++ extension is installed', () => { + assert.equal(chooseInstalledDebuggerType(['ms-vscode.cpptools']), 'cppvsdbg'); + }); + + it('matches installed extension IDs case-insensitively', () => { + assert.equal(chooseInstalledDebuggerType(['MS-DOTNETTOOLS.CSHARP']), 'coreclr'); + }); + + it('returns undefined when no supported debugger extension is installed', () => { + assert.equal(chooseInstalledDebuggerType(['publisher.other-extension']), undefined); + assert.equal(chooseInstalledDebuggerType([]), undefined); + }); + }); + + describe('getDebuggerTypeFromChoice', () => { + it('maps install choices to debugger types', () => { + assert.equal(getDebuggerTypeFromChoice(DEBUGGER_CHOICE_LABELS.installCsharp), 'coreclr'); + assert.equal(getDebuggerTypeFromChoice(DEBUGGER_CHOICE_LABELS.installCpp), 'cppvsdbg'); + }); + + it('maps the Node/Electron built-in choice without requiring installation', () => { + assert.equal(getDebuggerTypeFromChoice(DEBUGGER_CHOICE_LABELS.useNode), 'node'); + }); + + it('returns undefined when the modal is cancelled or returns an unknown label', () => { + assert.equal(getDebuggerTypeFromChoice(undefined), undefined); + assert.equal(getDebuggerTypeFromChoice('Cancel'), undefined); + }); + }); +});