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/README.en.md +6 -3
- package/README.md +6 -3
- package/dist/inject.js +795 -105
- package/package.json +1 -1
- package/src/host/helper.mjs +794 -125
- package/src/host/main.mjs +44 -3
- package/src/host/paths.mjs +32 -10
- package/src/host/pickFolder.mjs +48 -3
- package/src/host/setup.mjs +35 -10
- package/src/host/webdav.mjs +128 -12
- package/src/inject/core.js +140 -32
- package/src/inject/features/plugin-updates.js +1 -1
- package/src/inject/features/settings-dialog.js +597 -70
- package/src/inject/features/styles.js +15 -1
- package/src/inject/index.js +12 -2
- package/src/inject/ui.js +3 -2
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')
|
|
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
|
}
|
package/src/host/paths.mjs
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
// 跨平台定位 ZCode 桌面版可执行文件。
|
|
2
|
-
// 优先级:--zcode-path 参数 > ZCODEPRO_ZCODE_PATH
|
|
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
|
-
// 候选找不到时兜底。
|
|
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))
|
|
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'),
|
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 = [
|
|
@@ -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
|
-
|
|
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/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
|
+
}
|