From ffbd995a00f5f16ef2a71a54490e2c5d3b740de9 Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:36:26 -0700 Subject: [PATCH 1/8] Make F5 auto-install the C# extension on first run (#32) The coreclr debugger requires ms-dotnettools.csharp, but a brand-new user's first F5 failed with a passive notification and a half-started session. - Detect the missing debugger extension early, in resolveDebugConfiguration, so the session is cancelled cleanly instead of half-starting. - Turn the error into an actionable modal that installs the extension with progress, activates it in-session, and continues automatically (falling back to a reload prompt only if VS Code hasn't surfaced it yet). - Keep the adapter-factory check as a safety net. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ce7415c-8966-43ec-ad27-59a68e7ed74b --- README.md | 2 ++ src/extension.ts | 70 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7dbdeab..ae9e816 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,8 @@ The extension provides a **custom `winapp` debug type** that launches your app w | `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. + **Example `launch.json`:** ```jsonc diff --git a/src/extension.ts b/src/extension.ts index 2bdba26..37eba55 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -33,20 +33,59 @@ async function ensureDebuggerExtensionInstalled(debuggerType: string): Promise { + 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; + } + + // Activate the freshly installed extension so debugging can continue in this + // same session without a full window reload. If VS Code hasn't surfaced it to + // this extension host yet, fall back to asking the user to reload and retry. + 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. + } } - return false; + return true; } /** @@ -156,6 +195,14 @@ class WinAppDebugConfigurationProvider implements vscode.DebugConfigurationProvi config.request = 'launch'; } + // Ensure the extension backing the underlying debugger (e.g. the C# + // extension for coreclr) is installed before the session starts, so a + // first-run user isn't dropped into a half-started session (issue #32). + const debuggerType = config.debuggerType || 'coreclr'; + if (!await ensureDebuggerExtensionInstalled(debuggerType)) { + return undefined; + } + return config; } @@ -249,8 +296,9 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory // 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()); } From 839583987fc7b4b3caf8de12b620b9298f529c8e Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:01:57 -0700 Subject: [PATCH 2/8] Let user choose debugger extension when debuggerType is unset (#32) When no debuggerType is configured (e.g. first F5 with no launch.json), the default assumed coreclr and offered the C# extension even for C++ projects. - Add resolveDebuggerType: if a debuggerType is set, ensure its extension; if not, reuse an already-installed debugger extension, otherwise show a modal letting the user pick C# (.NET) or C/C++ to match their project. - Persist the chosen type onto the config so attach uses the matching debugger. - Extract installAndActivateExtension so both the single-extension and choose-extension paths share install/activate/reload-fallback logic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ce7415c-8966-43ec-ad27-59a68e7ed74b --- README.md | 2 +- src/extension.ts | 128 +++++++++++++++++++++++++++++++++++------------ 2 files changed, 96 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index ae9e816..e28442a 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ The extension provides a **custom `winapp` debug type** that launches your app w | `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. +> 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 extension that matches your project (C# or C/C++). **Example `launch.json`:** diff --git a/src/extension.ts b/src/extension.ts index 37eba55..f18f777 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,33 +18,18 @@ const DEBUGGER_EXTENSION_MAP: Record = { }; /** - * 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. - * Returns true if the extension is present (or the debugger type has no known requirement), - * false if the extension is missing. + * Debugger types to consider (in preference order) when a launch configuration + * doesn't specify one, and we need to reuse an already-installed extension. */ -async function ensureDebuggerExtensionInstalled(debuggerType: string): Promise { - const requirement = DEBUGGER_EXTENSION_MAP[debuggerType]; - if (!requirement) { - return true; - } - - if (vscode.extensions.getExtension(requirement.id)) { - return true; - } - - // Use a modal so the user makes a deliberate choice before the session starts, - // rather than a passive notification they can miss (see issue #32). - const choice = await vscode.window.showErrorMessage( - `The WinApp debugger needs the ${requirement.name} extension to debug "${debuggerType}" apps, but it isn't installed.`, - { modal: true }, - 'Install and Retry' - ); - - if (choice !== 'Install and Retry') { - return false; - } +const DEFAULT_DEBUGGER_CANDIDATES: string[] = ['coreclr', 'cppvsdbg']; +/** + * 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. + * Returns true if the extension is installed and usable now, false otherwise. + */ +async function installAndActivateExtension(requirement: { id: string; name: string }): Promise { try { await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: `Installing ${requirement.name}…` }, @@ -60,9 +45,6 @@ async function ensureDebuggerExtensionInstalled(debuggerType: string): Promise { + const requirement = DEBUGGER_EXTENSION_MAP[debuggerType]; + if (!requirement) { + return true; + } + + if (vscode.extensions.getExtension(requirement.id)) { + return true; + } + + // Use a modal so the user makes a deliberate choice before the session starts, + // rather than a passive notification they can miss (see issue #32). + const choice = await vscode.window.showErrorMessage( + `The WinApp debugger needs the ${requirement.name} extension to debug "${debuggerType}" apps, but it isn't installed.`, + { modal: true }, + 'Install and Retry' + ); + + if (choice !== 'Install and Retry') { + return false; + } + + return installAndActivateExtension(requirement); +} + +/** + * Resolve the debugger type for a session and make sure its backing extension is + * installed. When the configuration specifies a debuggerType, ensure that one. + * When it doesn't (e.g. first F5 with no launch.json), reuse an already-installed + * debugger extension if there is one, otherwise let the user pick the extension + * that matches their project type (C# vs C/C++) instead of guessing. + * Returns the resolved debugger type, or undefined if the user cancelled. + */ +async function resolveDebuggerType(explicitType: string | undefined): Promise { + if (explicitType) { + return (await ensureDebuggerExtensionInstalled(explicitType)) ? explicitType : undefined; + } + + // No debuggerType specified: reuse an already-installed debugger extension. + for (const candidate of DEFAULT_DEBUGGER_CANDIDATES) { + const requirement = DEBUGGER_EXTENSION_MAP[candidate]; + if (requirement && vscode.extensions.getExtension(requirement.id)) { + return candidate; + } + } + + // First run with nothing installed and no debuggerType configured: let the + // user choose the debugger that matches their project rather than assuming C#. + const installCsharp = 'Install C# (.NET)'; + const installCpp = 'Install C/C++'; + const choice = await vscode.window.showErrorMessage( + 'To launch your app you need a debugger extension installed. Since no "debuggerType" is set, ' + + 'install the one that matches your project type:', + { modal: true }, + installCsharp, + installCpp + ); + + let selected: string | undefined; + if (choice === installCsharp) { + selected = 'coreclr'; + } else if (choice === installCpp) { + selected = 'cppvsdbg'; + } else { + return undefined; + } + + const requirement = DEBUGGER_EXTENSION_MAP[selected]; + return (await installAndActivateExtension(requirement)) ? selected : undefined; +} + /** * Execute a winapp CLI command and show output in the terminal */ @@ -195,13 +254,16 @@ class WinAppDebugConfigurationProvider implements vscode.DebugConfigurationProvi config.request = 'launch'; } - // Ensure the extension backing the underlying debugger (e.g. the C# - // extension for coreclr) is installed before the session starts, so a - // first-run user isn't dropped into a half-started session (issue #32). - const debuggerType = config.debuggerType || 'coreclr'; - if (!await ensureDebuggerExtensionInstalled(debuggerType)) { + // 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; } From 2b7a306c6abc4a0bfa390c85ed622a6bd0629926 Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:09:12 -0700 Subject: [PATCH 3/8] Clarify debugger auto-install: log reason and handle attach failures (#32) Two follow-ups to the choose-your-debugger flow: - When the reused debugger extension (path #2) turns out to be the wrong one for the project, startDebugging resolves false. Detect that, kill the launched process, and show an actionable modal letting the user install the debugger that matches their project, then retry. - When WinApp auto-installs a debugger extension (any path), log the reason to a new 'WinApp Debugger' output channel and notify the user, so it's never a surprise why an extension was added. Extracts promptAndInstallDebuggerChoice so first-run and attach-failure share the same C#/C++ picker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ce7415c-8966-43ec-ad27-59a68e7ed74b --- src/extension.ts | 101 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 79 insertions(+), 22 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index f18f777..b796098 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,13 +23,33 @@ const DEBUGGER_EXTENSION_MAP: Record = { */ const DEFAULT_DEBUGGER_CANDIDATES: string[] = ['coreclr', 'cppvsdbg']; +/** + * 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. + */ +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 }): Promise { +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}…` }, @@ -67,9 +87,42 @@ async function installAndActivateExtension(requirement: { id: string; name: stri } } + 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# vs C/C++). 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 installCsharp = 'Install C# (.NET)'; + const installCpp = 'Install C/C++'; + const choice = await vscode.window.showErrorMessage( + message, + { modal: true }, + installCsharp, + installCpp + ); + + let selected: string | undefined; + if (choice === installCsharp) { + selected = 'coreclr'; + } else if (choice === installCpp) { + selected = 'cppvsdbg'; + } else { + return undefined; + } + + const requirement = DEBUGGER_EXTENSION_MAP[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 an actionable modal offering to install it. @@ -98,7 +151,10 @@ async function ensureDebuggerExtensionInstalled(debuggerType: string): Promise Date: Tue, 14 Jul 2026 14:26:47 -0700 Subject: [PATCH 4/8] Fix debugger selection retry flow Honor debugger recovery choices, add Node/Electron first-run handling, and cover debugger resolver decisions with unit tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ce7415c-8966-43ec-ad27-59a68e7ed74b --- README.md | 4 +- package.json | 2 +- src/debugger-resolver.ts | 55 +++++++ src/extension.ts | 242 ++++++++++++++++------------- src/test/debugger-resolver.test.ts | 68 ++++++++ 5 files changed, 256 insertions(+), 115 deletions(-) create mode 100644 src/debugger-resolver.ts create mode 100644 src/test/debugger-resolver.test.ts diff --git a/README.md b/README.md index e28442a..c8b6f99 100644 --- a/README.md +++ b/README.md @@ -133,11 +133,11 @@ 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` (default) | 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 extension that matches your project (C# or C/C++). +> 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`:** diff --git a/package.json b/package.json index e28ef6b..83aba25 100644 --- a/package.json +++ b/package.json @@ -216,7 +216,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 b796098..e9d9928 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,23 +6,15 @@ 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. - */ -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)' }, -}; - -/** - * Debugger types to consider (in preference order) when a launch configuration - * doesn't specify one, and we need to reuse an already-installed extension. - */ -const DEFAULT_DEBUGGER_CANDIDATES: string[] = ['coreclr', 'cppvsdbg']; - /** * 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. @@ -96,30 +88,28 @@ async function installAndActivateExtension( /** * Show a modal letting the user pick and install the debugger extension that - * matches their project (C# vs C/C++). Used both on first run (no debuggerType) + * 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 installCsharp = 'Install C# (.NET)'; - const installCpp = 'Install C/C++'; const choice = await vscode.window.showErrorMessage( message, { modal: true }, - installCsharp, - installCpp + DEBUGGER_CHOICE_LABELS.installCsharp, + DEBUGGER_CHOICE_LABELS.installCpp, + DEBUGGER_CHOICE_LABELS.useNode ); - let selected: string | undefined; - if (choice === installCsharp) { - selected = 'coreclr'; - } else if (choice === installCpp) { - selected = 'cppvsdbg'; - } else { + const selected = getDebuggerTypeFromChoice(choice); + if (!selected) { return undefined; } - const requirement = DEBUGGER_EXTENSION_MAP[selected]; + const requirement = getDebuggerExtensionRequirement(selected); + if (!requirement) { + return selected; + } return (await installAndActivateExtension(requirement, reason)) ? selected : undefined; } @@ -130,7 +120,7 @@ async function promptAndInstallDebuggerChoice(message: string, reason: string): * 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; } @@ -162,7 +152,7 @@ async function ensureDebuggerExtensionInstalled(debuggerType: string): Promise { @@ -171,18 +161,16 @@ async function resolveDebuggerType(explicitType: string | undefined): Promise 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( - 'To launch your app you need a debugger extension installed. Since no "debuggerType" is set, ' + - 'install the one that matches your project type:', + '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' ); } @@ -383,16 +371,16 @@ 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 @@ -405,105 +393,135 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory return new vscode.DebugAdapterInlineImplementation(new NoOpDebugAdapter()); } - let args = config.args || ''; - if (debuggerType === 'node') { - args = '--inspect' + (config.port ? `=${config.port}` : '') + ' ' + args; - } - - if (args.trim()) { - spawnArgs.push('--args', args.trim()); - } + const buildAttachDebugConfiguration = (currentDebuggerType: string, processId: number): vscode.DebugConfiguration => { + const debugConfiguration: vscode.DebugConfiguration = { + type: currentDebuggerType, + name: config.name || 'Attach to WinApp Package', + request: 'attach' + }; - spawnArgs.push('--json'); + if (currentDebuggerType === 'node') { + debugConfiguration.port = config.port || 9229; + } else { + debugConfiguration.processId = processId; + } - // 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. - const { processId, runProcess } = await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: 'Launching package...', - cancellable: false - }, async (progress) => { - progress.report({ message: 'Running winapp run...' }); + return debugConfiguration; + }; - let cwd = folder.uri.fsPath; - if (config.workingDirectory) { - cwd = config.workingDirectory; + const launchAndAttach = async (currentDebuggerType: string): Promise | undefined> => { + const spawnArgs = [...baseSpawnArgs]; + let args = config.args || ''; + if (currentDebuggerType === 'node') { + args = '--inspect' + (config.port ? `=${config.port}` : '') + ' ' + args; } - return new Promise<{ processId: number; runProcess: ReturnType }>((resolve, reject) => { - const child = spawn(cliPath, spawnArgs, { - cwd, - env: { ...process.env, WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE }, - shell: false - }); + if (args.trim()) { + spawnArgs.push('--args', args.trim()); + } - let stdout = ''; - let stderr = ''; - let resolved = false; + spawnArgs.push('--json'); - child.stdout!.on('data', (data: Buffer) => { - stdout += data.toString(); - if (resolved) { return; } + // 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. + const { processId, runProcess } = await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: 'Launching package...', + cancellable: false + }, async (progress) => { + progress.report({ message: 'Running winapp run...' }); - const pid = parseProcessIdFromJson(stdout); - if (pid) { - resolved = true; - resolve({ processId: pid, runProcess: child }); - } - }); + let cwd = folder.uri.fsPath; + if (config.workingDirectory) { + cwd = config.workingDirectory; + } - child.stderr!.on('data', (data: Buffer) => { - stderr += data.toString(); - console.warn('winapp run stderr:', data.toString()); - }); + return new Promise<{ processId: number; runProcess: ReturnType }>((resolve, reject) => { + const child = spawn(cliPath, spawnArgs, { + cwd, + env: { ...process.env, WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE }, + shell: false + }); + + let stdout = ''; + let stderr = ''; + let resolved = false; + + child.stdout!.on('data', (data: Buffer) => { + stdout += data.toString(); + if (resolved) { return; } + + const pid = parseProcessIdFromJson(stdout); + if (pid) { + resolved = true; + resolve({ processId: pid, runProcess: child }); + } + }); - child.on('error', (err) => { - if (!resolved) { - reject(new Error(`Failed to start winapp run: ${err.message}`)); - } - }); + child.stderr!.on('data', (data: Buffer) => { + stderr += data.toString(); + console.warn('winapp run stderr:', data.toString()); + }); - child.on('close', (code) => { - if (!resolved) { - if (code !== 0) { - reject(new Error(`winapp run exited with code ${code}. stderr: ${stderr}\nstdout: ${stdout}`)); - } else { - reject(new Error(`winapp run exited before returning a process ID. stdout: ${stdout}`)); + child.on('error', (err) => { + if (!resolved) { + reject(new Error(`Failed to start winapp run: ${err.message}`)); + } + }); + + child.on('close', (code) => { + if (!resolved) { + if (code !== 0) { + reject(new Error(`winapp run exited with code ${code}. stderr: ${stderr}\nstdout: ${stdout}`)); + } else { + reject(new Error(`winapp run exited before returning a process ID. stdout: ${stdout}`)); + } } - } + }); }); }); - }); - // Build the attach debug configuration - const debugConfiguration: vscode.DebugConfiguration = { - type: debuggerType, - name: config.name || 'Attach to WinApp Package', - request: 'attach' - }; + const debugConfiguration = buildAttachDebugConfiguration(currentDebuggerType, processId); + try { + const started = await vscode.debug.startDebugging(folder, debugConfiguration, { parentSession: session }); + if (!started) { + runProcess.kill(); + return undefined; + } + } catch (error) { + runProcess.kill(); + throw error; + } - if (debuggerType === 'node') { - debugConfiguration.port = config.port || 9229; - } else { - debugConfiguration.processId = processId; - } + return runProcess; + }; // 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 = DEBUGGER_EXTENSION_MAP[debuggerType]?.name ?? `"${debuggerType}"`; - await promptAndInstallDebuggerChoice( + let runProcess = await launchAndAttach(debuggerType); + if (!runProcess) { + const debuggerName = getDebuggerExtensionRequirement(debuggerType)?.name ?? `"${debuggerType}"`; + const retryDebuggerType = 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:`, + `Choose the debugger that matches your project so WinApp can retry:`, 'you selected it after the previous debugger failed to attach' ); - return new vscode.DebugAdapterInlineImplementation(new NoOpDebugAdapter()); + if (retryDebuggerType) { + runProcess = await launchAndAttach(retryDebuggerType); + if (!runProcess) { + const retryDebuggerName = getDebuggerExtensionRequirement(retryDebuggerType)?.name ?? `"${retryDebuggerType}"`; + vscode.window.showErrorMessage( + `The ${retryDebuggerName} debugger still couldn't attach to your app. Check your project type and debugger configuration, then try again.` + ); + } + } + + if (!runProcess) { + return new vscode.DebugAdapterInlineImplementation(new NoOpDebugAdapter()); + } } // When the child debug session ends, kill the winapp run process and stop the parent session 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); + }); + }); +}); From 2813064a43c1a7080978aad51a5d21ff257f0c3a Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:45:13 -0700 Subject: [PATCH 5/8] Infer debugger type before reuse Remove hard-coded coreclr launch defaults, infer debugger type from project files before installed-extension reuse, and detect immediate child attach termination for retry recovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ce7415c-8966-43ec-ad27-59a68e7ed74b --- README.md | 4 +- package.json | 11 +++-- src/debugger-resolver.ts | 33 ++++++++++++++ src/extension.ts | 69 ++++++++++++++++++++++++++++-- src/test/debugger-resolver.test.ts | 30 ++++++++++++- 5 files changed, 136 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c8b6f99..adfdcaf 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ The extension provides a **custom `winapp` debug type** that launches your app w | `debuggerType` | Language | Required Extension | |----------------|----------|--------------------| -| `coreclr` (default) | C# / .NET | [C#](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 | @@ -160,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 infers or prompts for the best debugger. | | `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 83aba25..3f2eb67 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 infer or prompt for the best debugger ('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" } } ] diff --git a/src/debugger-resolver.ts b/src/debugger-resolver.ts index 2d66980..ccd3eb2 100644 --- a/src/debugger-resolver.ts +++ b/src/debugger-resolver.ts @@ -53,3 +53,36 @@ export function getDebuggerTypeFromChoice(choice: string | undefined): string | } return undefined; } + +/** + * Infers the debugger from project files only when exactly one project family is + * present. Precedence is deliberately conservative: .NET, C++ and Node signals + * are collected first, and mixed families return undefined so the installed + * debugger reuse/picker path can decide instead of guessing. + */ +export function inferDebuggerTypeFromProject(fileNames: readonly string[]): string | undefined { + let hasDotNet = false; + let hasCpp = false; + let hasNode = false; + + for (const fileName of fileNames) { + const normalized = fileName.replace(/\\/g, '/').toLowerCase(); + const baseName = normalized.substring(normalized.lastIndexOf('/') + 1); + + if (baseName.endsWith('.csproj') || baseName.endsWith('.fsproj') || baseName.endsWith('.vbproj')) { + hasDotNet = true; + } else if (baseName.endsWith('.vcxproj')) { + hasCpp = true; + } else if (baseName === 'package.json') { + hasNode = true; + } + } + + const matches = [ + hasDotNet ? 'coreclr' : undefined, + hasCpp ? 'cppvsdbg' : undefined, + hasNode ? 'node' : undefined + ].filter((debuggerType): debuggerType is string => debuggerType !== undefined); + + return matches.length === 1 ? matches[0] : undefined; +} diff --git a/src/extension.ts b/src/extension.ts index e9d9928..5e185eb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,7 +10,8 @@ import { DEBUGGER_CHOICE_LABELS, chooseInstalledDebuggerType, getDebuggerExtensionRequirement, - getDebuggerTypeFromChoice + getDebuggerTypeFromChoice, + inferDebuggerTypeFromProject } from './debugger-resolver'; const WINAPP_DEBUG_TYPE = 'winapp'; @@ -155,12 +156,32 @@ async function ensureDebuggerExtensionInstalled(debuggerType: string): Promise { +async function resolveDebuggerType( + explicitType: string | undefined, + folder: vscode.WorkspaceFolder | undefined +): Promise { if (explicitType) { return (await ensureDebuggerExtensionInstalled(explicitType)) ? explicitType : undefined; } - // No debuggerType specified: reuse an already-installed debugger extension. + // No debuggerType specified: infer from project files before falling back to + // installed extension reuse so Node/Electron projects are not misclassified + // just because a C# or C++ debugger extension is already installed. + if (folder) { + const projectFiles = await glob(['**/*.csproj', '**/*.fsproj', '**/*.vbproj', '**/*.vcxproj', '**/package.json'], { + cwd: folder.uri.fsPath, + absolute: false, + nocase: true, + ignore: ['**/node_modules/**', '**/.git/**', '**/obj/**', '**/bin/**', '**/dist/**', '**/out/**', '**/.vs/**', '**/AppX/**', '**/.winapp/**', '**/packages/**'] + }); + const inferredType = inferDebuggerTypeFromProject(projectFiles); + if (inferredType) { + return (await ensureDebuggerExtensionInstalled(inferredType)) ? inferredType : undefined; + } + } + + // Reuse an already-installed debugger extension only when project inference + // is unavailable or ambiguous. const installedCandidate = chooseInstalledDebuggerType(vscode.extensions.all.map(extension => extension.id)); if (installedCandidate) { return installedCandidate; @@ -286,7 +307,7 @@ class WinAppDebugConfigurationProvider implements vscode.DebugConfigurationProvi // 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); + const debuggerType = await resolveDebuggerType(config.debuggerType, folder); if (!debuggerType) { return undefined; } @@ -481,18 +502,58 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory }); }); + let runProcessExited = false; + let earlyWatchSettled = false; + let earlyWatchTimeout: ReturnType | undefined; + let resolveEarlyAttachFailure: (failed: boolean) => void = () => { }; + let terminateDisposable: vscode.Disposable | undefined; + const finishEarlyAttachWatch = (failed: boolean): void => { + if (earlyWatchSettled) { + return; + } + earlyWatchSettled = true; + if (earlyWatchTimeout) { + clearTimeout(earlyWatchTimeout); + } + terminateDisposable?.dispose(); + resolveEarlyAttachFailure(failed); + }; + + const earlyAttachFailure = new Promise((resolve) => { + resolveEarlyAttachFailure = resolve; + earlyWatchTimeout = setTimeout(() => finishEarlyAttachWatch(false), 1500); + }); + + runProcess.once('close', () => { + runProcessExited = true; + finishEarlyAttachWatch(false); + }); + + terminateDisposable = vscode.debug.onDidTerminateDebugSession((ended) => { + if (ended.parentSession === session && !runProcessExited) { + finishEarlyAttachWatch(true); + } + }); + const debugConfiguration = buildAttachDebugConfiguration(currentDebuggerType, processId); try { const started = await vscode.debug.startDebugging(folder, debugConfiguration, { parentSession: session }); if (!started) { + finishEarlyAttachWatch(false); runProcess.kill(); return undefined; } } catch (error) { + finishEarlyAttachWatch(false); runProcess.kill(); throw error; } + if (await earlyAttachFailure) { + runProcess.kill(); + return undefined; + } + return runProcess; }; diff --git a/src/test/debugger-resolver.test.ts b/src/test/debugger-resolver.test.ts index 95827d9..4c32b7c 100644 --- a/src/test/debugger-resolver.test.ts +++ b/src/test/debugger-resolver.test.ts @@ -4,7 +4,8 @@ import { DEBUGGER_CHOICE_LABELS, chooseInstalledDebuggerType, getDebuggerExtensionRequirement, - getDebuggerTypeFromChoice + getDebuggerTypeFromChoice, + inferDebuggerTypeFromProject } from '../debugger-resolver'; describe('debugger resolver helpers', () => { @@ -65,4 +66,31 @@ describe('debugger resolver helpers', () => { assert.equal(getDebuggerTypeFromChoice('Cancel'), undefined); }); }); + + describe('inferDebuggerTypeFromProject', () => { + it('infers coreclr for pure .NET project files', () => { + assert.equal(inferDebuggerTypeFromProject(['src/App.csproj']), 'coreclr'); + assert.equal(inferDebuggerTypeFromProject(['src/Library.fsproj']), 'coreclr'); + }); + + it('infers cppvsdbg for pure C++ project files', () => { + assert.equal(inferDebuggerTypeFromProject(['native/App.vcxproj']), 'cppvsdbg'); + }); + + it('infers node for package.json or Electron-style Node projects without .NET or C++ projects', () => { + assert.equal(inferDebuggerTypeFromProject(['package.json']), 'node'); + assert.equal(inferDebuggerTypeFromProject(['apps/electron/package.json']), 'node'); + }); + + it('returns undefined for mixed or ambiguous project families', () => { + assert.equal(inferDebuggerTypeFromProject(['App.csproj', 'package.json']), undefined); + assert.equal(inferDebuggerTypeFromProject(['App.csproj', 'Native.vcxproj']), undefined); + assert.equal(inferDebuggerTypeFromProject(['Native.vcxproj', 'package.json']), undefined); + }); + + it('returns undefined for empty or unknown file listings', () => { + assert.equal(inferDebuggerTypeFromProject([]), undefined); + assert.equal(inferDebuggerTypeFromProject(['README.md', 'src/app.ts']), undefined); + }); + }); }); From 76d24fbf7c06fd7f1e50b73c8f408aa85e72f758 Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:54:45 -0700 Subject: [PATCH 6/8] Conserve node debugger inference Avoid classifying native projects with package.json as Node and make parent debug cleanup reliable when winapp run exits during the early attach watch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ce7415c-8966-43ec-ad27-59a68e7ed74b --- src/debugger-resolver.ts | 17 +++++++--- src/extension.ts | 51 ++++++++++++++++++++++-------- src/test/debugger-resolver.test.ts | 5 ++- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/debugger-resolver.ts b/src/debugger-resolver.ts index ccd3eb2..a1b86c7 100644 --- a/src/debugger-resolver.ts +++ b/src/debugger-resolver.ts @@ -55,15 +55,18 @@ export function getDebuggerTypeFromChoice(choice: string | undefined): string | } /** - * Infers the debugger from project files only when exactly one project family is - * present. Precedence is deliberately conservative: .NET, C++ and Node signals - * are collected first, and mixed families return undefined so the installed - * debugger reuse/picker path can decide instead of guessing. + * Infers the debugger from project files only when exactly one supported project + * family is present. Precedence is deliberately conservative: .NET and C++ + * project files map directly to their debuggers, while package.json maps to + * Node only when there are no competing native signals such as Cargo.toml or + * tauri.conf.json. Mixed families return undefined so the installed debugger + * reuse/picker path can decide instead of guessing. */ export function inferDebuggerTypeFromProject(fileNames: readonly string[]): string | undefined { let hasDotNet = false; let hasCpp = false; let hasNode = false; + let hasOtherNative = false; for (const fileName of fileNames) { const normalized = fileName.replace(/\\/g, '/').toLowerCase(); @@ -75,9 +78,15 @@ export function inferDebuggerTypeFromProject(fileNames: readonly string[]): stri hasCpp = true; } else if (baseName === 'package.json') { hasNode = true; + } else if (baseName === 'cargo.toml' || baseName === 'tauri.conf.json') { + hasOtherNative = true; } } + if (hasNode && hasOtherNative) { + return undefined; + } + const matches = [ hasDotNet ? 'coreclr' : undefined, hasCpp ? 'cppvsdbg' : undefined, diff --git a/src/extension.ts b/src/extension.ts index 5e185eb..301e02f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -168,7 +168,7 @@ async function resolveDebuggerType( // installed extension reuse so Node/Electron projects are not misclassified // just because a C# or C++ debugger extension is already installed. if (folder) { - const projectFiles = await glob(['**/*.csproj', '**/*.fsproj', '**/*.vbproj', '**/*.vcxproj', '**/package.json'], { + const projectFiles = await glob(['**/*.csproj', '**/*.fsproj', '**/*.vbproj', '**/*.vcxproj', '**/package.json', '**/Cargo.toml', '**/tauri.conf.json'], { cwd: folder.uri.fsPath, absolute: false, nocase: true, @@ -430,7 +430,12 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory return debugConfiguration; }; - const launchAndAttach = async (currentDebuggerType: string): Promise | undefined> => { + interface LaunchAttachResult { + runProcess: ReturnType; + hasExited: () => boolean; + } + + const launchAndAttach = async (currentDebuggerType: string): Promise => { const spawnArgs = [...baseSpawnArgs]; let args = config.args || ''; if (currentDebuggerType === 'node') { @@ -554,15 +559,18 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory return undefined; } - return runProcess; + return { + runProcess, + hasExited: () => runProcessExited + }; }; // 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). - let runProcess = await launchAndAttach(debuggerType); - if (!runProcess) { + let launchResult = await launchAndAttach(debuggerType); + if (!launchResult) { const debuggerName = getDebuggerExtensionRequirement(debuggerType)?.name ?? `"${debuggerType}"`; const retryDebuggerType = await promptAndInstallDebuggerChoice( `The ${debuggerName} debugger couldn't attach to your app. ` + @@ -571,8 +579,8 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory 'you selected it after the previous debugger failed to attach' ); if (retryDebuggerType) { - runProcess = await launchAndAttach(retryDebuggerType); - if (!runProcess) { + launchResult = await launchAndAttach(retryDebuggerType); + if (!launchResult) { const retryDebuggerName = getDebuggerExtensionRequirement(retryDebuggerType)?.name ?? `"${retryDebuggerType}"`; vscode.window.showErrorMessage( `The ${retryDebuggerName} debugger still couldn't attach to your app. Check your project type and debugger configuration, then try again.` @@ -580,25 +588,42 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory } } - if (!runProcess) { + if (!launchResult) { return new vscode.DebugAdapterInlineImplementation(new NoOpDebugAdapter()); } } + const { runProcess, hasExited: hasRunProcessExited } = launchResult; + // When the child debug session ends, kill the winapp run process and stop the parent session const parentSession = session; + let parentStopRequested = false; + const stopParentDebugging = (): void => { + if (parentStopRequested) { + return; + } + parentStopRequested = true; + vscode.debug.stopDebugging(parentSession); + }; + const disposable = vscode.debug.onDidTerminateDebugSession((ended) => { if (ended.parentSession === parentSession) { disposable.dispose(); - runProcess.kill(); - vscode.debug.stopDebugging(parentSession); + if (!hasRunProcessExited()) { + runProcess.kill(); + } + stopParentDebugging(); } }); // When the winapp run process exits (app closed), stop the debug session - runProcess.on('close', () => { - vscode.debug.stopDebugging(parentSession); - }); + if (hasRunProcessExited()) { + stopParentDebugging(); + } else { + runProcess.once('close', () => { + stopParentDebugging(); + }); + } // Return an inline no-op adapter — the real debugging happens in the child session above return new vscode.DebugAdapterInlineImplementation(new NoOpDebugAdapter()); diff --git a/src/test/debugger-resolver.test.ts b/src/test/debugger-resolver.test.ts index 4c32b7c..f25027b 100644 --- a/src/test/debugger-resolver.test.ts +++ b/src/test/debugger-resolver.test.ts @@ -77,7 +77,7 @@ describe('debugger resolver helpers', () => { assert.equal(inferDebuggerTypeFromProject(['native/App.vcxproj']), 'cppvsdbg'); }); - it('infers node for package.json or Electron-style Node projects without .NET or C++ projects', () => { + it('infers node for package.json or Electron-style Node projects without native signals', () => { assert.equal(inferDebuggerTypeFromProject(['package.json']), 'node'); assert.equal(inferDebuggerTypeFromProject(['apps/electron/package.json']), 'node'); }); @@ -85,7 +85,10 @@ describe('debugger resolver helpers', () => { it('returns undefined for mixed or ambiguous project families', () => { assert.equal(inferDebuggerTypeFromProject(['App.csproj', 'package.json']), undefined); assert.equal(inferDebuggerTypeFromProject(['App.csproj', 'Native.vcxproj']), undefined); + // C++ plus package.json is ambiguous: it could be a native app with JS tooling. assert.equal(inferDebuggerTypeFromProject(['Native.vcxproj', 'package.json']), undefined); + assert.equal(inferDebuggerTypeFromProject(['package.json', 'Cargo.toml']), undefined); + assert.equal(inferDebuggerTypeFromProject(['src-tauri/tauri.conf.json', 'package.json']), undefined); }); it('returns undefined for empty or unknown file listings', () => { From 42e8bfd029c47b51f6d99b6103072b4e2ee44ca3 Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:02:53 -0700 Subject: [PATCH 7/8] Refine debugger inference edge cases Treat CMake and other native markers as ambiguity for Node inference, and wait briefly for winapp run to exit before classifying early child debugger termination as attach failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ce7415c-8966-43ec-ad27-59a68e7ed74b --- src/debugger-resolver.ts | 12 +++++++----- src/extension.ts | 16 ++++++++++++++-- src/test/debugger-resolver.test.ts | 1 + 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/debugger-resolver.ts b/src/debugger-resolver.ts index a1b86c7..7b9a800 100644 --- a/src/debugger-resolver.ts +++ b/src/debugger-resolver.ts @@ -58,9 +58,9 @@ export function getDebuggerTypeFromChoice(choice: string | undefined): string | * Infers the debugger from project files only when exactly one supported project * family is present. Precedence is deliberately conservative: .NET and C++ * project files map directly to their debuggers, while package.json maps to - * Node only when there are no competing native signals such as Cargo.toml or - * tauri.conf.json. Mixed families return undefined so the installed debugger - * reuse/picker path can decide instead of guessing. + * Node only when there are no competing native/other-language signals. Mixed + * families return undefined so the installed debugger reuse/picker path can + * decide instead of guessing. */ export function inferDebuggerTypeFromProject(fileNames: readonly string[]): string | undefined { let hasDotNet = false; @@ -78,12 +78,14 @@ export function inferDebuggerTypeFromProject(fileNames: readonly string[]): stri hasCpp = true; } else if (baseName === 'package.json') { hasNode = true; - } else if (baseName === 'cargo.toml' || baseName === 'tauri.conf.json') { + // Add new native build-system markers here so package.json used for JS + // tooling does not cause native apps to be inferred as Node/Electron. + } else if (baseName === 'cargo.toml' || baseName === 'tauri.conf.json' || baseName === 'cmakelists.txt') { hasOtherNative = true; } } - if (hasNode && hasOtherNative) { + if (hasNode && (hasDotNet || hasCpp || hasOtherNative)) { return undefined; } diff --git a/src/extension.ts b/src/extension.ts index 301e02f..0c2afe6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -168,7 +168,7 @@ async function resolveDebuggerType( // installed extension reuse so Node/Electron projects are not misclassified // just because a C# or C++ debugger extension is already installed. if (folder) { - const projectFiles = await glob(['**/*.csproj', '**/*.fsproj', '**/*.vbproj', '**/*.vcxproj', '**/package.json', '**/Cargo.toml', '**/tauri.conf.json'], { + const projectFiles = await glob(['**/*.csproj', '**/*.fsproj', '**/*.vbproj', '**/*.vcxproj', '**/package.json', '**/Cargo.toml', '**/tauri.conf.json', '**/CMakeLists.txt'], { cwd: folder.uri.fsPath, absolute: false, nocase: true, @@ -510,6 +510,7 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory let runProcessExited = false; let earlyWatchSettled = false; let earlyWatchTimeout: ReturnType | undefined; + let earlyTerminationGraceTimeout: ReturnType | undefined; let resolveEarlyAttachFailure: (failed: boolean) => void = () => { }; let terminateDisposable: vscode.Disposable | undefined; const finishEarlyAttachWatch = (failed: boolean): void => { @@ -520,6 +521,9 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory if (earlyWatchTimeout) { clearTimeout(earlyWatchTimeout); } + if (earlyTerminationGraceTimeout) { + clearTimeout(earlyTerminationGraceTimeout); + } terminateDisposable?.dispose(); resolveEarlyAttachFailure(failed); }; @@ -536,7 +540,15 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory terminateDisposable = vscode.debug.onDidTerminateDebugSession((ended) => { if (ended.parentSession === session && !runProcessExited) { - finishEarlyAttachWatch(true); + if (earlyWatchTimeout) { + clearTimeout(earlyWatchTimeout); + earlyWatchTimeout = undefined; + } + if (!earlyTerminationGraceTimeout) { + earlyTerminationGraceTimeout = setTimeout(() => { + finishEarlyAttachWatch(!runProcessExited); + }, 900); + } } }); diff --git a/src/test/debugger-resolver.test.ts b/src/test/debugger-resolver.test.ts index f25027b..2f726e9 100644 --- a/src/test/debugger-resolver.test.ts +++ b/src/test/debugger-resolver.test.ts @@ -89,6 +89,7 @@ describe('debugger resolver helpers', () => { assert.equal(inferDebuggerTypeFromProject(['Native.vcxproj', 'package.json']), undefined); assert.equal(inferDebuggerTypeFromProject(['package.json', 'Cargo.toml']), undefined); assert.equal(inferDebuggerTypeFromProject(['src-tauri/tauri.conf.json', 'package.json']), undefined); + assert.equal(inferDebuggerTypeFromProject(['CMakeLists.txt', 'package.json']), undefined); }); it('returns undefined for empty or unknown file listings', () => { From 5273da4b847954ba815627d3e17c6d57b2185a39 Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:45:16 -0700 Subject: [PATCH 8/8] Scope PR to issue #32: drop project inference and async attach retry Remove changes introduced during the review loop that were outside the scope of issue #32 (auto-install the required debugger extension on first F5/WinApp debug): - Remove project-type inference (inferDebuggerTypeFromProject) from debugger-resolver.ts, extension.ts (resolveDebuggerType no longer takes a folder), and its unit tests. When no debuggerType is set, WinApp reuses an installed debugger or prompts the user to pick one. - Revert the debug-adapter factory to the simpler attach flow: launch the app, start the child debug session, and on a false result kill the run process and surface the choose-your-debugger prompt. This drops the async early-termination detection, grace period, and auto-retry loop. - Update README/package.json wording to remove "infers". The orphaned-process teardown hardening is moved to a separate PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ce7415c-8966-43ec-ad27-59a68e7ed74b --- README.md | 2 +- package.json | 2 +- src/debugger-resolver.ts | 44 ----- src/extension.ts | 292 ++++++++--------------------- src/test/debugger-resolver.test.ts | 34 +--- 5 files changed, 85 insertions(+), 289 deletions(-) diff --git a/README.md b/README.md index adfdcaf..63d163a 100644 --- a/README.md +++ b/README.md @@ -160,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 | | Optional underlying debugger override (`coreclr`, `cppvsdbg`, or `node`). If omitted, WinApp infers or prompts for the best debugger. | +| `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 3f2eb67..a31cbc6 100644 --- a/package.json +++ b/package.json @@ -168,7 +168,7 @@ "cppvsdbg", "node" ], - "description": "Optional override for the underlying debugger to use. Omit to let WinApp infer or prompt for the best debugger ('coreclr', 'cppvsdbg', or '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", diff --git a/src/debugger-resolver.ts b/src/debugger-resolver.ts index 7b9a800..2d66980 100644 --- a/src/debugger-resolver.ts +++ b/src/debugger-resolver.ts @@ -53,47 +53,3 @@ export function getDebuggerTypeFromChoice(choice: string | undefined): string | } return undefined; } - -/** - * Infers the debugger from project files only when exactly one supported project - * family is present. Precedence is deliberately conservative: .NET and C++ - * project files map directly to their debuggers, while package.json maps to - * Node only when there are no competing native/other-language signals. Mixed - * families return undefined so the installed debugger reuse/picker path can - * decide instead of guessing. - */ -export function inferDebuggerTypeFromProject(fileNames: readonly string[]): string | undefined { - let hasDotNet = false; - let hasCpp = false; - let hasNode = false; - let hasOtherNative = false; - - for (const fileName of fileNames) { - const normalized = fileName.replace(/\\/g, '/').toLowerCase(); - const baseName = normalized.substring(normalized.lastIndexOf('/') + 1); - - if (baseName.endsWith('.csproj') || baseName.endsWith('.fsproj') || baseName.endsWith('.vbproj')) { - hasDotNet = true; - } else if (baseName.endsWith('.vcxproj')) { - hasCpp = true; - } else if (baseName === 'package.json') { - hasNode = true; - // Add new native build-system markers here so package.json used for JS - // tooling does not cause native apps to be inferred as Node/Electron. - } else if (baseName === 'cargo.toml' || baseName === 'tauri.conf.json' || baseName === 'cmakelists.txt') { - hasOtherNative = true; - } - } - - if (hasNode && (hasDotNet || hasCpp || hasOtherNative)) { - return undefined; - } - - const matches = [ - hasDotNet ? 'coreclr' : undefined, - hasCpp ? 'cppvsdbg' : undefined, - hasNode ? 'node' : undefined - ].filter((debuggerType): debuggerType is string => debuggerType !== undefined); - - return matches.length === 1 ? matches[0] : undefined; -} diff --git a/src/extension.ts b/src/extension.ts index 0c2afe6..e01941f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,8 +10,7 @@ import { DEBUGGER_CHOICE_LABELS, chooseInstalledDebuggerType, getDebuggerExtensionRequirement, - getDebuggerTypeFromChoice, - inferDebuggerTypeFromProject + getDebuggerTypeFromChoice } from './debugger-resolver'; const WINAPP_DEBUG_TYPE = 'winapp'; @@ -156,32 +155,12 @@ async function ensureDebuggerExtensionInstalled(debuggerType: string): Promise { +async function resolveDebuggerType(explicitType: string | undefined): Promise { if (explicitType) { return (await ensureDebuggerExtensionInstalled(explicitType)) ? explicitType : undefined; } - // No debuggerType specified: infer from project files before falling back to - // installed extension reuse so Node/Electron projects are not misclassified - // just because a C# or C++ debugger extension is already installed. - if (folder) { - const projectFiles = await glob(['**/*.csproj', '**/*.fsproj', '**/*.vbproj', '**/*.vcxproj', '**/package.json', '**/Cargo.toml', '**/tauri.conf.json', '**/CMakeLists.txt'], { - cwd: folder.uri.fsPath, - absolute: false, - nocase: true, - ignore: ['**/node_modules/**', '**/.git/**', '**/obj/**', '**/bin/**', '**/dist/**', '**/out/**', '**/.vs/**', '**/AppX/**', '**/.winapp/**', '**/packages/**'] - }); - const inferredType = inferDebuggerTypeFromProject(projectFiles); - if (inferredType) { - return (await ensureDebuggerExtensionInstalled(inferredType)) ? inferredType : undefined; - } - } - - // Reuse an already-installed debugger extension only when project inference - // is unavailable or ambiguous. + // No debuggerType specified: reuse an already-installed debugger extension. const installedCandidate = chooseInstalledDebuggerType(vscode.extensions.all.map(extension => extension.id)); if (installedCandidate) { return installedCandidate; @@ -292,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 { @@ -307,7 +286,7 @@ class WinAppDebugConfigurationProvider implements vscode.DebugConfigurationProvi // 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, folder); + const debuggerType = await resolveDebuggerType(config.debuggerType); if (!debuggerType) { return undefined; } @@ -414,228 +393,121 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory return new vscode.DebugAdapterInlineImplementation(new NoOpDebugAdapter()); } - const buildAttachDebugConfiguration = (currentDebuggerType: string, processId: number): vscode.DebugConfiguration => { - const debugConfiguration: vscode.DebugConfiguration = { - type: currentDebuggerType, - name: config.name || 'Attach to WinApp Package', - request: 'attach' - }; - - if (currentDebuggerType === 'node') { - debugConfiguration.port = config.port || 9229; - } else { - debugConfiguration.processId = processId; - } - - return debugConfiguration; - }; - - interface LaunchAttachResult { - runProcess: ReturnType; - hasExited: () => boolean; + let args = config.args || ''; + if (debuggerType === 'node') { + args = '--inspect' + (config.port ? `=${config.port}` : '') + ' ' + args; } - const launchAndAttach = async (currentDebuggerType: string): Promise => { - const spawnArgs = [...baseSpawnArgs]; - let args = config.args || ''; - if (currentDebuggerType === 'node') { - args = '--inspect' + (config.port ? `=${config.port}` : '') + ' ' + args; - } + if (args.trim()) { + baseSpawnArgs.push('--args', args.trim()); + } - if (args.trim()) { - spawnArgs.push('--args', args.trim()); - } + baseSpawnArgs.push('--json'); - spawnArgs.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. + const { processId, runProcess } = await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: 'Launching package...', + cancellable: false + }, async (progress) => { + progress.report({ message: 'Running winapp run...' }); - // 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. - const { processId, runProcess } = await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: 'Launching package...', - cancellable: false - }, async (progress) => { - progress.report({ message: 'Running winapp run...' }); + let cwd = folder.uri.fsPath; + if (config.workingDirectory) { + cwd = config.workingDirectory; + } - let cwd = folder.uri.fsPath; - if (config.workingDirectory) { - cwd = config.workingDirectory; - } + return new Promise<{ processId: number; runProcess: ReturnType }>((resolve, reject) => { + const child = spawn(cliPath, baseSpawnArgs, { + cwd, + env: { ...process.env, WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE }, + shell: false + }); - return new Promise<{ processId: number; runProcess: ReturnType }>((resolve, reject) => { - const child = spawn(cliPath, spawnArgs, { - cwd, - env: { ...process.env, WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE }, - shell: false - }); - - let stdout = ''; - let stderr = ''; - let resolved = false; - - child.stdout!.on('data', (data: Buffer) => { - stdout += data.toString(); - if (resolved) { return; } - - const pid = parseProcessIdFromJson(stdout); - if (pid) { - resolved = true; - resolve({ processId: pid, runProcess: child }); - } - }); + let stdout = ''; + let stderr = ''; + let resolved = false; - child.stderr!.on('data', (data: Buffer) => { - stderr += data.toString(); - console.warn('winapp run stderr:', data.toString()); - }); + child.stdout!.on('data', (data: Buffer) => { + stdout += data.toString(); + if (resolved) { return; } - child.on('error', (err) => { - if (!resolved) { - reject(new Error(`Failed to start winapp run: ${err.message}`)); - } - }); - - child.on('close', (code) => { - if (!resolved) { - if (code !== 0) { - reject(new Error(`winapp run exited with code ${code}. stderr: ${stderr}\nstdout: ${stdout}`)); - } else { - reject(new Error(`winapp run exited before returning a process ID. stdout: ${stdout}`)); - } - } - }); + const pid = parseProcessIdFromJson(stdout); + if (pid) { + resolved = true; + resolve({ processId: pid, runProcess: child }); + } }); - }); - - let runProcessExited = false; - let earlyWatchSettled = false; - let earlyWatchTimeout: ReturnType | undefined; - let earlyTerminationGraceTimeout: ReturnType | undefined; - let resolveEarlyAttachFailure: (failed: boolean) => void = () => { }; - let terminateDisposable: vscode.Disposable | undefined; - const finishEarlyAttachWatch = (failed: boolean): void => { - if (earlyWatchSettled) { - return; - } - earlyWatchSettled = true; - if (earlyWatchTimeout) { - clearTimeout(earlyWatchTimeout); - } - if (earlyTerminationGraceTimeout) { - clearTimeout(earlyTerminationGraceTimeout); - } - terminateDisposable?.dispose(); - resolveEarlyAttachFailure(failed); - }; - - const earlyAttachFailure = new Promise((resolve) => { - resolveEarlyAttachFailure = resolve; - earlyWatchTimeout = setTimeout(() => finishEarlyAttachWatch(false), 1500); - }); - runProcess.once('close', () => { - runProcessExited = true; - finishEarlyAttachWatch(false); - }); + child.stderr!.on('data', (data: Buffer) => { + stderr += data.toString(); + console.warn('winapp run stderr:', data.toString()); + }); - terminateDisposable = vscode.debug.onDidTerminateDebugSession((ended) => { - if (ended.parentSession === session && !runProcessExited) { - if (earlyWatchTimeout) { - clearTimeout(earlyWatchTimeout); - earlyWatchTimeout = undefined; + child.on('error', (err) => { + if (!resolved) { + reject(new Error(`Failed to start winapp run: ${err.message}`)); } - if (!earlyTerminationGraceTimeout) { - earlyTerminationGraceTimeout = setTimeout(() => { - finishEarlyAttachWatch(!runProcessExited); - }, 900); + }); + + child.on('close', (code) => { + if (!resolved) { + if (code !== 0) { + reject(new Error(`winapp run exited with code ${code}. stderr: ${stderr}\nstdout: ${stdout}`)); + } else { + reject(new Error(`winapp run exited before returning a process ID. stdout: ${stdout}`)); + } } - } + }); }); + }); - const debugConfiguration = buildAttachDebugConfiguration(currentDebuggerType, processId); - try { - const started = await vscode.debug.startDebugging(folder, debugConfiguration, { parentSession: session }); - if (!started) { - finishEarlyAttachWatch(false); - runProcess.kill(); - return undefined; - } - } catch (error) { - finishEarlyAttachWatch(false); - runProcess.kill(); - throw error; - } - - if (await earlyAttachFailure) { - runProcess.kill(); - return undefined; - } - - return { - runProcess, - hasExited: () => runProcessExited - }; + // Build the attach debug configuration + const debugConfiguration: vscode.DebugConfiguration = { + type: debuggerType, + name: config.name || 'Attach to WinApp Package', + request: 'attach' }; + if (debuggerType === 'node') { + debugConfiguration.port = config.port || 9229; + } else { + debugConfiguration.processId = processId; + } + // 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). - let launchResult = await launchAndAttach(debuggerType); - if (!launchResult) { + // 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}"`; - const retryDebuggerType = await promptAndInstallDebuggerChoice( + await promptAndInstallDebuggerChoice( `The ${debuggerName} debugger couldn't attach to your app. ` + `This usually means the installed debugger extension doesn't match your project type. ` + - `Choose the debugger that matches your project so WinApp can retry:`, + `Install the debugger that matches your project, then start debugging again:`, 'you selected it after the previous debugger failed to attach' ); - if (retryDebuggerType) { - launchResult = await launchAndAttach(retryDebuggerType); - if (!launchResult) { - const retryDebuggerName = getDebuggerExtensionRequirement(retryDebuggerType)?.name ?? `"${retryDebuggerType}"`; - vscode.window.showErrorMessage( - `The ${retryDebuggerName} debugger still couldn't attach to your app. Check your project type and debugger configuration, then try again.` - ); - } - } - - if (!launchResult) { - return new vscode.DebugAdapterInlineImplementation(new NoOpDebugAdapter()); - } + return new vscode.DebugAdapterInlineImplementation(new NoOpDebugAdapter()); } - const { runProcess, hasExited: hasRunProcessExited } = launchResult; - // When the child debug session ends, kill the winapp run process and stop the parent session const parentSession = session; - let parentStopRequested = false; - const stopParentDebugging = (): void => { - if (parentStopRequested) { - return; - } - parentStopRequested = true; - vscode.debug.stopDebugging(parentSession); - }; - const disposable = vscode.debug.onDidTerminateDebugSession((ended) => { if (ended.parentSession === parentSession) { disposable.dispose(); - if (!hasRunProcessExited()) { - runProcess.kill(); - } - stopParentDebugging(); + runProcess.kill(); + vscode.debug.stopDebugging(parentSession); } }); // When the winapp run process exits (app closed), stop the debug session - if (hasRunProcessExited()) { - stopParentDebugging(); - } else { - runProcess.once('close', () => { - stopParentDebugging(); - }); - } + runProcess.on('close', () => { + vscode.debug.stopDebugging(parentSession); + }); // Return an inline no-op adapter — the real debugging happens in the child session above return new vscode.DebugAdapterInlineImplementation(new NoOpDebugAdapter()); diff --git a/src/test/debugger-resolver.test.ts b/src/test/debugger-resolver.test.ts index 2f726e9..95827d9 100644 --- a/src/test/debugger-resolver.test.ts +++ b/src/test/debugger-resolver.test.ts @@ -4,8 +4,7 @@ import { DEBUGGER_CHOICE_LABELS, chooseInstalledDebuggerType, getDebuggerExtensionRequirement, - getDebuggerTypeFromChoice, - inferDebuggerTypeFromProject + getDebuggerTypeFromChoice } from '../debugger-resolver'; describe('debugger resolver helpers', () => { @@ -66,35 +65,4 @@ describe('debugger resolver helpers', () => { assert.equal(getDebuggerTypeFromChoice('Cancel'), undefined); }); }); - - describe('inferDebuggerTypeFromProject', () => { - it('infers coreclr for pure .NET project files', () => { - assert.equal(inferDebuggerTypeFromProject(['src/App.csproj']), 'coreclr'); - assert.equal(inferDebuggerTypeFromProject(['src/Library.fsproj']), 'coreclr'); - }); - - it('infers cppvsdbg for pure C++ project files', () => { - assert.equal(inferDebuggerTypeFromProject(['native/App.vcxproj']), 'cppvsdbg'); - }); - - it('infers node for package.json or Electron-style Node projects without native signals', () => { - assert.equal(inferDebuggerTypeFromProject(['package.json']), 'node'); - assert.equal(inferDebuggerTypeFromProject(['apps/electron/package.json']), 'node'); - }); - - it('returns undefined for mixed or ambiguous project families', () => { - assert.equal(inferDebuggerTypeFromProject(['App.csproj', 'package.json']), undefined); - assert.equal(inferDebuggerTypeFromProject(['App.csproj', 'Native.vcxproj']), undefined); - // C++ plus package.json is ambiguous: it could be a native app with JS tooling. - assert.equal(inferDebuggerTypeFromProject(['Native.vcxproj', 'package.json']), undefined); - assert.equal(inferDebuggerTypeFromProject(['package.json', 'Cargo.toml']), undefined); - assert.equal(inferDebuggerTypeFromProject(['src-tauri/tauri.conf.json', 'package.json']), undefined); - assert.equal(inferDebuggerTypeFromProject(['CMakeLists.txt', 'package.json']), undefined); - }); - - it('returns undefined for empty or unknown file listings', () => { - assert.equal(inferDebuggerTypeFromProject([]), undefined); - assert.equal(inferDebuggerTypeFromProject(['README.md', 'src/app.ts']), undefined); - }); - }); });