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/README.en.md +1 -0
- package/README.md +1 -0
- package/dist/inject.js +1368 -216
- package/package.json +1 -1
- package/src/host/helper.mjs +1248 -156
- package/src/host/main.mjs +11 -0
- package/src/host/pickFolder.mjs +48 -3
- package/src/host/setup.mjs +34 -9
- package/src/host/taskIndex.mjs +83 -0
- package/src/host/webdav.mjs +128 -12
- package/src/inject/core.js +190 -42
- package/src/inject/features/file-menu.js +9 -2
- package/src/inject/features/plugin-updates.js +1 -1
- package/src/inject/features/settings-dialog.js +821 -118
- package/src/inject/features/styles.js +15 -1
- package/src/inject/features/title-gen.js +169 -0
- package/src/inject/features/title-lock.js +14 -0
- package/src/inject/features/toolbar-icons.js +38 -4
- package/src/inject/index.js +14 -2
- package/src/inject/provider-fields.js +71 -0
- package/src/inject/ui.js +23 -7
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
|
|
package/src/host/pickFolder.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// 系统原生目录选择器(Linux:xdg-desktop-portal
|
|
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 };
|
package/src/host/setup.mjs
CHANGED
|
@@ -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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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 = {}) {
|
package/src/host/taskIndex.mjs
CHANGED
|
@@ -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
|
// 读取失败返回空表,调用方按“无法归属”保守处理
|
package/src/host/webdav.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
// WebDAV
|
|
2
|
-
// Basic 凭据经 stdin 的 --config
|
|
3
|
-
// proxyUrl 注入(与 helper 其他网络访问一致,空 = 不用代理)。
|
|
4
|
-
|
|
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
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|