@wanghaopeng1148/deskpet 2.0.0 → 2.0.2
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.md +70 -16
- package/bin/deskpet.mjs +70 -2
- package/dist/node/server/db/database.js +113 -0
- package/dist/node/server/db/migrate-legacy.js +88 -0
- package/dist/node/server/db/task-repository.js +374 -0
- package/dist/node/server/http/http-server.js +493 -0
- package/dist/node/server/http/ws-hub.js +59 -0
- package/dist/node/server/main.js +291 -0
- package/dist/node/server/plugins/actions/builtin.js +67 -0
- package/dist/node/server/plugins/actions/clipboard-watch.js +27 -0
- package/dist/node/server/plugins/actions/http-request.js +41 -0
- package/dist/node/server/plugins/actions/jenkins-build.js +183 -0
- package/dist/node/server/plugins/actions/open-app.js +41 -0
- package/dist/node/server/plugins/actions/python-script.js +180 -0
- package/dist/node/server/plugins/actions/screenshot.js +38 -0
- package/dist/node/server/plugins/actions/send-keystroke.js +100 -0
- package/dist/node/server/plugins/actions/show-reminder.js +7 -0
- package/dist/node/server/plugins/actions/ssh-command.js +123 -0
- package/dist/node/server/plugins/actions/task-chain.js +24 -0
- package/dist/node/server/plugins/actions/volume-control.js +31 -0
- package/dist/node/server/plugins/index.js +35 -0
- package/dist/node/server/plugins/registry.js +23 -0
- package/dist/node/server/services/clipboard-watcher.js +112 -0
- package/dist/node/server/services/config-store.js +141 -0
- package/dist/node/server/services/idle-monitor.js +131 -0
- package/dist/node/server/services/notifier.js +36 -0
- package/dist/node/server/services/quick-actions-store.js +52 -0
- package/dist/node/server/services/remote-connector.js +67 -0
- package/dist/node/server/services/scanner-reader.js +217 -0
- package/dist/node/server/services/script-runner.js +228 -0
- package/dist/node/server/services/snapshot-service.js +135 -0
- package/dist/node/server/services/task-scheduler.js +813 -0
- package/dist/node/server/services/wechat-bot.js +635 -0
- package/dist/node/server/services/wechat-command-types.js +1 -0
- package/dist/node/server/services/wechat-commands.js +330 -0
- package/dist/node/server/suppress-warnings.js +12 -0
- package/dist/node/server/utils/asset-url.js +26 -0
- package/dist/node/server/utils/auto-start.js +186 -0
- package/dist/node/server/utils/clipboard.js +50 -0
- package/dist/node/server/utils/dashboard-url.js +8 -0
- package/dist/node/server/utils/instance-guard.js +165 -0
- package/dist/node/server/utils/native-notify.js +53 -0
- package/dist/node/server/utils/open.js +37 -0
- package/dist/node/server/utils/paths.js +95 -0
- package/dist/node/server/utils/python-interpreter.js +129 -0
- package/dist/node/shared/animation-engine.js +349 -0
- package/dist/node/shared/chain-condition.js +39 -0
- package/dist/node/shared/cron-weekly.js +124 -0
- package/dist/node/shared/py-task-params.js +335 -0
- package/dist/node/shared/types.js +69 -0
- package/package.json +6 -2
- package/server/http/http-server.ts +4 -1
- package/server/utils/auto-start.ts +158 -49
- package/server/utils/paths.ts +28 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 单实例保护 —— 判定「是否已有另一个 DeskPet 实例正在运行」。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不能只靠端口检测(main.ts 的 listen):
|
|
5
|
+
* Node/libuv 在 Windows 上默认给监听套接字设置 SO_REUSEADDR,而 Windows 的
|
|
6
|
+
* 语义与 Linux 不同 —— 它允许**多个进程同时绑定同一端口且都 listen 成功**
|
|
7
|
+
* (Linux 下会直接 EADDRINUSE)。因此「端口占用了就当已有实例」这个假设
|
|
8
|
+
* 在 Windows 上基本不成立,多个实例可以并存且互不知晓。
|
|
9
|
+
*
|
|
10
|
+
* 为什么这件事很要紧:
|
|
11
|
+
* 每个实例启动都会执行 TaskScheduler 的残留恢复(recoverInterrupted)——
|
|
12
|
+
* 它把库里所有 running / pending 的任务都当成「上个进程崩溃留下的」,
|
|
13
|
+
* 复位状态并把未结束的执行记录收尾为中断。若同时有实例正在跑任务,
|
|
14
|
+
* 新实例这一下就会把对方正在执行的任务掐掉,用户看到的现象是
|
|
15
|
+
* 「刚点运行不到 1 秒就报:服务重启,执行被中断」,且任务日志一个字节都没产生。
|
|
16
|
+
*
|
|
17
|
+
* 判定方式:心跳文件。运行中的实例定期刷新其内容(pid + 时间戳),
|
|
18
|
+
* 启动时若发现心跳足够新且对应进程真的还活着,就认定已有实例在跑,
|
|
19
|
+
* 此时必须跳过破坏性的恢复动作。
|
|
20
|
+
*/
|
|
21
|
+
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { getDataRoot } from "./paths.js";
|
|
24
|
+
/** 心跳刷新间隔 */
|
|
25
|
+
const HEARTBEAT_MS = 5000;
|
|
26
|
+
/** 心跳新鲜期:超过这个时长没刷新,就认为前一个实例已经退出 */
|
|
27
|
+
const STALE_MS = 20000;
|
|
28
|
+
function heartbeatPath() {
|
|
29
|
+
return join(getDataRoot(), '.deskpet-alive');
|
|
30
|
+
}
|
|
31
|
+
/** 探测进程是否存活(signal 0 只做存在性检查,不真正投递信号) */
|
|
32
|
+
export function isProcessAlive(pid) {
|
|
33
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
34
|
+
return false;
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function readHeartbeat() {
|
|
44
|
+
try {
|
|
45
|
+
const raw = readFileSync(heartbeatPath(), 'utf-8').trim();
|
|
46
|
+
const [pidText, atText] = raw.split(':');
|
|
47
|
+
const pid = Number(pidText);
|
|
48
|
+
const at = Number(atText);
|
|
49
|
+
if (!Number.isFinite(pid) || !Number.isFinite(at))
|
|
50
|
+
return null;
|
|
51
|
+
return { pid, at };
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 是否已有「另一个仍在运行」的实例。
|
|
59
|
+
* 心跳过旧、或其进程已不存在,都视为没有(前一个实例是崩溃/被强杀退出的)。
|
|
60
|
+
*/
|
|
61
|
+
export function anotherInstanceAlive(now = Date.now()) {
|
|
62
|
+
const hb = readHeartbeat();
|
|
63
|
+
if (!hb)
|
|
64
|
+
return false;
|
|
65
|
+
if (hb.pid === process.pid)
|
|
66
|
+
return false;
|
|
67
|
+
if (now - hb.at > STALE_MS)
|
|
68
|
+
return false;
|
|
69
|
+
return isProcessAlive(hb.pid);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 开始为自己打心跳,返回停止函数。
|
|
73
|
+
* 定时器 unref,不阻止进程退出。
|
|
74
|
+
*/
|
|
75
|
+
export function startHeartbeat() {
|
|
76
|
+
const beat = () => {
|
|
77
|
+
try {
|
|
78
|
+
mkdirSync(getDataRoot(), { recursive: true });
|
|
79
|
+
writeFileSync(heartbeatPath(), `${process.pid}:${Date.now()}`, 'utf-8');
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* 心跳写失败不能影响主流程 */
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
beat();
|
|
86
|
+
const timer = setInterval(beat, HEARTBEAT_MS);
|
|
87
|
+
timer.unref?.();
|
|
88
|
+
return () => clearInterval(timer);
|
|
89
|
+
}
|
|
90
|
+
// ── 单实例锁(原子) ─────────────────────────────────────────
|
|
91
|
+
function lockPath() {
|
|
92
|
+
return join(getDataRoot(), '.deskpet.lock');
|
|
93
|
+
}
|
|
94
|
+
function readLockPid() {
|
|
95
|
+
try {
|
|
96
|
+
const pid = Number(readFileSync(lockPath(), 'utf-8').trim());
|
|
97
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 抢占单实例锁。
|
|
105
|
+
*
|
|
106
|
+
* 为什么需要它:心跳方案(above)是「先读、再判、后写」三步,天生有竞态 ——
|
|
107
|
+
* 两个实例几乎同时启动时,双方都可能读到「没有心跳 / 心跳过期」,于是双双认定
|
|
108
|
+
* 「我是唯一实例」,双双执行破坏性的 recoverInterrupted(),把对方正在跑的任务掐掉。
|
|
109
|
+
*
|
|
110
|
+
* 这里改用文件系统的原子操作 `O_EXCL`:整个「检查是否存在 + 创建」是一步完成的,
|
|
111
|
+
* 不可能两个进程同时成功。拿不到锁时再看锁属主是否还活着:
|
|
112
|
+
* · 还活着 → 让位(本次不执行残留恢复)
|
|
113
|
+
* · 已死 → 是上次崩溃留下的陈旧锁,抢占重写
|
|
114
|
+
*/
|
|
115
|
+
export function acquireInstanceLock() {
|
|
116
|
+
const noop = () => { };
|
|
117
|
+
try {
|
|
118
|
+
mkdirSync(getDataRoot(), { recursive: true });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return { acquired: true, release: noop }; // 数据目录不可写时不阻断启动
|
|
122
|
+
}
|
|
123
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
124
|
+
try {
|
|
125
|
+
// 'wx' = O_CREAT | O_EXCL:文件已存在则抛 EEXIST,检查与创建不可分割
|
|
126
|
+
const fd = openSync(lockPath(), 'wx');
|
|
127
|
+
try {
|
|
128
|
+
writeFileSync(fd, String(process.pid), 'utf-8');
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
closeSync(fd);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
acquired: true,
|
|
135
|
+
release: () => {
|
|
136
|
+
try {
|
|
137
|
+
rmSync(lockPath(), { force: true });
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* ignore */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (err.code !== 'EEXIST') {
|
|
147
|
+
// 非「已存在」的异常(权限等):不因锁机制本身挡住服务启动
|
|
148
|
+
return { acquired: true, release: noop };
|
|
149
|
+
}
|
|
150
|
+
const owner = readLockPid();
|
|
151
|
+
if (owner && owner !== process.pid && isProcessAlive(owner)) {
|
|
152
|
+
return { acquired: false, release: noop };
|
|
153
|
+
}
|
|
154
|
+
// 陈旧锁(属主进程已不存在,或内容损坏):删掉后重试一次
|
|
155
|
+
try {
|
|
156
|
+
rmSync(lockPath(), { force: true });
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
/* ignore */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// 两轮都没抢到 → 说明确实有并发实例在抢,保守让位
|
|
164
|
+
return { acquired: false, release: noop };
|
|
165
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 系统原生通知 — 取代 Electron Notification 模块
|
|
3
|
+
*
|
|
4
|
+
* 采用各平台自带命令,尽力而为:失败不影响任务链路,前端另有 WebSocket 通道兜底。
|
|
5
|
+
* macOS : osascript display notification
|
|
6
|
+
* Linux : notify-send
|
|
7
|
+
* Windows: PowerShell WinRT Toast(需 Windows PowerShell 5.1)
|
|
8
|
+
*/
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
function escapeOsascript(s) {
|
|
11
|
+
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
12
|
+
}
|
|
13
|
+
function escapePs(s) {
|
|
14
|
+
return s.replace(/'/g, "''");
|
|
15
|
+
}
|
|
16
|
+
export function notifyNative(title, body) {
|
|
17
|
+
const t = title.trim() || 'DeskPet';
|
|
18
|
+
const b = body.trim();
|
|
19
|
+
try {
|
|
20
|
+
if (process.platform === 'darwin') {
|
|
21
|
+
execFile('osascript', ['-e', `display notification "${escapeOsascript(b)}" with title "${escapeOsascript(t)}"`], { timeout: 5000, windowsHide: true }, () => {
|
|
22
|
+
/* 尽力而为 */
|
|
23
|
+
});
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (process.platform === 'linux') {
|
|
27
|
+
execFile('notify-send', [t, b], { timeout: 5000 }, () => {
|
|
28
|
+
/* ignore */
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (process.platform === 'win32') {
|
|
33
|
+
const script = [
|
|
34
|
+
'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
|
|
35
|
+
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null",
|
|
36
|
+
"$xml = New-Object Windows.Data.Xml.Dom.XmlDocument",
|
|
37
|
+
"$xml.LoadXml('<toast><visual><binding template=\"ToastText02\"><text id=\"1\">" +
|
|
38
|
+
escapePs(t) +
|
|
39
|
+
'</text><text id="2">' +
|
|
40
|
+
escapePs(b) +
|
|
41
|
+
'</text></binding></visual></toast>\')',
|
|
42
|
+
'$toast = New-Object Windows.UI.Notifications.ToastNotification $xml',
|
|
43
|
+
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('DeskPet').Show($toast)"
|
|
44
|
+
].join('; ');
|
|
45
|
+
execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { timeout: 8000, windowsHide: true }, () => {
|
|
46
|
+
/* ignore */
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* ignore */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 打开外部资源 — 取代 Electron shell 模块
|
|
3
|
+
* openPath → 文件或文件夹(系统默认程序)
|
|
4
|
+
* openUrl → 浏览器
|
|
5
|
+
* openApp → 应用(macOS: open -a / Windows: start / Linux: 直接执行)
|
|
6
|
+
*/
|
|
7
|
+
import { exec } from 'node:child_process';
|
|
8
|
+
function quote(s) {
|
|
9
|
+
return s.replace(/"/g, '\\"');
|
|
10
|
+
}
|
|
11
|
+
function run(cmd) {
|
|
12
|
+
return new Promise((resolvePromise) => {
|
|
13
|
+
exec(cmd, { timeout: 10000, windowsHide: true }, (err) => {
|
|
14
|
+
resolvePromise(err ? err.message : null);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** 用系统默认程序打开文件/文件夹;成功返回 null,失败返回错误信息 */
|
|
19
|
+
export async function openPath(target) {
|
|
20
|
+
if (process.platform === 'darwin')
|
|
21
|
+
return run(`open "${quote(target)}"`);
|
|
22
|
+
if (process.platform === 'win32')
|
|
23
|
+
return run(`start "" "${target.replace(/"/g, '')}"`);
|
|
24
|
+
return run(`xdg-open "${quote(target)}"`);
|
|
25
|
+
}
|
|
26
|
+
/** 用默认浏览器打开 URL */
|
|
27
|
+
export async function openUrl(url) {
|
|
28
|
+
return openPath(url);
|
|
29
|
+
}
|
|
30
|
+
/** 打开应用 */
|
|
31
|
+
export async function openApp(app) {
|
|
32
|
+
if (process.platform === 'darwin')
|
|
33
|
+
return run(`open -a "${quote(app)}"`);
|
|
34
|
+
if (process.platform === 'win32')
|
|
35
|
+
return run(`start "" "${app.replace(/"/g, '')}"`);
|
|
36
|
+
return run(`"${quote(app)}"`);
|
|
37
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路径工具 — 资源/配置目录解析
|
|
3
|
+
*
|
|
4
|
+
* 数据目录优先级: 环境变量 DESKPET_HOME > ~/.deskpet
|
|
5
|
+
* 内置资源目录优先级: 环境变量 DESKPET_RESOURCES > <项目根>/resources
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
import { dirname, isAbsolute, join, resolve } from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
/**
|
|
12
|
+
* 当前模块目录(兼容 CJS 打包产物与 ESM 直跑测试)
|
|
13
|
+
* CJS: __dirname 直接可用;ESM: import.meta.url 转换
|
|
14
|
+
*/
|
|
15
|
+
export function moduleDir() {
|
|
16
|
+
try {
|
|
17
|
+
return __dirname;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const MODULE_DIR = moduleDir();
|
|
24
|
+
/**
|
|
25
|
+
* 包根目录(含 package.json 的那一层)。
|
|
26
|
+
*
|
|
27
|
+
* 为什么不写死相对层级:源码运行时本模块在 `server/utils/`,
|
|
28
|
+
* 而发布产物在 `dist/node/server/utils/` —— 两者距包根的层数不同,
|
|
29
|
+
* 任何 `../../xxx` 形式的假设都会在编译后失效(曾导致 resources 与 dist/web 定位不到)。
|
|
30
|
+
* 这里改为从模块目录向上查找 package.json,源码形态与产物形态都能正确定位。
|
|
31
|
+
*/
|
|
32
|
+
function findPackageRoot() {
|
|
33
|
+
let dir = MODULE_DIR;
|
|
34
|
+
for (let i = 0; i < 8; i += 1) {
|
|
35
|
+
if (existsSync(join(dir, 'package.json')))
|
|
36
|
+
return dir;
|
|
37
|
+
const parent = dirname(dir);
|
|
38
|
+
if (parent === dir)
|
|
39
|
+
break;
|
|
40
|
+
dir = parent;
|
|
41
|
+
}
|
|
42
|
+
// 兜底:沿用源码形态的假设(server/utils → 上两级即包根)
|
|
43
|
+
return resolve(MODULE_DIR, '../..');
|
|
44
|
+
}
|
|
45
|
+
const PACKAGE_ROOT = findPackageRoot();
|
|
46
|
+
/** 包根目录 */
|
|
47
|
+
export function getPackageRoot() {
|
|
48
|
+
return PACKAGE_ROOT;
|
|
49
|
+
}
|
|
50
|
+
/** 数据根目录(配置/数据库/皮肤/快照) */
|
|
51
|
+
export function getDataRoot() {
|
|
52
|
+
const env = process.env.DESKPET_HOME;
|
|
53
|
+
if (env && env.trim())
|
|
54
|
+
return resolve(env.trim());
|
|
55
|
+
return join(homedir(), '.deskpet');
|
|
56
|
+
}
|
|
57
|
+
/** 内置资源根目录(resources/,随项目分发) */
|
|
58
|
+
export function getResourcesRoot() {
|
|
59
|
+
const env = process.env.DESKPET_RESOURCES;
|
|
60
|
+
if (env && env.trim())
|
|
61
|
+
return resolve(env.trim());
|
|
62
|
+
return join(PACKAGE_ROOT, 'resources');
|
|
63
|
+
}
|
|
64
|
+
/** 用户配置目录 */
|
|
65
|
+
export function getConfigDir() {
|
|
66
|
+
return join(getDataRoot(), 'config');
|
|
67
|
+
}
|
|
68
|
+
/** 用户导入皮肤目录 */
|
|
69
|
+
export function getUserSkinsDir() {
|
|
70
|
+
return join(getDataRoot(), 'skins');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 解析皮肤相关路径。
|
|
74
|
+
* 候选顺序: 绝对路径 → 用户皮肤目录 → 内置资源目录
|
|
75
|
+
*/
|
|
76
|
+
export function resolveAssetPath(relOrAbs) {
|
|
77
|
+
const candidates = [];
|
|
78
|
+
if (isAbsolute(relOrAbs)) {
|
|
79
|
+
candidates.push(relOrAbs);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
candidates.push(join(getUserSkinsDir(), relOrAbs));
|
|
83
|
+
candidates.push(join(getResourcesRoot(), relOrAbs));
|
|
84
|
+
}
|
|
85
|
+
for (const c of candidates) {
|
|
86
|
+
if (existsSync(c))
|
|
87
|
+
return c;
|
|
88
|
+
}
|
|
89
|
+
return candidates[0];
|
|
90
|
+
}
|
|
91
|
+
/** 确保目录存在(递归) */
|
|
92
|
+
export function ensureDir(dir) {
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
return dir;
|
|
95
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python 解释器解析
|
|
3
|
+
*
|
|
4
|
+
* 背景(真实故障):
|
|
5
|
+
* Windows 上常常**没有真正的 `python3`**,PATH 里唯一能匹配到的是 Microsoft Store 的
|
|
6
|
+
* 「应用执行别名」存根 `%LOCALAPPDATA%\Microsoft\WindowsApps\python3.exe`(实际指向
|
|
7
|
+
* AppInstallerPythonRedirector.exe)。该存根被非交互式拉起时不会执行任何脚本,
|
|
8
|
+
* **直接以退出码 9009 结束,且 stdout/stderr 全空**,表现为:
|
|
9
|
+
* 脚本异常退出 (exit_code=9009), stderr:
|
|
10
|
+
* 非常难排查。因此这里统一做解释器解析:
|
|
11
|
+
* 1) 若任务指定了解释器 → 按名称(搜索 PATH)或绝对路径解析,解析不到就明确报错;
|
|
12
|
+
* 2) 未指定 → 按平台候选顺序自动探测(Windows 优先 `python`,因为 `python3` 多为 Store 存根);
|
|
13
|
+
* 3) 一律**跳过 Store 别名存根**与无法直接启动的 .cmd/.bat;
|
|
14
|
+
* 4) 返回可执行文件的绝对路径,交给 spawn 使用。
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, statSync } from 'node:fs';
|
|
17
|
+
import { basename, isAbsolute, join } from 'node:path';
|
|
18
|
+
const IS_WIN = process.platform === 'win32';
|
|
19
|
+
/** Microsoft Store 应用执行别名目录 */
|
|
20
|
+
const STORE_ALIAS_DIR_RE = /[\\/]WindowsApps[\\/]/i;
|
|
21
|
+
/** 自动探测顺序(Windows 优先 python,因为 python3 常只是 Store 存根) */
|
|
22
|
+
export const INTERPRETER_CANDIDATES = IS_WIN
|
|
23
|
+
? ['python', 'python3', 'py']
|
|
24
|
+
: ['python3', 'python', 'py'];
|
|
25
|
+
/** 是否是 Microsoft Store 的 python 别名存根(假的解释器) */
|
|
26
|
+
export function isStoreAlias(p) {
|
|
27
|
+
return IS_WIN && STORE_ALIAS_DIR_RE.test(p) && /^python/i.test(basename(p));
|
|
28
|
+
}
|
|
29
|
+
function pathDirs() {
|
|
30
|
+
return (process.env.PATH ?? '')
|
|
31
|
+
.split(IS_WIN ? ';' : ':')
|
|
32
|
+
.map((d) => d.trim().replace(/^"(.*)"$/, '$1'))
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
function isUsableFile(p) {
|
|
36
|
+
try {
|
|
37
|
+
return existsSync(p) && statSync(p).isFile();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** 把一个解释器名/路径解析为可执行的绝对路径 */
|
|
44
|
+
function resolveOne(raw) {
|
|
45
|
+
const name = raw.trim();
|
|
46
|
+
if (!name)
|
|
47
|
+
return {};
|
|
48
|
+
const looksLikePath = isAbsolute(name) || /[\\/]/.test(name);
|
|
49
|
+
const candidates = [];
|
|
50
|
+
if (looksLikePath) {
|
|
51
|
+
candidates.push(name);
|
|
52
|
+
if (IS_WIN && !/\.(exe|cmd|bat)$/i.test(name))
|
|
53
|
+
candidates.push(`${name}.exe`);
|
|
54
|
+
}
|
|
55
|
+
else if (IS_WIN) {
|
|
56
|
+
for (const dir of pathDirs())
|
|
57
|
+
candidates.push(join(dir, `${name}.exe`));
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
for (const dir of pathDirs())
|
|
61
|
+
candidates.push(join(dir, name));
|
|
62
|
+
}
|
|
63
|
+
let storeAlias = false;
|
|
64
|
+
let scriptShim = false;
|
|
65
|
+
for (const c of candidates) {
|
|
66
|
+
if (!isUsableFile(c))
|
|
67
|
+
continue;
|
|
68
|
+
if (isStoreAlias(c)) {
|
|
69
|
+
storeAlias = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (IS_WIN && /\.(cmd|bat)$/i.test(c)) {
|
|
73
|
+
scriptShim = true;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
return { command: c };
|
|
77
|
+
}
|
|
78
|
+
return { storeAlias, scriptShim };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 解析 Python 解释器
|
|
82
|
+
* - 指定了 `preferred` 却解析不到 → 明确报错(不静默回退,避免「以为用了 A 实际用了 B」)
|
|
83
|
+
* - 未指定 → 按平台候选顺序自动探测
|
|
84
|
+
*/
|
|
85
|
+
export function resolvePythonInterpreter(preferred) {
|
|
86
|
+
const pref = (preferred ?? '').trim();
|
|
87
|
+
if (pref) {
|
|
88
|
+
const r = resolveOne(pref);
|
|
89
|
+
if (r.command)
|
|
90
|
+
return { ok: true, interpreter: { command: r.command, name: pref } };
|
|
91
|
+
return { ok: false, error: `解释器「${pref}」不可用:${describeFailure(pref, r)}` };
|
|
92
|
+
}
|
|
93
|
+
for (const name of INTERPRETER_CANDIDATES) {
|
|
94
|
+
const r = resolveOne(name);
|
|
95
|
+
if (r.command)
|
|
96
|
+
return { ok: true, interpreter: { command: r.command, name } };
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
error: `未找到可用的 Python 解释器(已尝试:${INTERPRETER_CANDIDATES.join('、')})。` +
|
|
101
|
+
'请在任务「解释器」里填写完整路径,例如 C:\\Program Files\\Python312\\python.exe'
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function describeFailure(name, r) {
|
|
105
|
+
if (r.storeAlias) {
|
|
106
|
+
return (`它只匹配到 Microsoft Store 的应用执行别名存根(${IS_WIN ? 'WindowsApps\\' : ''}${name}.exe),` +
|
|
107
|
+
'不是真正的 Python,被系统直接拒绝执行(退出码 9009)。请改填真实解释器,例如 python');
|
|
108
|
+
}
|
|
109
|
+
if (r.scriptShim) {
|
|
110
|
+
return `它只找到 .cmd/.bat 脚本(无法被直接启动)。请改填对应的 .exe 路径`;
|
|
111
|
+
}
|
|
112
|
+
return '未在 PATH 中找到,请确认已安装或填写完整路径';
|
|
113
|
+
}
|
|
114
|
+
/** 探测本机可用的解释器列表(供前端下拉选择) */
|
|
115
|
+
export function detectPythonInterpreters() {
|
|
116
|
+
const found = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const name of INTERPRETER_CANDIDATES) {
|
|
119
|
+
const r = resolveOne(name);
|
|
120
|
+
if (!r.command)
|
|
121
|
+
continue;
|
|
122
|
+
const key = r.command.toLowerCase();
|
|
123
|
+
if (seen.has(key))
|
|
124
|
+
continue;
|
|
125
|
+
seen.add(key);
|
|
126
|
+
found.push({ command: r.command, name });
|
|
127
|
+
}
|
|
128
|
+
return found;
|
|
129
|
+
}
|