Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ All commands are accessible from the Command Palette (`Ctrl+Shift+P`). Type **Wi
| **WinApp: Generate Manifest** | Generate an `AppxManifest.xml` from a template (packaged or sparse). |
| **WinApp: Add Manifest Execution Alias** | Add an execution alias to the manifest so the packaged app can be launched from the command line. |
| **WinApp: Update Manifest Assets** | Auto-generate all required app icon assets from a single source image (PNG, JPG, GIF, or BMP). |
| **WinApp: Generate Certificate** | Create a development certificate for signing, with an option to install it immediately. |
| **WinApp: Install Certificate** | Install an existing `.pfx` or `.cer` certificate. (requires Admin elevation) |
| **WinApp: Generate Certificate** | Create a development certificate for signing, with an option to also install (trust) it. Installing prompts for admin via a UAC window when VS Code isn't elevated. |
| **WinApp: Install Certificate** | Install (trust) an existing `.pfx` or `.cer` certificate in the machine store. Prompts for admin via a UAC window when VS Code isn't elevated. |
| **WinApp: Certificate Info** | Display certificate details (subject, thumbprint, expiry) to verify a certificate matches your manifest. |
| **WinApp: Sign Package** | Sign an MSIX package or executable with a certificate. |
| **WinApp: Run SDK Tool** | Run Windows SDK tools (`makeappx`, `signtool`, `mt`, `makepri`) with custom arguments. |
Expand Down Expand Up @@ -245,8 +245,8 @@ For debugging, install the debugger extension that matches your app's language (
| **"No folders containing .exe files found in the workspace..."** or **"No build output folder selected..."** when pressing F5 | The project hasn't been built yet, or the build output is in an unexpected location. | Build your project first (e.g., `dotnet build`), or set `inputFolder` in `launch.json` to point to the folder containing your `.exe`. |
| **Debugger doesn't attach** | The required debugger extension isn't installed. | Install the matching extension for your language — see [Supported debuggers](#integrated-debugging). |
| **App launches but changes aren't visible** | The `winapp` debug type does not build the project automatically. | Rebuild your project before pressing F5, or add a `preLaunchTask` to automate it (see the tip in [Integrated Debugging](#integrated-debugging)). |
| **Certificate trust error when running** | The development certificate isn't installed or has expired. | Run **WinApp: Generate Certificate** and choose to install it, or run **WinApp: Install Certificate** with your existing `.pfx` file. (requires Admin elevation) |
| **"Access denied" or permission errors** | Some operations (certificate install, package registration) require elevation. | Run VS Code as Administrator, or use an elevated terminal for the failing command. |
| **Certificate trust error when running** | The development certificate isn't installed or has expired. | Run **WinApp: Generate Certificate** and choose to also install it, or run **WinApp: Install Certificate** with your existing `.pfx` file. Both prompt for admin (UAC) when VS Code isn't elevated. |
| **"Access denied" or permission errors** | Some operations (package registration) require elevation. Certificate install now prompts for admin automatically. | Approve the UAC prompt when it appears, or run VS Code as Administrator. |

## Feedback and Support

Expand Down
111 changes: 102 additions & 9 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { spawn } from 'child_process';
import { getWinappCliPath, WINAPP_CLI_CALLER_VALUE, escapePowerShellArg } from './winapp-cli-utils';
import * as fs from 'fs';
import { spawn, execFile } from 'child_process';
import {
getWinappCliPath,
WINAPP_CLI_CALLER_VALUE,
escapePowerShellArg,
resolveWindowsPowerShellPath,
isUsableElevatedCliPath,
decideElevatedWinappCommand
} from './winapp-cli-utils';
import { detectProjects } from './project-detection';
import { resolveProjectDirectory as resolveProjectDirectoryCore } from './project-resolver';
import { glob } from 'glob';
import { ManifestEditorProvider } from './manifest-editor/manifest-editor-provider';

const WINAPP_DEBUG_TYPE = 'winapp';
const WINDOWS_POWERSHELL_PATH = resolveWindowsPowerShellPath(process.env.SystemRoot);

/**
* Maps debugger types to the VS Code extensions that provide them.
Expand Down Expand Up @@ -69,6 +78,81 @@ async function runWinappCommand(extensionPath: string, command: string, cwd: str
return '';
}

/**
* Report whether the current VS Code process is running elevated (as
* administrator).
*
* VS Code cannot launch an elevated integrated terminal, so admin-only winapp
* commands must be routed either through the normal terminal (when already
* elevated) or through a separate UAC-elevated window (when not). We probe the
* process token with PowerShell's WindowsPrincipal check. On any failure we
* return `false`, which is the safe default: the command is then launched via
* an elevated window (UAC), avoiding a silent "Access denied" failure.
*/
async function isProcessElevated(): Promise<boolean> {
return new Promise((resolve) => {
execFile(
WINDOWS_POWERSHELL_PATH,
[
'-NoProfile',
'-NonInteractive',
'-Command',
'[bool]([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)'
],
{ timeout: 10000, windowsHide: true },
(error, stdout) => {
resolve(!error && stdout.trim().toLowerCase() === 'true');
}
);
});
}

/**
* Run a winapp command that requires administrator rights.
*
* If VS Code is already elevated, the command runs in the normal integrated
* terminal. Otherwise it is launched in a separate UAC-elevated PowerShell
* window (VS Code cannot elevate the integrated terminal), and an information
* message explains that a Windows admin prompt will appear.
*/
async function runWinappCommandElevated(extensionPath: string, command: string, cwd: string): Promise<void> {
const isElevated = await isProcessElevated();
const cliPath = getWinappCliPath(extensionPath);
const launcherPath = WINDOWS_POWERSHELL_PATH;
const decision = decideElevatedWinappCommand(
isElevated,
isUsableElevatedCliPath(cliPath, fs.existsSync(cliPath)),
cliPath,
command,
cwd,
launcherPath
);

if (decision.kind === 'run-normally') {
await runWinappCommand(extensionPath, command, cwd);
return;
}

if (decision.kind === 'error-cli-missing') {
vscode.window.showErrorMessage(
'The bundled WinApp CLI executable could not be found, so the administrator command was not started. Rebuild or reinstall the extension, then try again.'
);
return;
}

const terminal = vscode.window.createTerminal({
name: 'WinApp CLI (Admin launcher)',
cwd: cwd,
shellPath: WINDOWS_POWERSHELL_PATH,
env: { WINAPP_CLI_CALLER: WINAPP_CLI_CALLER_VALUE }
});
terminal.show();
terminal.sendText(decision.command);
vscode.window.showInformationMessage(
'Installing the certificate requires administrator rights. Approve the Windows User Account Control (UAC) prompt in the elevated window that just opened.'
);
}

/**
* Resolves the project directory for commands that need a winapp project context.
* Priority: 1) winapp.appDirectories setting, 2) project at workspace root, 3) scan workspace.
Expand Down Expand Up @@ -656,16 +740,23 @@ export function activate(context: vscode.ExtensionContext) {
}

const install = await vscode.window.showQuickPick(
['Yes', 'No'],
{ placeHolder: 'Install certificate after generation?' }
['Generate only', 'Generate and install (requires admin)'],
{ placeHolder: 'Generate a development certificate — install it in the machine store too?' }
);

let command = 'cert generate';
if (install === 'Yes') {
command += ' --install';
if (!install) {
return;
}

await runWinappCommand(extensionPath, command, projectDir);
// Installing trusts the certificate in the machine store, which needs
// administrator rights. When VS Code isn't elevated we can't install
// from the integrated terminal, so run the whole generate+install in a
// separate UAC-elevated window instead of failing with "Access denied".
if (install === 'Generate and install (requires admin)') {
await runWinappCommandElevated(extensionPath, 'cert generate --install', projectDir);
} else {
await runWinappCommand(extensionPath, 'cert generate', projectDir);
}
})
);

Expand All @@ -686,7 +777,9 @@ export function activate(context: vscode.ExtensionContext) {
return;
}

await runWinappCommand(extensionPath, `cert install ${escapePowerShellArg(certPath)}`, workspacePath);
// Trusting a certificate in the machine store requires admin; route
// through an elevated window when VS Code isn't already elevated.
await runWinappCommandElevated(extensionPath, `cert install ${escapePowerShellArg(certPath)}`, workspacePath);
})
);

Expand Down
142 changes: 140 additions & 2 deletions src/test/shell-escape.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { escapePowerShellArg } from '../winapp-cli-utils';
import {
escapePowerShellArg,
buildElevatedTerminalCommand,
resolveWindowsPowerShellPath,
isUsableElevatedCliPath,
decideElevatedWinappCommand
} from '../winapp-cli-utils';

const launcherPath = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';

describe('escapePowerShellArg', () => {
it('wraps an ordinary path in single quotes', () => {
assert.equal(escapePowerShellArg('C:\\apps\\my app'), "'C:\\apps\\my app'");
});

it('does not expand subexpression syntax', () => {
// A double-quoted PowerShell string would evaluate $(...) a single-quoted
// A double-quoted PowerShell string would evaluate $(...) - a single-quoted
// literal must keep it verbatim so the path cannot run commands.
const malicious = 'C:\\$(Remove-Item -Recurse C:\\)';
assert.equal(escapePowerShellArg(malicious), `'${malicious}'`);
Expand Down Expand Up @@ -36,3 +44,133 @@ describe('escapePowerShellArg', () => {
assert.equal(escapePowerShellArg(''), "''");
});
});

describe('buildElevatedTerminalCommand', () => {
it('launches an elevated PowerShell window that runs the winapp CLI in the working directory', () => {
const result = buildElevatedTerminalCommand(
'C:\\ext\\bin\\winapp.exe',
"cert install 'C:\\proj\\devcert.pfx'",
'C:\\proj',
launcherPath
);
assert.equal(
result,
"Start-Process -FilePath 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -Verb RunAs -ArgumentList '-NoExit', '-Command', " +
"'Set-Location -LiteralPath ''C:\\proj''; & ''C:\\ext\\bin\\winapp.exe'' cert install ''C:\\proj\\devcert.pfx'''"
);
});

it('requests elevation via the RunAs verb and keeps the window open', () => {
const result = buildElevatedTerminalCommand('winapp', 'cert generate --install', 'C:\\proj', launcherPath);
assert.match(result, /-Verb RunAs/);
assert.match(result, /-NoExit/);
});

it('sets the elevated working directory so RunAs does not default to System32', () => {
const result = buildElevatedTerminalCommand('winapp', 'cert generate', 'C:\\my proj', launcherPath);
// Doubled quotes because Set-Location sits inside the outer -Command literal.
assert.match(result, /Set-Location -LiteralPath ''C:\\my proj''/);
});

it('uses the supplied absolute Windows PowerShell launcher path', () => {
const customLauncher = 'D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
const result = buildElevatedTerminalCommand('C:\\ext\\bin\\winapp.exe', 'cert generate', 'C:\\proj', customLauncher);
assert.match(result, /^Start-Process -FilePath 'D:\\Windows\\System32\\WindowsPowerShell\\v1\.0\\powershell\.exe'/);
});

it('doubles single quotes in the working directory so it cannot break out of Set-Location', () => {
const result = buildElevatedTerminalCommand('C:\\ext\\bin\\winapp.exe', 'cert generate', "C:\\o'brien\\proj", launcherPath);
assert.match(result, /Set-Location -LiteralPath ''C:\\o''''brien\\proj''/);
assert.equal((result.match(/'/g) || []).length % 2, 0);
});

it('doubles single quotes in the CLI path so it cannot break out of the literal', () => {
const result = buildElevatedTerminalCommand("C:\\o'brien\\winapp.exe", 'cert generate', 'C:\\proj', launcherPath);
// The path's quote is doubled once for the inner call and again for the
// outer -Command literal, leaving every quote in the line balanced.
assert.equal((result.match(/'/g) || []).length % 2, 0);
assert.ok(result.includes("o''''brien"));
});

it('keeps a quote-injection attempt in the args inert', () => {
const attack = escapePowerShellArg("'; Remove-Item C:\\ #");
const result = buildElevatedTerminalCommand('winapp', `cert install ${attack}`, 'C:\\proj', launcherPath);
// Every quote is paired: the injection stays inside a single literal.
assert.equal((result.match(/'/g) || []).length % 2, 0);
});
});

describe('resolveWindowsPowerShellPath', () => {
it('builds the launcher path from SystemRoot', () => {
assert.equal(resolveWindowsPowerShellPath('D:\\Windows'), 'D:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe');
});

it('falls back to C:\\Windows when SystemRoot is unavailable', () => {
assert.equal(resolveWindowsPowerShellPath(undefined), launcherPath);
});

it('falls back to C:\\Windows when SystemRoot is empty', () => {
assert.equal(resolveWindowsPowerShellPath(''), launcherPath);
});
});

describe('isUsableElevatedCliPath', () => {
it('accepts an absolute existing winapp.exe path', () => {
assert.equal(isUsableElevatedCliPath('C:\\ext\\bin\\winapp.exe', true), true);
});

it('rejects the search-path fallback even if a command by that name exists', () => {
assert.equal(isUsableElevatedCliPath('winapp', true), false);
});

it('rejects an absolute winapp.exe path when it does not exist', () => {
assert.equal(isUsableElevatedCliPath('C:\\ext\\bin\\winapp.exe', false), false);
});

it('rejects an absolute path to a different executable', () => {
assert.equal(isUsableElevatedCliPath('C:\\ext\\bin\\powershell.exe', true), false);
});
});

describe('decideElevatedWinappCommand', () => {
it('fails closed when an elevated process only has an unusable CLI path', () => {
assert.deepEqual(
decideElevatedWinappCommand(true, false, 'winapp', 'cert generate --install', 'C:\\proj', launcherPath),
{ kind: 'error-cli-missing' }
);
});

it('routes elevated processes with a usable CLI path to the normal terminal', () => {
assert.deepEqual(
decideElevatedWinappCommand(true, true, 'C:\\ext\\bin\\winapp.exe', 'cert generate --install', 'C:\\proj', launcherPath),
{ kind: 'run-normally' }
);
});

it('routes non-elevated processes with a usable CLI path to an elevated launcher command', () => {
const decision = decideElevatedWinappCommand(
false,
true,
'C:\\ext\\bin\\winapp.exe',
'cert generate --install',
'C:\\proj',
launcherPath
);

assert.equal(decision.kind, 'run-elevated');
if (decision.kind === 'run-elevated') {
assert.equal(
decision.command,
buildElevatedTerminalCommand('C:\\ext\\bin\\winapp.exe', 'cert generate --install', 'C:\\proj', launcherPath)
);
}
});

it('fails closed when a non-elevated process only has an unusable CLI path', () => {
assert.deepEqual(
decideElevatedWinappCommand(false, false, 'winapp', 'cert generate --install', 'C:\\proj', launcherPath),
{ kind: 'error-cli-missing' }
);
});
});

66 changes: 66 additions & 0 deletions src/winapp-cli-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,69 @@ export function getWinappCliPath(extensionPath: string): string {
export function escapePowerShellArg(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}

export function resolveWindowsPowerShellPath(systemRoot: string | undefined): string {
return path.join(systemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
}

export function isUsableElevatedCliPath(cliPath: string, cliPathExists: boolean): boolean {
return path.isAbsolute(cliPath) && path.basename(cliPath).toLowerCase() === 'winapp.exe' && cliPathExists;
}

export type ElevatedWinappCommandDecision =
| { kind: 'run-normally' }
| { kind: 'run-elevated'; command: string }
| { kind: 'error-cli-missing' };

export function decideElevatedWinappCommand(
isElevated: boolean,
cliPathIsUsable: boolean,
cliPath: string,
cliArgs: string,
workingDirectory: string,
launcherPath: string
): ElevatedWinappCommandDecision {
if (!cliPathIsUsable) {
return { kind: 'error-cli-missing' };
}

if (isElevated) {
return { kind: 'run-normally' };
}

return { kind: 'run-elevated', command: buildElevatedTerminalCommand(cliPath, cliArgs, workingDirectory, launcherPath) };
}

/**
* Build a PowerShell command that runs the winapp CLI elevated in a *separate*
* console via `Start-Process -Verb RunAs`.
*
* VS Code cannot create an elevated integrated terminal (there is no extension
* API for it, by design). Commands that need administrator rights — notably
* `cert install`, which trusts a certificate in the machine store — are
* therefore launched in a new UAC-elevated PowerShell window. `-NoExit` keeps
* that window open so the user can read the result and any errors.
*
* The elevated shell is pointed at `workingDirectory` with `Set-Location`
* before invoking the CLI. A RunAs-elevated process otherwise starts in
* `System32`, which would break commands that rely on the working directory
* (e.g. `cert generate` infers the publisher from the manifest in the current
* directory and writes `devcert.pfx` there). `Set-Location` is used instead of
* `Start-Process -WorkingDirectory` because the latter is ignored with `-Verb`
* on Windows PowerShell 5.1.
*
* The working directory, CLI path, and the composed command are all quoted with
* {@link escapePowerShellArg}, so values containing single quotes stay balanced
* and cannot break out of the literal.
*
* @param launcherPath Absolute path to Windows PowerShell for the elevated launcher.
* @param cliPath Path to the bundled winapp executable.
* @param cliArgs The winapp arguments, already PowerShell-escaped where needed
* (e.g. `cert install 'C:\path\devcert.pfx'`).
* @param workingDirectory Directory to run the elevated command in.
* @returns A PowerShell command line to send to a (non-elevated) terminal.
*/
export function buildElevatedTerminalCommand(cliPath: string, cliArgs: string, workingDirectory: string, launcherPath: string): string {
const innerCommand = `Set-Location -LiteralPath ${escapePowerShellArg(workingDirectory)}; & ${escapePowerShellArg(cliPath)} ${cliArgs}`.trim();
return `Start-Process -FilePath ${escapePowerShellArg(launcherPath)} -Verb RunAs -ArgumentList '-NoExit', '-Command', ${escapePowerShellArg(innerCommand)}`;
}
Loading