dsh-app-updater 1.3.2 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/elevate.js CHANGED
@@ -1,205 +1,205 @@
1
- /**
2
- * Windows 提权执行。
3
- *
4
- * 为什么需要它:winget 目录里的「官网全量包」有一大半是**机器级**安装 —— MSI 要
5
- * 写 HKLM / Program Files,安装器要往 Program Files 落文件。进程没有管理员令牌
6
- * 时有两种失败方式:
7
- * 1) MSI 直接失败(msiexec 返回 1603,MSI 日志里是 Error 1925「权限不足」);
8
- * 2) 安装器**静默退出 0 却什么都没装**(实测 Logitech G HUB)。
9
- * 第 2 种最坑:只看退出码就会误报「更新成功」,所以安装之后必须复核版本
10
- * (见 engine.js 的 verifyInstalled)。
11
- *
12
- * Windows 的进程令牌创建后无法原地提升,因此这里把**一条命令**写进临时
13
- * PowerShell 助手脚本,用 `Start-Process -Verb RunAs` 在提权副本里执行,再把
14
- * 退出码与输出回传到文件后读回。两个脚本都写成 UTF-8 **带 BOM**:Windows
15
- * PowerShell 5.1 读 .ps1 时若无 BOM 会按本地代码页解码,中文路径会乱。
16
- */
17
-
18
- import { existsSync } from "node:fs";
19
- import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
20
- import { tmpdir } from "node:os";
21
- import { join } from "node:path";
22
-
23
- import { runProcess } from "./proc.js";
24
-
25
- const PS_PROBE_TIMEOUT_MS = 20000;
26
-
27
- /** Windows 取消提权的退出码(ERROR_CANCELLED)。 */
28
- export const ELEVATION_CANCELLED = 1223;
29
-
30
- /** PowerShell 单引号字面量:内部的 `'` 要写成 `''`。 */
31
- export function psQuote(value) {
32
- return `'${String(value == null ? "" : value).replace(/'/g, "''")}'`;
33
- }
34
-
35
- /** Windows PowerShell 5.1 的绝对路径。 */
36
- export function powershellPath(env = process.env) {
37
- const root = env.SystemRoot || env.windir || "C:\\Windows";
38
- return join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
39
- }
40
-
41
- /** 本机是否有可用的 PowerShell(没有它就无法提权)。 */
42
- export function elevationAvailable(env = process.env) {
43
- try {
44
- return existsSync(powershellPath(env));
45
- } catch {
46
- return false;
47
- }
48
- }
49
-
50
- let cachedElevated = null;
51
-
52
- /** 清掉「是否已提权」的缓存(单元测试与提权后复核用)。 */
53
- export function resetElevationCache() {
54
- cachedElevated = null;
55
- }
56
-
57
- /**
58
- * 当前进程是否在管理员上下文里。
59
- * 非 Windows 平台按 uid==0 判断(macOS 的 brew 永远不需要提权)。
60
- */
61
- export async function isElevated(opts = {}) {
62
- if (cachedElevated !== null && !opts.refresh) return cachedElevated;
63
- if (process.platform !== "win32") {
64
- cachedElevated = typeof process.getuid === "function" ? process.getuid() === 0 : true;
65
- return cachedElevated;
66
- }
67
- if (!elevationAvailable()) {
68
- cachedElevated = false;
69
- return cachedElevated;
70
- }
71
- const res = await runProcess(
72
- powershellPath(),
73
- [
74
- "-NoProfile",
75
- "-NonInteractive",
76
- "-ExecutionPolicy",
77
- "Bypass",
78
- "-Command",
79
- "[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
80
- ],
81
- { timeoutMs: PS_PROBE_TIMEOUT_MS, signal: opts.signal }
82
- );
83
- cachedElevated = res.ok && /true/i.test(res.stdout);
84
- return cachedElevated;
85
- }
86
-
87
- function stripBom(text) {
88
- return String(text || "").replace(/^\ufeff/, "");
89
- }
90
-
91
- /**
92
- * 在提权副本里执行一条命令。
93
- *
94
- * 返回结构与 proc.js 的 runProcess 一致(外加 cancelled),因此调用方可以
95
- * 无差别地把它当成「另一个 runProcess」。
96
- *
97
- * @param {string} exe 绝对路径
98
- * @param {string[]} args 参数数组
99
- * @param {{ timeoutMs?: number, signal?: AbortSignal }} [opts]
100
- * @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, timedOut:boolean, spawnError:string|null, cancelled:boolean}>}
101
- */
102
- export async function runElevated(exe, args, opts = {}) {
103
- const timeoutMs = opts.timeoutMs || 60 * 60 * 1000;
104
- const fail = (spawnError) => ({
105
- ok: false,
106
- code: null,
107
- stdout: "",
108
- stderr: "",
109
- timedOut: false,
110
- spawnError,
111
- cancelled: false,
112
- });
113
-
114
- if (process.platform !== "win32") return fail("非 Windows 平台不支持提权执行");
115
- if (!elevationAvailable()) return fail("未找到 powershell.exe,无法提权执行");
116
-
117
- const ps = powershellPath();
118
- const dir = await mkdtemp(join(tmpdir(), "dsh-app-updater-elev-"));
119
- const outFile = join(dir, "out.txt");
120
- const codeFile = join(dir, "code.txt");
121
- const helperFile = join(dir, "helper.ps1");
122
- const launchFile = join(dir, "launch.ps1");
123
-
124
- const helper = [
125
- "$ErrorActionPreference = 'Continue'",
126
- "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
127
- "$OutputEncoding = [System.Text.Encoding]::UTF8",
128
- `$exe = ${psQuote(exe)}`,
129
- `$argv = @(${(args || []).map(psQuote).join(", ")})`,
130
- `& $exe @argv 2>&1 | Out-File -FilePath ${psQuote(outFile)} -Encoding UTF8`,
131
- "$code = $LASTEXITCODE",
132
- "if ($null -eq $code) { $code = 0 }",
133
- `Set-Content -Path ${psQuote(codeFile)} -Value $code -Encoding ASCII`,
134
- "exit $code",
135
- "",
136
- ].join("\r\n");
137
-
138
- // 启动器只负责「把助手脚本以管理员身份跑起来并把退出码带回来」。
139
- const launch = [
140
- "$ErrorActionPreference = 'Stop'",
141
- "try {",
142
- ` $p = Start-Process -FilePath ${psQuote(ps)} -ArgumentList @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File', ${psQuote(helperFile)}) -Verb RunAs -WindowStyle Hidden -Wait -PassThru`,
143
- `} catch { exit ${ELEVATION_CANCELLED} }`,
144
- `if ($null -eq $p) { exit ${ELEVATION_CANCELLED} }`,
145
- "exit $p.ExitCode",
146
- "",
147
- ].join("\r\n");
148
-
149
- try {
150
- await writeFile(helperFile, `\ufeff${helper}`, "utf8");
151
- await writeFile(launchFile, `\ufeff${launch}`, "utf8");
152
- } catch (err) {
153
- await rm(dir, { recursive: true, force: true }).catch(() => {});
154
- return fail(String((err && err.message) || err));
155
- }
156
-
157
- const res = await runProcess(
158
- ps,
159
- ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", launchFile],
160
- { timeoutMs, signal: opts.signal }
161
- );
162
-
163
- let stdout = "";
164
- try {
165
- stdout = stripBom(await readFile(outFile, "utf8"));
166
- } catch {
167
- // 安装器没有产出任何输出:正常(GUI 安装包),失败原因由退出码与 MSI 日志给
168
- }
169
-
170
- let code = res.code;
171
- let reported = false;
172
- try {
173
- const raw = stripBom(await readFile(codeFile, "utf8")).trim();
174
- if (/^-?\d+$/.test(raw)) {
175
- code = Number(raw);
176
- reported = true;
177
- }
178
- } catch {
179
- // 提权副本没能跑到写退出码那一步
180
- }
181
-
182
- // 超时时提权副本可能还在跑(安装器),保留临时目录不删,避免动到正在执行的脚本
183
- if (!res.timedOut) {
184
- await rm(dir, { recursive: true, force: true }).catch(() => {});
185
- }
186
-
187
- const cancelled = res.code === ELEVATION_CANCELLED || code === ELEVATION_CANCELLED;
188
- const spawnError = res.spawnError
189
- ? res.spawnError
190
- : cancelled
191
- ? "提权被拒绝(UAC 被取消)"
192
- : !reported && !res.timedOut && code !== 0
193
- ? "提权副本未能报告退出码(可能被 UAC 拒绝)"
194
- : null;
195
-
196
- return {
197
- ok: !res.timedOut && !spawnError && code === 0,
198
- code,
199
- stdout,
200
- stderr: "",
201
- timedOut: res.timedOut,
202
- spawnError,
203
- cancelled,
204
- };
1
+ /**
2
+ * Windows 提权执行。
3
+ *
4
+ * 为什么需要它:winget 目录里的「官网全量包」有一大半是**机器级**安装 —— MSI 要
5
+ * 写 HKLM / Program Files,安装器要往 Program Files 落文件。进程没有管理员令牌
6
+ * 时有两种失败方式:
7
+ * 1) MSI 直接失败(msiexec 返回 1603,MSI 日志里是 Error 1925「权限不足」);
8
+ * 2) 安装器**静默退出 0 却什么都没装**(实测 Logitech G HUB)。
9
+ * 第 2 种最坑:只看退出码就会误报「更新成功」,所以安装之后必须复核版本
10
+ * (见 engine.js 的 verifyInstalled)。
11
+ *
12
+ * Windows 的进程令牌创建后无法原地提升,因此这里把**一条命令**写进临时
13
+ * PowerShell 助手脚本,用 `Start-Process -Verb RunAs` 在提权副本里执行,再把
14
+ * 退出码与输出回传到文件后读回。两个脚本都写成 UTF-8 **带 BOM**:Windows
15
+ * PowerShell 5.1 读 .ps1 时若无 BOM 会按本地代码页解码,中文路径会乱。
16
+ */
17
+
18
+ import { existsSync } from "node:fs";
19
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ import { runProcess } from "./proc.js";
24
+
25
+ const PS_PROBE_TIMEOUT_MS = 20000;
26
+
27
+ /** Windows 取消提权的退出码(ERROR_CANCELLED)。 */
28
+ export const ELEVATION_CANCELLED = 1223;
29
+
30
+ /** PowerShell 单引号字面量:内部的 `'` 要写成 `''`。 */
31
+ export function psQuote(value) {
32
+ return `'${String(value == null ? "" : value).replace(/'/g, "''")}'`;
33
+ }
34
+
35
+ /** Windows PowerShell 5.1 的绝对路径。 */
36
+ export function powershellPath(env = process.env) {
37
+ const root = env.SystemRoot || env.windir || "C:\\Windows";
38
+ return join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
39
+ }
40
+
41
+ /** 本机是否有可用的 PowerShell(没有它就无法提权)。 */
42
+ export function elevationAvailable(env = process.env) {
43
+ try {
44
+ return existsSync(powershellPath(env));
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ let cachedElevated = null;
51
+
52
+ /** 清掉「是否已提权」的缓存(单元测试与提权后复核用)。 */
53
+ export function resetElevationCache() {
54
+ cachedElevated = null;
55
+ }
56
+
57
+ /**
58
+ * 当前进程是否在管理员上下文里。
59
+ * 非 Windows 平台按 uid==0 判断(macOS 的 brew 永远不需要提权)。
60
+ */
61
+ export async function isElevated(opts = {}) {
62
+ if (cachedElevated !== null && !opts.refresh) return cachedElevated;
63
+ if (process.platform !== "win32") {
64
+ cachedElevated = typeof process.getuid === "function" ? process.getuid() === 0 : true;
65
+ return cachedElevated;
66
+ }
67
+ if (!elevationAvailable()) {
68
+ cachedElevated = false;
69
+ return cachedElevated;
70
+ }
71
+ const res = await runProcess(
72
+ powershellPath(),
73
+ [
74
+ "-NoProfile",
75
+ "-NonInteractive",
76
+ "-ExecutionPolicy",
77
+ "Bypass",
78
+ "-Command",
79
+ "[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
80
+ ],
81
+ { timeoutMs: PS_PROBE_TIMEOUT_MS, signal: opts.signal }
82
+ );
83
+ cachedElevated = res.ok && /true/i.test(res.stdout);
84
+ return cachedElevated;
85
+ }
86
+
87
+ function stripBom(text) {
88
+ return String(text || "").replace(/^\ufeff/, "");
89
+ }
90
+
91
+ /**
92
+ * 在提权副本里执行一条命令。
93
+ *
94
+ * 返回结构与 proc.js 的 runProcess 一致(外加 cancelled),因此调用方可以
95
+ * 无差别地把它当成「另一个 runProcess」。
96
+ *
97
+ * @param {string} exe 绝对路径
98
+ * @param {string[]} args 参数数组
99
+ * @param {{ timeoutMs?: number, signal?: AbortSignal }} [opts]
100
+ * @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, timedOut:boolean, spawnError:string|null, cancelled:boolean}>}
101
+ */
102
+ export async function runElevated(exe, args, opts = {}) {
103
+ const timeoutMs = opts.timeoutMs || 60 * 60 * 1000;
104
+ const fail = (spawnError) => ({
105
+ ok: false,
106
+ code: null,
107
+ stdout: "",
108
+ stderr: "",
109
+ timedOut: false,
110
+ spawnError,
111
+ cancelled: false,
112
+ });
113
+
114
+ if (process.platform !== "win32") return fail("非 Windows 平台不支持提权执行");
115
+ if (!elevationAvailable()) return fail("未找到 powershell.exe,无法提权执行");
116
+
117
+ const ps = powershellPath();
118
+ const dir = await mkdtemp(join(tmpdir(), "dsh-app-updater-elev-"));
119
+ const outFile = join(dir, "out.txt");
120
+ const codeFile = join(dir, "code.txt");
121
+ const helperFile = join(dir, "helper.ps1");
122
+ const launchFile = join(dir, "launch.ps1");
123
+
124
+ const helper = [
125
+ "$ErrorActionPreference = 'Continue'",
126
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
127
+ "$OutputEncoding = [System.Text.Encoding]::UTF8",
128
+ `$exe = ${psQuote(exe)}`,
129
+ `$argv = @(${(args || []).map(psQuote).join(", ")})`,
130
+ `& $exe @argv 2>&1 | Out-File -FilePath ${psQuote(outFile)} -Encoding UTF8`,
131
+ "$code = $LASTEXITCODE",
132
+ "if ($null -eq $code) { $code = 0 }",
133
+ `Set-Content -Path ${psQuote(codeFile)} -Value $code -Encoding ASCII`,
134
+ "exit $code",
135
+ "",
136
+ ].join("\r\n");
137
+
138
+ // 启动器只负责「把助手脚本以管理员身份跑起来并把退出码带回来」。
139
+ const launch = [
140
+ "$ErrorActionPreference = 'Stop'",
141
+ "try {",
142
+ ` $p = Start-Process -FilePath ${psQuote(ps)} -ArgumentList @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File', ${psQuote(helperFile)}) -Verb RunAs -WindowStyle Hidden -Wait -PassThru`,
143
+ `} catch { exit ${ELEVATION_CANCELLED} }`,
144
+ `if ($null -eq $p) { exit ${ELEVATION_CANCELLED} }`,
145
+ "exit $p.ExitCode",
146
+ "",
147
+ ].join("\r\n");
148
+
149
+ try {
150
+ await writeFile(helperFile, `\ufeff${helper}`, "utf8");
151
+ await writeFile(launchFile, `\ufeff${launch}`, "utf8");
152
+ } catch (err) {
153
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
154
+ return fail(String((err && err.message) || err));
155
+ }
156
+
157
+ const res = await runProcess(
158
+ ps,
159
+ ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", launchFile],
160
+ { timeoutMs, signal: opts.signal }
161
+ );
162
+
163
+ let stdout = "";
164
+ try {
165
+ stdout = stripBom(await readFile(outFile, "utf8"));
166
+ } catch {
167
+ // 安装器没有产出任何输出:正常(GUI 安装包),失败原因由退出码与 MSI 日志给
168
+ }
169
+
170
+ let code = res.code;
171
+ let reported = false;
172
+ try {
173
+ const raw = stripBom(await readFile(codeFile, "utf8")).trim();
174
+ if (/^-?\d+$/.test(raw)) {
175
+ code = Number(raw);
176
+ reported = true;
177
+ }
178
+ } catch {
179
+ // 提权副本没能跑到写退出码那一步
180
+ }
181
+
182
+ // 超时时提权副本可能还在跑(安装器),保留临时目录不删,避免动到正在执行的脚本
183
+ if (!res.timedOut) {
184
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
185
+ }
186
+
187
+ const cancelled = res.code === ELEVATION_CANCELLED || code === ELEVATION_CANCELLED;
188
+ const spawnError = res.spawnError
189
+ ? res.spawnError
190
+ : cancelled
191
+ ? "提权被拒绝(UAC 被取消)"
192
+ : !reported && !res.timedOut && code !== 0
193
+ ? "提权副本未能报告退出码(可能被 UAC 拒绝)"
194
+ : null;
195
+
196
+ return {
197
+ ok: !res.timedOut && !spawnError && code === 0,
198
+ code,
199
+ stdout,
200
+ stderr: "",
201
+ timedOut: res.timedOut,
202
+ spawnError,
203
+ cancelled,
204
+ };
205
205
  }