zcode-pro 0.17.1 → 0.18.0

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/src/host/main.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  // 3. 连接 CDP,向所有页面注入增强脚本(新文档自动注入 + 已加载页面立即注入)
6
6
  // 4. 启动本地辅助 HTTP 服务并常驻,CDP 断开时自动重连
7
7
  import { spawn, execSync, spawnSync } from 'node:child_process';
8
- import { existsSync, readFileSync } from 'node:fs';
9
- import { dirname, resolve } from 'node:path';
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs';
9
+ import { dirname, join, resolve } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { randomBytes } from 'node:crypto';
12
12
  import { resolveZcodeExecutable, resolveDataRootDir } from './paths.mjs';
@@ -17,6 +17,17 @@ import { runSetup } from './setup.mjs';
17
17
  const here = dirname(fileURLToPath(import.meta.url));
18
18
  const INJECT_BUNDLE = resolve(here, '..', '..', 'dist', 'inject.js');
19
19
 
20
+ // Windows 控制台中文乱码适配:输出为 UTF-8 字节,旧版 conhost/PowerShell 的默认
21
+ // 代码页(936/GBK)按 GBK 解码会显示乱码。检测到真实控制台且非 UTF-8 代码页时
22
+ // 切到 65001——只影响当前控制台会话(重开终端即恢复),重定向/非交互不干预。
23
+ // 放在模块顶层:cli.mjs 经 import main.mjs 进入,setup 等子命令的输出同样受益。
24
+ if (process.platform === 'win32' && process.stdout.isTTY) {
25
+ try {
26
+ const cp = execSync('chcp.com', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).match(/(\d+)/)?.[1];
27
+ if (cp && cp !== '65001') execSync('chcp.com 65001', { stdio: 'ignore', windowsHide: true });
28
+ } catch { /* 无控制台或 chcp 不可用时保持原样 */ }
29
+ }
30
+
20
31
  function usage() {
21
32
  return `ZCode Pro — ZCode 桌面版界面增强启动器
22
33
 
@@ -74,6 +85,26 @@ function guiNotify(title, body) {
74
85
  } catch { /* ignore */ }
75
86
  }
76
87
 
88
+ // Windows 快捷方式静默启动时控制台与桌面通知都不可见(guiNotify 仅 linux 有渠道),
89
+ // 关键启动阶段与致命错误落盘 ~/.zcode/zcodepro.log,便于排查“双击没反应”(issue #1)。
90
+ // 日志超 512KB 时轮转为 .old(zcode-pro.cmd 失败分支有同款轮转,需同步维护)。
91
+ let winLogRotated = false;
92
+ function winLog(line) {
93
+ if (process.platform !== 'win32') return;
94
+ try {
95
+ const dir = resolveDataRootDir();
96
+ mkdirSync(dir, { recursive: true });
97
+ const file = join(dir, 'zcodepro.log');
98
+ if (!winLogRotated) {
99
+ winLogRotated = true;
100
+ try {
101
+ if (existsSync(file) && statSync(file).size > 512 * 1024) renameSync(file, `${file}.old`);
102
+ } catch { /* ignore */ }
103
+ }
104
+ appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`);
105
+ } catch { /* ignore */ }
106
+ }
107
+
77
108
  async function cdpAlive(port) {
78
109
  try { await fetchBrowserWsUrl(port, 1200); return true; } catch { return false; }
79
110
  }
@@ -147,9 +178,13 @@ export async function run(argv) {
147
178
  try {
148
179
  exe = resolveZcodeExecutable(args.zcodePath);
149
180
  } catch (err) {
150
- if (err.code === 'ZCODE_NOT_FOUND') guiNotify('ZCode Pro 启动失败', '未找到 ZCode 可执行文件,详见终端输出。');
181
+ if (err.code === 'ZCODE_NOT_FOUND') {
182
+ winLog('未找到 ZCode 可执行文件: ' + err.message);
183
+ guiNotify('ZCode Pro 启动失败', '未找到 ZCode 可执行文件,详见终端输出。');
184
+ }
151
185
  throw err;
152
186
  }
187
+ winLog(`启动: ZCode=${exe}, CDP 端口 ${args.cdpPort}`);
153
188
  const dataRoot = resolveDataRootDir();
154
189
  const token = randomBytes(16).toString('hex');
155
190
  const state = { injectedPages: 0 };
@@ -166,10 +201,12 @@ export async function run(argv) {
166
201
  if (!ok) {
167
202
  console.error(`[zcodepro] 等待 ZCode 调试端口 ${args.cdpPort} 超时。`);
168
203
  if (zcodeAppProcessExists()) {
204
+ winLog(`等待 CDP 端口 ${args.cdpPort} 超时:ZCode 已在运行但未开调试端口,需先退出原实例`);
169
205
  console.error('[zcodepro] 检测到 ZCode 已在运行,但未开启调试端口,无法注入。');
170
206
  console.error('[zcodepro] 请手动退出当前 ZCode(zcodepro 不会替你关闭正在运行的实例),然后重新运行 zcodepro。');
171
207
  guiNotify('ZCode Pro 暂无法注入', 'ZCode 已在运行但未开启调试端口,请退出 ZCode 后重新打开 ZCode Pro。');
172
208
  } else {
209
+ winLog(`等待 CDP 端口 ${args.cdpPort} 超时(未见 ZCode 进程)`);
173
210
  guiNotify('ZCode Pro 启动失败', `等待 ZCode 调试端口 ${args.cdpPort} 超时。`);
174
211
  }
175
212
  process.exit(1);
@@ -191,6 +228,7 @@ export async function run(argv) {
191
228
  healthRes = await fetch(`http://127.0.0.1:${args.helperPort}/health`, { headers: { 'X-ZCodePro-Token': token } }).then((r) => r.json());
192
229
  } catch (err) {
193
230
  console.error(`[zcodepro] 辅助服务健康检查失败: ${err.message}`);
231
+ winLog('辅助服务健康检查失败: ' + err.message);
194
232
  server.close();
195
233
  process.exit(1);
196
234
  }
@@ -253,10 +291,12 @@ export async function run(argv) {
253
291
  }
254
292
  if (!conn) {
255
293
  console.error('[zcodepro] 无法连接 CDP,退出。');
294
+ winLog('无法连接 CDP,退出');
256
295
  server.close();
257
296
  process.exit(1);
258
297
  }
259
298
  console.log('[zcodepro] 已连接 CDP 并开始注入。保持本进程运行以维持增强;Ctrl+C 退出(不影响 ZCode)。');
299
+ winLog('已连接 CDP 并开始注入');
260
300
 
261
301
  process.on('SIGINT', () => { stopping = true; try { conn.close(); } catch {} server.close(); process.exit(0); });
262
302
  process.on('SIGTERM', () => { stopping = true; try { conn.close(); } catch {} server.close(); process.exit(0); });
@@ -268,6 +308,7 @@ export async function run(argv) {
268
308
  // 连接断开:ZCode 退出则一并退出,否则重连
269
309
  if (!(await cdpAlive(args.cdpPort))) {
270
310
  console.log('[zcodepro] ZCode 已退出,zcodepro 一并退出。');
311
+ winLog('ZCode 已退出,zcodepro 一并退出');
271
312
  server.close();
272
313
  process.exit(0);
273
314
  }
@@ -1,13 +1,29 @@
1
1
  // 跨平台定位 ZCode 桌面版可执行文件。
2
- // 优先级:--zcode-path 参数 > ZCODEPRO_ZCODE_PATH 环境变量 > 平台默认候选路径 > 注册表(Windows) > PATH 上的 zcode。
2
+ // 优先级:--zcode-path 参数 > ZCODEPRO_ZCODE_PATH 环境变量(进程 > 用户注册表) >
3
+ // 平台默认候选路径 > 注册表 App Paths/卸载表(Windows) > PATH 上的 zcode。
3
4
  import { existsSync } from 'node:fs';
4
5
  import { join } from 'node:path';
5
6
  import { arch, homedir, platform } from 'node:os';
6
7
  import { execFileSync } from 'node:child_process';
7
8
 
9
+ // reg 输出按行取 REG_SZ 后的值,容忍引号与 ",0" 图标序号后缀。
10
+ const reg = (args) => {
11
+ try {
12
+ return execFileSync('reg', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
13
+ } catch { return ''; }
14
+ };
15
+ const valueAfter = (out) => (out.split(/REG_SZ\s+/)[1] || '').trim();
16
+
17
+ // 安装脚本 -ZCodePath 持久化的用户级环境变量(HKCU\Environment):写入后不会传播
18
+ // 到已运行的资源管理器,进程环境变量读不到时从这里兜底,免注销/重启(issue #1)。
19
+ function windowsUserEnvZcodePath() {
20
+ const value = valueAfter(reg(['query', 'HKCU\\Environment', '/v', 'ZCODEPRO_ZCODE_PATH']));
21
+ return value ? [value] : [];
22
+ }
23
+
8
24
  // Windows 注册表探测(结果缓存):自定义安装目录的安装器会在 App Paths(可执行
9
25
  // 文件全路径)或卸载表 DisplayIcon(控制面板-卸载软件里可见)留有登记,固定
10
- // 候选找不到时兜底。reg 输出按行取 REG_SZ 后的值,容忍引号与 ",0" 图标序号后缀。
26
+ // 候选找不到时兜底。
11
27
  let winRegistryCandidates;
12
28
 
13
29
  function windowsRegistryCandidates() {
@@ -21,18 +37,16 @@ function windowsRegistryCandidates() {
21
37
  winRegistryCandidates.push(p);
22
38
  }
23
39
  };
24
- const reg = (args) => {
25
- try {
26
- return execFileSync('reg', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
27
- } catch { return ''; }
28
- };
29
- const valueAfter = (out) => (out.split(/REG_SZ\s+/)[1] || '').trim();
30
40
  // App Paths:安装器注册的可执行文件全路径(ShellExecute 同款查找)
31
41
  for (const root of ['HKLM', 'HKCU']) {
32
42
  const out = reg(['query', `${root}\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\zcode.exe`, '/ve']);
33
43
  if (out) push(valueAfter(out));
34
44
  }
35
- // 卸载表 DisplayIcon:安装目录自定义时通常只有这里能找到
45
+ // 卸载表 DisplayIcon:安装目录自定义时通常只有这里能找到。
46
+ // ZCode 的 NSIS 安装器不写 App Paths、DisplayIcon 常指向 uninstallerIcon.ico,
47
+ // 从其所在目录推导 ZCode.exe(win11 虚拟机实测,issue #1)。逐条验证取第一个
48
+ // 有效命中。zcode-pro.cmd 与 install.ps1 也各自实现卸载表定位(匹配方式不完全
49
+ // 相同),改推导规则需三处同步。
36
50
  for (const key of [
37
51
  'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
38
52
  'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
@@ -41,7 +55,14 @@ function windowsRegistryCandidates() {
41
55
  const out = reg(['query', key, '/s', '/v', 'DisplayIcon']);
42
56
  for (const line of out.split(/\r?\n/)) {
43
57
  const seg = (line.split(/REG_SZ\s+/)[1] || '').trim();
44
- if (seg && /zcode/i.test(seg)) push(seg);
58
+ if (seg && /zcode/i.test(seg)) {
59
+ push(seg);
60
+ const dir = seg.replace(/[^\\\/]+$/, '');
61
+ if (dir) {
62
+ push(join(dir, 'ZCode.exe'));
63
+ push(join(dir, 'zcode.exe'));
64
+ }
65
+ }
45
66
  }
46
67
  }
47
68
  return winRegistryCandidates;
@@ -53,6 +74,7 @@ function candidates() {
53
74
  const localAppData = process.env.LOCALAPPDATA || join(home, 'AppData', 'Local');
54
75
  const programFiles = process.env.PROGRAMFILES || 'C:\\Program Files';
55
76
  return [
77
+ ...windowsUserEnvZcodePath(),
56
78
  join(localAppData, 'Programs', 'ZCode', 'ZCode.exe'),
57
79
  join(localAppData, 'Programs', 'zcode', 'zcode.exe'),
58
80
  join(programFiles, 'ZCode', 'ZCode.exe'),
@@ -1,5 +1,5 @@
1
- // 系统原生目录选择器(Linux:xdg-desktop-portal FileChooser,经 gdbus 调用,
2
- // KDE/GNOME 弹出各自的原生对话框),支持指定起始目录。
1
+ // 系统原生目录选择器(Windows:PowerShell FolderBrowserDialog;Linux:xdg-desktop-portal
2
+ // FileChooser,经 gdbus 调用,KDE/GNOME 弹出各自的原生对话框),支持指定起始目录。
3
3
  // 返回 { path: string | null }(用户取消为 null)或 { unavailable: true }(环境无可用选择器)。
4
4
 
5
5
  import { spawn, spawnSync } from 'node:child_process';
@@ -133,10 +133,55 @@ function portalPickFolder(startDir, title, timeoutMs) {
133
133
  });
134
134
  }
135
135
 
136
+ // Windows:PowerShell 的 FolderBrowserDialog(系统自带 .NET,无需额外安装)。
137
+ // -STA 是对话框的线程模型要求;无属主窗口的对话框可能沉到别的窗口后面,
138
+ // 用一个 TopMost 空窗当属主强制置前。startDir 经 SelectedPath 预选(路径不存在
139
+ // 时对话框自行忽略);输出编码固定 UTF-8——GUI 启动的进程无控制台,PowerShell
140
+ // 重定向输出默认走 OEM 代码页(中文系统是 936),Node 按 UTF-8 解码会乱码。
141
+ // 返回路径 / null(取消或超时)/ undefined(起不来)
142
+ function powershellPickFolder(startDir, title, timeoutMs = 600000) {
143
+ return new Promise((resolve) => {
144
+ const safeTitle = String(title || '').replace(/'/g, "''");
145
+ const safeStart = String(startDir || '').replace(/'/g, "''");
146
+ const script = [
147
+ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
148
+ 'Add-Type -AssemblyName System.Windows.Forms | Out-Null',
149
+ "$owner = New-Object System.Windows.Forms.Form",
150
+ '$owner.TopMost = $true',
151
+ '$d = New-Object System.Windows.Forms.FolderBrowserDialog',
152
+ `$d.Description = '${safeTitle}'`,
153
+ ...(safeStart ? [`$d.SelectedPath = '${safeStart}'`] : []),
154
+ "if ($d.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }",
155
+ ].join('; ');
156
+ let out = '';
157
+ let done = false;
158
+ const finish = (v) => {
159
+ if (done) return;
160
+ done = true;
161
+ clearTimeout(timer);
162
+ try { child.kill(); } catch { /* ignore */ }
163
+ resolve(v);
164
+ };
165
+ const child = spawn('powershell', ['-NoProfile', '-STA', '-Command', script], {
166
+ stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
167
+ });
168
+ child.on('error', () => finish(undefined));
169
+ child.stdout.on('data', (d) => { out += d.toString(); });
170
+ // 退出码非 0 = PowerShell 本身出错(视为不可用);0 且无输出 = 用户取消
171
+ child.on('close', (code) => finish(code === 0 ? (out.trim() || null) : undefined));
172
+ const timer = setTimeout(() => finish(null), timeoutMs);
173
+ });
174
+ }
175
+
136
176
  export async function pickFolderSystem(startDir, title, timeoutMs = 180000) {
177
+ const dialogTitle = title || '选择文件夹';
178
+ if (process.platform === 'win32') {
179
+ const r = await powershellPickFolder(String(startDir || ''), dialogTitle, timeoutMs);
180
+ if (r !== undefined) return { path: r };
181
+ return { unavailable: true };
182
+ }
137
183
  if (process.platform !== 'linux') return { unavailable: true };
138
184
  const start = firstExistingDir(String(startDir || '/'));
139
- const dialogTitle = title || '选择文件夹';
140
185
  if (kdialogAvailable()) {
141
186
  const r = await kdialogPickFolder(start, dialogTitle);
142
187
  if (r !== undefined) return { path: r };
@@ -148,7 +148,7 @@ const psQuote = (s) => String(s).replace(/'/g, "''"); // PowerShell 单引号字
148
148
  // 桌面目录由 PowerShell 的 GetFolderPath 解析(正确跟随 OneDrive 重定向等)。
149
149
  // wshOk 为假(WSH 异常的机器)时降级:.lnk 直接经 cmd 最小化启动,不再依赖
150
150
  // wscript+vbs,代价是启动瞬间任务栏短暂闪一个最小化控制台
151
- export function windowsShortcutPlan(cmd, desktop = false, wshOk = true) {
151
+ export function windowsShortcutPlan(cmd, desktop = false, wshOk = true, menuLnk = true) {
152
152
  const paths = windowsPaths();
153
153
  // vbs 由 wscript 按 ANSI 代码页解析(不支持 UTF-8),内容只用 ASCII
154
154
  const vbsContent = [
@@ -163,7 +163,7 @@ export function windowsShortcutPlan(cmd, desktop = false, wshOk = true) {
163
163
  ' Next',
164
164
  ' cmd = cmd & " " & Join(parts, " ")',
165
165
  'End If',
166
- 'sh.Run cmd, 0, False',
166
+ 'sh.Run "cmd.exe /d /c " & cmd, 0, False',
167
167
  '',
168
168
  ].join('\r\n');
169
169
  // 图标复用已安装的 ZCode:探测走 paths.mjs 同一条优先级(含注册表,覆盖自定义
@@ -193,9 +193,11 @@ export function windowsShortcutPlan(cmd, desktop = false, wshOk = true) {
193
193
  "$ErrorActionPreference = 'Stop'",
194
194
  '$wsh = New-Object -ComObject WScript.Shell',
195
195
  ...setProps,
196
- `$lnk = $wsh.CreateShortcut('${psQuote(paths.lnkPath)}')`,
197
- 'setProps $lnk',
198
- '$lnk.Save()',
196
+ ...(menuLnk ? [
197
+ `$lnk = $wsh.CreateShortcut('${psQuote(paths.lnkPath)}')`,
198
+ 'setProps $lnk',
199
+ '$lnk.Save()',
200
+ ] : []),
199
201
  ...(desktop ? [
200
202
  "$desk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'ZCode Pro.lnk'",
201
203
  '$dl = $wsh.CreateShortcut($desk)',
@@ -209,6 +211,16 @@ export function windowsShortcutPlan(cmd, desktop = false, wshOk = true) {
209
211
  return { ...paths, vbsContent, psScript, icon, wshOk };
210
212
  }
211
213
 
214
+ // 桌面 .lnk 的 JS 侧近似路径(仅用于结果汇报与“外来不覆盖”预判;实际创建仍走
215
+ // PowerShell 的 GetFolderPath,正确跟随 OneDrive 重定向)。OneDrive 桌面重定向时
216
+ // 桌面在 %OneDrive%\Desktop,未重定向时在用户主目录
217
+ export function desktopLnkPathApprox() {
218
+ for (const od of [process.env.OneDrive, process.env.OneDriveConsumer]) {
219
+ if (od && existsSync(join(od, 'Desktop'))) return join(od, 'Desktop', 'ZCode Pro.lnk');
220
+ }
221
+ return join(homedir(), 'Desktop', 'ZCode Pro.lnk');
222
+ }
223
+
212
224
  // —— 快捷方式设置项 ——
213
225
 
214
226
  const DESKTOP_FILE = 'zcode-pro.desktop';
@@ -346,10 +358,18 @@ const shortcutItem = {
346
358
 
347
359
  installWindows(cmd, opts = {}) {
348
360
  const wshOk = windowsWshOk();
349
- const plan = windowsShortcutPlan(cmd, !!opts.desktop, wshOk);
350
- // .lnk 已存在且不属于本功能(Arguments 无归属标记)= install.ps1 等其他方式创建,不覆盖
351
- if (existsSync(plan.lnkPath) && !lnkIsOurs(plan.lnkPath)) {
352
- return { skipped: `已存在其他方式创建的 ${plan.lnkPath},未覆盖` };
361
+ // 菜单/桌面两个 .lnk 各自独立判断“外来不覆盖”,互不连坐(与 Linux/macOS 的
362
+ // 桌面条目一致):开始菜单被 install.ps1 等创建占位时,桌面快捷方式仍应能建
363
+ const paths = windowsPaths();
364
+ const menuForeign = existsSync(paths.lnkPath) && !lnkIsOurs(paths.lnkPath);
365
+ const deskPath = desktopLnkPathApprox();
366
+ const deskForeign = !!opts.desktop && existsSync(deskPath) && !lnkIsOurs(deskPath);
367
+ const plan = windowsShortcutPlan(cmd, !!opts.desktop, wshOk, !menuForeign);
368
+ if (menuForeign && (!opts.desktop || deskForeign)) {
369
+ return { skipped: [
370
+ menuForeign ? `已存在其他方式创建的 ${plan.lnkPath},未覆盖` : '',
371
+ deskForeign ? `桌面 ${deskPath} 已存在其他方式创建的条目,未覆盖` : '',
372
+ ].filter(Boolean).join(';') };
353
373
  }
354
374
  if (wshOk) {
355
375
  mkdirSync(plan.vbsDir, { recursive: true });
@@ -359,7 +379,12 @@ const shortcutItem = {
359
379
  const how = wshOk
360
380
  ? `经 ${plan.vbsPath} 静默启动`
361
381
  : '本机 WSH 异常,经 cmd 最小化启动(启动时会短暂闪烁控制台窗口)';
362
- return { detail: `已创建 ${plan.lnkPath}(${how}${opts.desktop ? ',含桌面' : ''})` };
382
+ const parts = [];
383
+ parts.push(menuForeign
384
+ ? `开始菜单 ${plan.lnkPath} 已存在其他方式创建的条目,未覆盖`
385
+ : `已创建 ${plan.lnkPath}`);
386
+ if (opts.desktop) parts.push(deskForeign ? `桌面 ${deskPath} 已存在其他方式创建的条目,未覆盖` : '已创建桌面快捷方式');
387
+ return { detail: `${parts.join(';')}(${how})` };
363
388
  },
364
389
 
365
390
  installMac(cmd, opts = {}) {
@@ -1,7 +1,10 @@
1
- // WebDAV 传输(设置同步用):用 curl 子进程完成 GET / PUT / MKCOL。
2
- // Basic 凭据经 stdin 的 --config 文件传入,不进命令行参数;代理由调用方以
3
- // proxyUrl 注入(与 helper 其他网络访问一致,空 = 不用代理)。
4
- import { spawn } from 'node:child_process';
1
+ // WebDAV 传输(设置同步用,rtk 升级的 GitHub 请求也复用):优先用 curl 子进程完成
2
+ // GET / PUT / MKCOL / DELETE / PROPFIND。Basic 凭据经 stdin 的 --config 文件传入,
3
+ // 不进命令行参数;代理由调用方以 proxyUrl 注入(与 helper 其他网络访问一致,空 = 不用代理)。
4
+ // 环境里没有 curl 时(常见于老 Windows / 精简系统虚拟机)自动回退到 Node 自带
5
+ // fetch 直连:凭据改走 Authorization 头,代理在该路径不生效;设
6
+ // ZCODEPRO_SYNC_FORCE_FETCH=1 可强制走 fetch(排障/测试用)。
7
+ import { spawn, spawnSync } from 'node:child_process';
5
8
  import { mkdtemp } from 'node:fs/promises'; // fs 的 mkdtemp 是回调版,await 它会把 undefined 当回调
6
9
  import { readFileSync, rmSync } from 'node:fs';
7
10
  import { tmpdir } from 'node:os';
@@ -20,6 +23,7 @@ const CURL_EXIT = {
20
23
 
21
24
  // HTTP 状态 → 同步场景的人话(404 不算失败,由调用方按“云端没有数据”处理)
22
25
  export function webdavHttpText(code) {
26
+ if (code === 404) return '文件或目录不存在(HTTP 404,可能已被删除)';
23
27
  if (code === 401 || code === 403) return `登录或密码被拒绝(HTTP ${code};坚果云等网盘需使用应用密码,不是登录密码)`;
24
28
  if (code === 405) return '服务器不允许该操作(HTTP 405)';
25
29
  if (code === 409) return '目标目录不存在(HTTP 409)';
@@ -40,15 +44,92 @@ function proxyEnvVars(proxyUrl) {
40
44
  };
41
45
  }
42
46
 
43
- // 执行一次 WebDAV 请求。method 为 GET / PUT / MKCOL;PUT 用 uploadFile 上传
44
- // (-T 隐含 PUT)。返回 { ok, httpCode, body, error }:ok 表示 curl 本身执行
45
- // 成功(HTTP 4xx/5xx 也算 ok,由调用方看 httpCode 分支);error 为中文原因。
46
- export async function webdavRequest({ method, url, login = '', password = '', uploadFile = null, proxyUrl = '', timeoutMs = 45000 }) {
47
+ // curl 解析(进程内只探测一次):先按 PATH 探测;Windows 上再补测 System32 下的
48
+ // curl.exe(Win10 1803+ 自带,但精简环境的 PATH 可能不含)。都不存在返回 null,
49
+ // 后续请求全部走 fetch 兜底。ZCODEPRO_SYNC_FORCE_FETCH=1 强制跳过 curl(排障/测试)。
50
+ // 导出供 /proxy/test(代理连通性检测也依赖 curl,同样受精简 PATH 影响)复用。
51
+ let curlResolved;
52
+ export function findCurl() {
53
+ if (curlResolved !== undefined) return curlResolved;
54
+ if (process.env.ZCODEPRO_SYNC_FORCE_FETCH === '1') {
55
+ curlResolved = null;
56
+ return null;
57
+ }
58
+ const candidates = ['curl'];
59
+ if (process.platform === 'win32') {
60
+ const sysRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
61
+ candidates.push(join(sysRoot, 'System32', 'curl.exe'));
62
+ }
63
+ for (const c of candidates) {
64
+ const r = spawnSync(c, ['--version'], { windowsHide: true, encoding: 'utf8' });
65
+ if (!r.error && r.status === 0) {
66
+ curlResolved = c;
67
+ return c;
68
+ }
69
+ }
70
+ curlResolved = null;
71
+ return null;
72
+ }
73
+
74
+ // fetch 网络异常 → 与 curl 版一致的人话(具体错误码在 err.cause 里)
75
+ function fetchErrText(err) {
76
+ const cause = err?.cause || err;
77
+ const code = cause?.code || '';
78
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return '无法解析服务器地址';
79
+ if (code === 'ECONNREFUSED') return '无法连接服务器';
80
+ if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'UND_ERR_HEADERS_TIMEOUT'
81
+ || err?.name === 'TimeoutError' || err?.name === 'AbortError') return '超时';
82
+ if (code === 'CERT_HAS_EXPIRED' || code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
83
+ || code === 'SELF_SIGNED_CERT_IN_CHAIN') return 'SSL 证书无效';
84
+ if (code === 'EPROTO') return 'SSL 握手失败';
85
+ return `网络请求失败:${cause?.message || String(err)}`;
86
+ }
87
+
88
+ // curl 不可用时的兜底实现:返回值约定与 curl 版一致({ ok, httpCode, body, error,
89
+ // redirectUrl },HTTP 4xx/5xx 也算 ok)。凭据走 Authorization 头;proxyUrl 在这条
90
+ // 路径不生效(fetch 不读代理环境变量,调用方仍照传以免改签名);redirect 不跟随
91
+ // (对齐 curl 不带 -L),302 的目标地址放 redirectUrl
92
+ async function fetchWebdavRequest({ method, url, login = '', password = '', uploadFile = null, timeoutMs = 45000, headers = [] }) {
93
+ // curl 路径的 headers 是 ["Depth: 1"] 字符串数组,fetch 要对象:按第一个冒号拆开
94
+ const hdrs = {};
95
+ for (const h of headers) {
96
+ const i = h.indexOf(':');
97
+ if (i > 0) hdrs[h.slice(0, i).trim()] = h.slice(i + 1).trim();
98
+ }
99
+ if (method === 'PUT') hdrs['Content-Type'] = 'application/json';
100
+ if (login || password) hdrs['Authorization'] = 'Basic ' + Buffer.from(`${login}:${password}`).toString('base64');
101
+ try {
102
+ const res = await fetch(url, {
103
+ method,
104
+ headers: hdrs,
105
+ redirect: 'manual',
106
+ signal: AbortSignal.timeout(Math.max(10000, timeoutMs)),
107
+ body: method === 'PUT' && uploadFile ? readFileSync(uploadFile) : undefined,
108
+ });
109
+ const body = await res.text().catch(() => '');
110
+ return { ok: true, httpCode: res.status, body, error: '', redirectUrl: res.headers.get('location') || '' };
111
+ } catch (err) {
112
+ return { ok: false, httpCode: null, body: '', error: fetchErrText(err), redirectUrl: '' };
113
+ }
114
+ }
115
+
116
+ // 执行一次 WebDAV 请求。method 为 GET / PUT / MKCOL / DELETE / PROPFIND;PUT 用
117
+ // uploadFile 上传(-T 隐含 PUT),PROPFIND 经 headers 附加 Depth 等请求头。
118
+ // 返回 { ok, httpCode, body, error, redirectUrl }:ok 表示 curl 本身执行成功
119
+ // (HTTP 4xx/5xx 也算 ok,由调用方看 httpCode 分支);error 为中文原因;
120
+ // redirectUrl 为重定向目标(请求不跟随重定向,302 时才有值)。
121
+ export async function webdavRequest({ method, url, login = '', password = '', uploadFile = null, proxyUrl = '', timeoutMs = 45000, headers = [] }) {
122
+ const curl = findCurl();
123
+ if (!curl) return fetchWebdavRequest({ method, url, login, password, uploadFile, timeoutMs, headers });
47
124
  const dir = await mkdtemp(join(tmpdir(), 'zcodepro-webdav-'));
48
125
  const bodyFile = join(dir, 'body');
49
126
  try {
50
127
  const args = ['-sS', '--connect-timeout', '10', '--max-time', String(Math.max(10, Math.ceil(timeoutMs / 1000))),
51
- '-w', '%{http_code}', '-o', bodyFile, '--config', '-'];
128
+ '-w', '%{http_code} %{redirect_url}', '-o', bodyFile, '--config', '-'];
129
+ for (const hd of headers) {
130
+ // 头只能是这里拼好的固定字符串(Depth: 1 等),带换行的一律丢弃
131
+ if (typeof hd === 'string' && hd && !/[\r\n]/.test(hd)) args.push('-H', hd);
132
+ }
52
133
  if (method === 'PUT' && uploadFile) {
53
134
  args.push('-H', 'Content-Type: application/json', '-T', uploadFile);
54
135
  } else {
@@ -57,7 +138,7 @@ export async function webdavRequest({ method, url, login = '', password = '', up
57
138
  args.push(url);
58
139
  const cfg = login || password ? `user = ${curlQuote(`${login}:${password}`)}\n` : '';
59
140
  const r = await new Promise((resolveRun) => {
60
- const child = spawn('curl', args, { env: { ...process.env, ...proxyEnvVars(proxyUrl) }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
141
+ const child = spawn(curl, args, { env: { ...process.env, ...proxyEnvVars(proxyUrl) }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
61
142
  let stdout = '';
62
143
  let stderr = '';
63
144
  child.stdout.on('data', (d) => { stdout += d.toString(); });
@@ -70,7 +151,8 @@ export async function webdavRequest({ method, url, login = '', password = '', up
70
151
  child.stdin.write(cfg);
71
152
  child.stdin.end();
72
153
  });
73
- if (r.code === -1) return { ok: false, httpCode: null, body: '', error: '未找到 curl 命令(WebDAV 同步依赖 curl)' };
154
+ // 探测时还在、用时被删掉的极端情形:同样转 fetch 兜底,不再报“依赖 curl”
155
+ if (r.code === -1) return fetchWebdavRequest({ method, url, login, password, uploadFile, timeoutMs, headers });
74
156
  if (r.code !== 0) {
75
157
  const why = CURL_EXIT[r.code] || `curl 退出码 ${r.code}`;
76
158
  const hint = (r.stderr.split('\n').find((l) => l.trim()) || '').trim();
@@ -78,8 +160,10 @@ export async function webdavRequest({ method, url, login = '', password = '', up
78
160
  }
79
161
  let body = '';
80
162
  try { body = readFileSync(bodyFile, 'utf8'); } catch { /* 无响应体 */ }
163
+ // -w 追加了 redirect_url(无重定向时为空),parseInt 只取开头的状态码不受影响
81
164
  const httpCode = Number(parseInt(r.stdout.trim(), 10)) || 0;
82
- return { ok: true, httpCode, body, error: '' };
165
+ const redirectUrl = r.stdout.trim().split(/\s+/)[1] || '';
166
+ return { ok: true, httpCode, body, error: '', redirectUrl };
83
167
  } finally {
84
168
  try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
85
169
  }
@@ -103,3 +187,35 @@ export async function webdavPutWithMkcol({ url, login, password, uploadFile, pro
103
187
  } catch { /* URL 解析失败:直接重试原 PUT */ }
104
188
  return webdavRequest({ method: 'PUT', url, uploadFile, ...auth, timeoutMs });
105
189
  }
190
+
191
+ // 解析 PROPFIND(Depth: 1)的 multistatus 响应,只取文件条目:各网盘的命名空间
192
+ // 前缀不一(D: / d: / 无前缀),用宽松的标签匹配手写解析(项目零依赖,保持)。
193
+ // href 可能是绝对路径也可能是完整 URL,统一取最后一段并解码成文件名。
194
+ // 返回 [{ name, size, lastModified }]:size 为字节数,lastModified 为 Date.parse
195
+ // 出的毫秒时间戳(缺失为 null);目录条目与解析不出的条目直接跳过。
196
+ export function parseWebdavListing(body) {
197
+ const out = [];
198
+ if (!body || typeof body !== 'string') return out;
199
+ const respRe = /<(?:[A-Za-z0-9_.-]+:)?response(?:\s[^>]*)?>([\s\S]*?)<\/(?:[A-Za-z0-9_.-]+:)?response>/g;
200
+ const tagRe = (name) => new RegExp(`<(?:[A-Za-z0-9_.-]+:)?${name}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[A-Za-z0-9_.-]+:)?${name}>`);
201
+ let m;
202
+ while ((m = respRe.exec(body)) !== null) {
203
+ const block = m[1];
204
+ const tag = (name) => {
205
+ const t = tagRe(name).exec(block);
206
+ return t ? t[1].trim() : '';
207
+ };
208
+ const href = tag('href');
209
+ if (!href) continue;
210
+ // 目录条目(collection 标记或 href 以 / 结尾)不要
211
+ if (/<(?:[A-Za-z0-9_.-]+:)?collection[\s/>]/.test(block)) continue;
212
+ if (href.endsWith('/')) continue;
213
+ const rawName = (href.split('?')[0].split('#')[0].split('/').filter(Boolean).pop()) || '';
214
+ let name = rawName;
215
+ try { name = decodeURIComponent(rawName); } catch { /* 已是普通字符串 */ }
216
+ if (!name) continue;
217
+ const lm = tag('getlastmodified');
218
+ out.push({ name, size: Number(tag('getcontentlength')) || 0, lastModified: lm ? (Date.parse(lm) || null) : null });
219
+ }
220
+ return out;
221
+ }