zcode-pro 0.17.2 → 0.19.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
@@ -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
 
@@ -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 = [
@@ -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 = {}) {
@@ -206,11 +206,94 @@ export async function listOverriddenTitles(dataRoot) {
206
206
  return { titles };
207
207
  }
208
208
 
209
+ // 智能起名写入:把生成标题按“手动重命名”存入索引(title_overridden = 1),
210
+ // 之后官方自动标题不会再覆盖这条记录。仅在会话未删除、且从未被手动重命名过
211
+ // (title_overridden = 0)时写入——手动重命名永远优先。返回 { written, title };
212
+ // written = false 表示没写(已重命名或会话不在索引中)。库被占用时抛 index-busy。
213
+ export async function setTaskTitleIfNotOverridden(dataRoot, sessId, title) {
214
+ const dbFile = taskIndexPath(dataRoot);
215
+ if (!existsSync(dbFile)) return { written: false, title };
216
+ const driver = await loadDriver();
217
+ if (!driver) {
218
+ const err = new Error('缺少 SQLite 支持(需要 Node ≥23.4 或 sqlite3 命令)');
219
+ err.code = 'index-error';
220
+ throw err;
221
+ }
222
+ try {
223
+ if (driver.kind === 'node') {
224
+ const db = new driver.DatabaseSync(dbFile);
225
+ try {
226
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
227
+ db.exec('BEGIN IMMEDIATE');
228
+ try {
229
+ const r = db.prepare(
230
+ 'UPDATE tasks SET title = ?, title_overridden = 1 WHERE task_id = ? AND deleted = 0 AND title_overridden = 0'
231
+ ).run(title, sessId);
232
+ db.exec('COMMIT');
233
+ return { written: r.changes > 0, title };
234
+ } catch (err) {
235
+ try { db.exec('ROLLBACK'); } catch { /* 已回滚/连接失效 */ }
236
+ throw err;
237
+ }
238
+ } finally { db.close(); }
239
+ }
240
+ const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
241
+ const r = runCli(dbFile,
242
+ 'BEGIN IMMEDIATE;\n'
243
+ + `UPDATE tasks SET title = ${esc(title)}, title_overridden = 1`
244
+ + ` WHERE task_id = ${esc(sessId)} AND deleted = 0 AND title_overridden = 0;\n`
245
+ + 'SELECT changes();\nCOMMIT;');
246
+ return { written: Number(r.stdout.trim()) > 0, title };
247
+ } catch (err) {
248
+ if (isBusyError(err)) {
249
+ const e = new Error('任务索引正被占用(ZCode 可能正在写入)');
250
+ e.code = 'index-busy';
251
+ throw e;
252
+ }
253
+ const e = new Error('写入任务标题失败: ' + (err?.message || err));
254
+ e.code = 'index-error';
255
+ throw e;
256
+ }
257
+ }
258
+
209
259
  function isBusyError(err) {
210
260
  const s = String(err?.code || '') + String(err?.message || '');
211
261
  return /SQLITE_BUSY|database is locked/i.test(s);
212
262
  }
213
263
 
264
+ // 读某会话记录的模型信息(智能起名“跟随当前会话”用)。model 列存
265
+ // “<供应商id>/<模型id>”(如 be0a90cb…/glm-5.3),拆开后返回
266
+ // { providerId, modelId };没有斜线时 providerId 退回 provider 列的值。
267
+ // 会话不存在返回 null;读取失败按 null 处理(调用方换明确报错)
268
+ export async function getTaskProviderModel(dataRoot, sessId) {
269
+ const dbFile = taskIndexPath(dataRoot);
270
+ if (!existsSync(dbFile)) return null;
271
+ const driver = await loadDriver();
272
+ if (!driver) return null;
273
+ let row = null;
274
+ try {
275
+ if (driver.kind === 'node') {
276
+ const db = new driver.DatabaseSync(dbFile);
277
+ try {
278
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
279
+ row = db.prepare('SELECT provider, model FROM tasks WHERE task_id = ? AND deleted = 0').get(sessId) || null;
280
+ } finally { db.close(); }
281
+ } else {
282
+ const esc = (v) => "'" + String(v).replace(/'/g, "''") + "'";
283
+ const r = runCli(dbFile,
284
+ `SELECT coalesce(json_object('provider', provider, 'model', model), 'null') FROM tasks WHERE task_id = ${esc(sessId)} AND deleted = 0;`);
285
+ const parsed = JSON.parse((r.stdout.trim() || 'null'));
286
+ row = parsed && typeof parsed === 'object' ? parsed : null;
287
+ }
288
+ } catch { /* 读不了就当没有 */ }
289
+ if (!row) return null;
290
+ const model = String(row.model || '');
291
+ const slash = model.indexOf('/');
292
+ return slash > 0
293
+ ? { providerId: model.slice(0, slash), modelId: model.slice(slash + 1) }
294
+ : { providerId: String(row.provider || ''), modelId: model };
295
+ }
296
+
214
297
  // 任务(会话)ID → 工作区路径(无尾分隔符):空闲回收在 Windows 上的后台命令
215
298
  // 归属判定用(命令行里只带会话 ID,需经索引反查它属于哪个项目)。
216
299
  // 读取失败返回空表,调用方按“无法归属”保守处理
@@ -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
+ }