Skip to content

Commit 4fe467d

Browse files
fix: Use PowerShell to trigger UAC prompts (#328)
1 parent 534d1f2 commit 4fe467d

File tree

4 files changed

+38
-34
lines changed

4 files changed

+38
-34
lines changed

lib/installer.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from 'path';
44
import { exec } from 'teen_process';
55
import { log } from './logger';
66
import { queryRegistry } from './registry';
7-
import { shellExec } from './utils';
7+
import { runElevated } from './utils';
88

99
const POSSIBLE_WAD_INSTALL_ROOTS = [
1010
process.env['ProgramFiles(x86)'],
@@ -24,12 +24,17 @@ session.DoAction("CostFinalize")
2424
WScript.Echo session.Property("INSTALLFOLDER")
2525
`.replace(/\n/g, '\r\n');
2626

27+
/**
28+
*
29+
* @param {string} installerGuid
30+
* @returns {Promise<string>} install location
31+
*/
2732
async function fetchMsiInstallLocation (installerGuid) {
2833
const tmpRoot = await tempDir.openDir();
2934
const scriptPath = path.join(tmpRoot, 'get_wad_inst_location.vbs');
3035
try {
3136
await fs.writeFile(scriptPath, INST_LOCATION_SCRIPT_BY_GUID(installerGuid), 'latin1');
32-
const {stdout} = await shellExec('cscript.exe', ['/Nologo', scriptPath]);
37+
const {stdout} = await runElevated('cscript.exe', ['/Nologo', scriptPath]);
3338
return _.trim(stdout);
3439
} finally {
3540
await fs.rimraf(tmpRoot);
@@ -66,10 +71,13 @@ export const getWADExecutablePath = _.memoize(async function getWADInstallPath (
6671
);
6772
if (wadEntry) {
6873
log.debug(`Found MSI entry: ${JSON.stringify(wadEntry)}`);
69-
const installerGuid = _.last(wadEntry.root.split('\\'));
74+
const installerGuid = /** @type {string} */ (_.last(wadEntry.root.split('\\')));
7075
// WAD MSI installer leaves InstallLocation registry value empty,
7176
// so we need to be hacky here
72-
const result = path.join(await fetchMsiInstallLocation(installerGuid), WAD_EXE_NAME);
77+
const result = path.join(
78+
await fetchMsiInstallLocation(installerGuid),
79+
WAD_EXE_NAME
80+
);
7381
log.debug(`Checking if WAD exists at '${result}'`);
7482
if (await fs.exists(result)) {
7583
return result;
@@ -93,6 +101,10 @@ export const getWADExecutablePath = _.memoize(async function getWADInstallPath (
93101
);
94102
});
95103

104+
/**
105+
*
106+
* @returns {Promise<boolean>}
107+
*/
96108
export async function isAdmin () {
97109
try {
98110
await exec('fsutil.exe', ['dirty', 'query', process.env.SystemDrive || 'C:']);

lib/registry.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import _ from 'lodash';
2-
import { shellExec } from './utils';
2+
import { runElevated } from './utils';
33

44
const REG = 'reg.exe';
55
const ENTRY_PATTERN = /^\s+(\w+)\s+([A-Z_]+)\s*(.*)/;
@@ -62,7 +62,7 @@ function parseRegQueryOutput (output) {
6262
async function queryRegistry (root) {
6363
let stdout;
6464
try {
65-
({stdout} = await shellExec(REG, ['query', root, '/s']));
65+
({stdout} = await runElevated(REG, ['query', root, '/s']));
6666
} catch {
6767
return [];
6868
}

lib/utils.js

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,54 +3,46 @@ import { net } from 'appium/support';
33
import { promisify } from 'node:util';
44
import { exec } from 'node:child_process';
55
import B from 'bluebird';
6+
import { log } from './logger';
67

78
const execAsync = promisify(exec);
89

910
/**
1011
* This API triggers UAC when necessary
11-
* unlike the 'spawn' call used by teen_process's exec.
12-
* See https://github.com/nodejs/node-v0.x-archive/issues/6797
1312
*
1413
* @param {string} cmd
1514
* @param {string[]} args
1615
* @param {import('node:child_process').ExecOptions & {timeoutMs?: number}} opts
1716
* @returns {Promise<{stdout: string; stderr: string;}>}
1817
* @throws {import('node:child_process').ExecException}
18+
*
19+
* Notes:
20+
* - If the UAC prompt is cancelled by the user, Start-Process returns a non-zero exit code.
1921
*/
20-
export async function shellExec(cmd, args = [], opts = {}) {
22+
export async function runElevated(cmd, args = [], opts = {}) {
2123
const {
2224
timeoutMs = 60 * 1000 * 5
2325
} = opts;
24-
const fullCmd = [cmd, ...args].map(escapeWindowsArg).join(' ');
25-
const { stdout, stderr } = await B.resolve(execAsync(fullCmd, opts))
26-
.timeout(timeoutMs, `The command '${fullCmd}' timed out after ${timeoutMs}ms`);
26+
27+
const escapePSSingleQuoted = (/** @type {string} */ str) => `'${String(str).replace(/'/g, "''")}'`;
28+
const psFilePath = escapePSSingleQuoted(cmd);
29+
const psArgList = _.isEmpty(args) ? "''" : args.map(escapePSSingleQuoted).join(',');
30+
// Build the PowerShell Start-Process command (safe quoting for inner tokens)
31+
const psCommand = `Start-Process -FilePath ${psFilePath} -ArgumentList ${psArgList} -Verb RunAs`;
32+
// Wrap the PowerShell command in double-quotes for the outer shell call.
33+
// We avoid additional interpolation here by using only single-quoted literals inside the PS command.
34+
const fullCmd = `powershell -NoProfile -Command "${psCommand}"`;
35+
log.debug(`Executing command: ${fullCmd}`);
36+
const { stdout, stderr } = /** @type {any} */ (
37+
await B.resolve(execAsync(fullCmd, opts))
38+
.timeout(timeoutMs, `The command '${fullCmd}' timed out after ${timeoutMs}ms`)
39+
);
2740
return {
2841
stdout: _.isString(stdout) ? stdout : stdout.toString(),
2942
stderr: _.isString(stderr) ? stderr : stderr.toString(),
3043
};
3144
}
3245

33-
/**
34-
* Escapes a string to be used as a Windows command line argument
35-
*
36-
* @param {string} arg
37-
* @returns {string}
38-
*/
39-
function escapeWindowsArg(arg) {
40-
if (!arg) {
41-
return '""';
42-
}
43-
44-
const needsQuotes = /[\s"]/g.test(arg);
45-
if (!needsQuotes) {
46-
return arg;
47-
}
48-
49-
// Escape double quotes and backslashes before quotes
50-
const escaped = arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1');
51-
return `"${escaped}"`;
52-
}
53-
5446
/**
5547
*
5648
* @param {string} srcUrl

scripts/install-wad.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ function parseNextPageUrl(headers) {
4242
/**
4343
* https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#list-releases
4444
*
45-
* @returns {Promise<ReleaseInfo[]}
45+
* @returns {Promise<ReleaseInfo[]>}
4646
*/
4747
async function listReleases() {
4848
/** @type {Record<string, any>[]} */

0 commit comments

Comments
 (0)