@wwkit/shared 1.0.33 → 1.0.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wwkit/shared",
3
- "version": "1.0.33",
3
+ "version": "1.0.34",
4
4
  "author": "bluesliu <langcai163@163.com>",
5
5
  "description": "Shared utilities for @wwkit packages — config loader, platform detection",
6
6
  "type": "module",
@@ -0,0 +1,225 @@
1
+ /**
2
+ * 跨平台客户端操作:打开浏览器 / 查找可执行文件 / 提权预检
3
+ *
4
+ * 封装 Windows / macOS / Linux 的客户端侧系统差异,供 @wwkit 各包复用:
5
+ * - openBrowser 跨平台打开浏览器打开 URL(mac open / win cmd start / linux xdg-open)
6
+ * - whichPath 跨平台查找可执行文件路径(win32 用 where,其余用 which)
7
+ * - isWindowsAdmin Windows 管理员预检(net session;非 Windows 视为无需该权限)
8
+ *
9
+ * 设计原则:纯通用,不依赖任何包的配置/路径/状态目录;门控(如测试不弹浏览器)
10
+ * 由调用方自行判断,不进本模块。
11
+ */
12
+
13
+ import { spawn, spawnSync } from 'node:child_process'
14
+ import fs from 'node:fs'
15
+ import net from 'node:net'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+
19
+ /**
20
+ * 跨平台打开浏览器打开 URL。
21
+ * 打开失败静默返回 false(如无默认浏览器),不抛异常。
22
+ * @param {string} url
23
+ * @returns {boolean} 是否成功发起打开
24
+ */
25
+ export function openBrowser(url) {
26
+ try {
27
+ const opts = { detached: true, stdio: 'ignore', windowsHide: true } // windowsHide:避免启动浏览器时 cmd 闪窗
28
+ let child
29
+ if (process.platform === 'win32') {
30
+ child = spawn('cmd', ['/c', 'start', '', url], opts)
31
+ } else if (process.platform === 'darwin') {
32
+ child = spawn('open', [url], opts)
33
+ } else {
34
+ child = spawn('xdg-open', [url], opts)
35
+ }
36
+ child.on('error', () => {}) // 打开失败静默(如无默认浏览器)
37
+ child.unref()
38
+ return true
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
+ // which 结果缓存:环境级探测(where/which 查 PATH)在轮询期反复调用,
45
+ // 每 3s 一次 spawnSync 既浪费也不必要;TTL 60s 内直接复用。
46
+ const _whichCache = new Map()
47
+ const _WHICH_TTL_MS = 60_000
48
+
49
+ /**
50
+ * 跨平台查找可执行文件路径(win32 用 where,其余用 which);找不到返回 null。
51
+ * 结果缓存 60s,避免轮询期反复 spawn。
52
+ * @param {string} cmd
53
+ * @returns {string|null}
54
+ */
55
+ export function whichPath(cmd) {
56
+ const now = Date.now()
57
+ const hit = _whichCache.get(cmd)
58
+ if (hit && now - hit.ts < _WHICH_TTL_MS) {
59
+ return hit.value
60
+ }
61
+ const tool = process.platform === 'win32' ? 'where' : 'which'
62
+ // windowsHide:Windows 下 where/which 是控制台程序,无此配置每次调用都会弹一个黑窗
63
+ const result = spawnSync(tool, [cmd], { encoding: 'utf-8', windowsHide: true })
64
+ const first = result.status === 0 ? String(result.stdout || '').trim().split('\n')[0] : null
65
+ _whichCache.set(cmd, { ts: now, value: first || null })
66
+ return first || null
67
+ }
68
+
69
+ /**
70
+ * Windows 管理员预检:net session 仅提权 shell 可成功;非 Windows 视为无需该权限。
71
+ * @returns {boolean}
72
+ */
73
+ export function isWindowsAdmin() {
74
+ if (process.platform !== 'win32') return true
75
+ try {
76
+ spawnSync('net', ['session'], { stdio: 'pipe', windowsHide: true })
77
+ return true
78
+ } catch {
79
+ return false
80
+ }
81
+ }
82
+
83
+ /**
84
+ * 后台分离进程(detached + unref,父进程退出不受影响);返回 child。
85
+ * @param {string} command
86
+ * @param {string[]} args
87
+ * @param {object} [opts]
88
+ * @returns {import('node:child_process').ChildProcess}
89
+ */
90
+ export function spawnDetached(command, args, opts = {}) {
91
+ const proc = spawn(command, args, {
92
+ detached: true,
93
+ stdio: 'ignore',
94
+ windowsHide: false,
95
+ ...opts,
96
+ })
97
+ proc.unref()
98
+ return proc
99
+ }
100
+
101
+ /**
102
+ * pgrep 查找匹配进程 pid(win32 无 pgrep,返回 [])。
103
+ * 容错:spawnSync 在参数非法时同步抛错、在 EAGAIN/maxBuffer 溢出时返回 {error,status:null},
104
+ * 一律按「无匹配」兜底,不抛异常。
105
+ * @param {string} pattern
106
+ * @returns {number[]}
107
+ */
108
+ export function findPgrepPids(pattern) {
109
+ if (process.platform === 'win32') return []
110
+ let result
111
+ try {
112
+ result = spawnSync('pgrep', ['-f', pattern])
113
+ } catch {
114
+ return []
115
+ }
116
+ if (!result || result.status !== 0) return []
117
+ const pids = []
118
+ for (const line of String(result.stdout).trim().split('\n')) {
119
+ const pid = parseInt(line.trim(), 10)
120
+ if (Number.isFinite(pid)) pids.push(pid)
121
+ }
122
+ return pids
123
+ }
124
+
125
+ /**
126
+ * TCP 可达探测(连接成功 = true)。
127
+ * @param {string} host
128
+ * @param {number|string} port
129
+ * @param {number} [timeout=3000]
130
+ * @returns {Promise<boolean>}
131
+ */
132
+ export function probeTcp(host, port, timeout = 3000) {
133
+ return new Promise((resolve) => {
134
+ const sock = net.connect({ host, port: Number(port), timeout })
135
+ sock.on('connect', () => { sock.destroy(); resolve(true) })
136
+ sock.on('error', () => resolve(false))
137
+ sock.on('timeout', () => { sock.destroy(); resolve(false) })
138
+ })
139
+ }
140
+
141
+ /**
142
+ * 本地端口是否已被监听。
143
+ * @param {number|string} port
144
+ * @param {string} [host='127.0.0.1']
145
+ * @returns {Promise<boolean>}
146
+ */
147
+ export function isPortBusy(port, host = '127.0.0.1') {
148
+ return new Promise((resolve) => {
149
+ const s = net.createServer()
150
+ s.once('error', () => resolve(true))
151
+ s.once('listening', () => s.close(() => resolve(false)))
152
+ s.listen(Number(port), host)
153
+ })
154
+ }
155
+
156
+ /**
157
+ * 跨平台查找 Chrome 可执行文件路径;找不到返回 null。
158
+ * macOS 多候选(App/Canary/Chromium/Edge/Homebrew);Linux 用 whichPath;
159
+ * Windows 用户级安装 → 注册表 App Paths → Program Files 两目录。
160
+ * @returns {string|null}
161
+ */
162
+ export function getChromePath() {
163
+ if (process.platform === 'darwin') {
164
+ const candidates = [
165
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
166
+ '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
167
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
168
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
169
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
170
+ path.join(os.homedir(), 'Applications', 'Google Chrome.app', 'Contents', 'MacOS', 'Google Chrome'),
171
+ whichPath('chromium'),
172
+ whichPath('google-chrome'),
173
+ ].filter(Boolean)
174
+ for (const c of candidates) {
175
+ if (fs.existsSync(c)) return c
176
+ }
177
+ return null
178
+ }
179
+ if (process.platform === 'linux') {
180
+ return whichPath('google-chrome') ?? whichPath('google-chrome-stable')
181
+ }
182
+ if (process.platform === 'win32') {
183
+ const candidates = [
184
+ process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Google', 'Chrome', 'Application', 'chrome.exe') : null,
185
+ regAppPath('chrome.exe'),
186
+ process.env.PROGRAMFILES ? path.join(process.env.PROGRAMFILES, 'Google', 'Chrome', 'Application', 'chrome.exe') : null,
187
+ process.env['PROGRAMFILES(X86)'] ? path.join(process.env['PROGRAMFILES(X86)'], 'Google', 'Chrome', 'Application', 'chrome.exe') : null,
188
+ ]
189
+ for (const c of candidates) {
190
+ if (c && fs.existsSync(c)) return c
191
+ }
192
+ return null
193
+ }
194
+ return null
195
+ }
196
+
197
+ /** 从注册表 App Paths 读取可执行文件路径(Windows 内置 reg.exe,无额外依赖);找不到返回 null */
198
+ function regAppPath(name) {
199
+ if (process.platform !== 'win32') return null
200
+ try {
201
+ const r = spawnSync('reg', [
202
+ 'query',
203
+ `HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${name}`,
204
+ '/ve',
205
+ ], { encoding: 'utf-8', windowsHide: true })
206
+ if (r.status !== 0) {
207
+ const r2 = spawnSync('reg', [
208
+ 'query',
209
+ `HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${name}`,
210
+ '/ve',
211
+ ], { encoding: 'utf-8', windowsHide: true })
212
+ if (r2.status !== 0) return null
213
+ return parseRegDefault(r2.stdout)
214
+ }
215
+ return parseRegDefault(r.stdout)
216
+ } catch {
217
+ return null
218
+ }
219
+ }
220
+
221
+ function parseRegDefault(out) {
222
+ // reg query /ve 输出形如: " (默认) REG_SZ C:\path\chrome.exe"
223
+ const m = String(out ?? '').match(/REG_SZ\s+(\S.*)$/m)
224
+ return m ? m[1].trim() : null
225
+ }
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { createConfigLoader, getXdgConfigDir, deepMerge, resolveEnvVars, resolvePkgVars } from './config-loader.js'
2
2
  export { Shell } from './shell.js'
3
+ export { openBrowser, whichPath, isWindowsAdmin, spawnDetached, findPgrepPids, probeTcp, isPortBusy, getChromePath } from './client-ops.js'
3
4
  export { parseProxy, openTunnel, fetchText, downloadToFile } from './http.js'
4
5
  export { parseFlags, DEFAULT_BOOLEAN_FLAGS } from './args.js'
5
6
  export { default as JSON5 } from 'json5'