From 24cb9da4df1563185d248ed5d9fadb7f2b238009 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 12 Nov 2026 01:18:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(trae):=20=E6=9C=AC=E6=9C=BA=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E6=8E=A2=E6=B5=8B=E8=A1=A5=E9=BD=90=20Linux/macOS=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E4=BF=AE=E5=A4=8D=20Ubuntu=20=E4=B8=8B?= =?UTF-8?q?=E8=83=BD=E8=A7=A3=E5=AF=86=E5=8D=B4=E8=A2=AB=E5=88=A4=E3=80=8C?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=9C=AA=E5=AE=89=E8=A3=85=E3=80=8D?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux 下 traeDataDir() 与 tcDecrypt 本已工作(storage.json 的 iCubeAuthInfo://icube.cloudide 可正常解出凭据),但 detectTrae() 的 clientInstalled/clientPath/running 三个字段硬编码 win32-only,导致 /api/status 的 traeClient 判定为未安装、面板不给本机导入入口。 - clientPathOf(): 新增 darwin(/Applications/.app)与 linux (PATH 常见目录 kebab-case bin + .desktop Name 匹配解析 Exec 兜底) - traeRunning()/traeRunningCached(): unix 走 unixTraePids() (Linux 读 /proc//exe,macOS pgrep -x),不再恒 false - terminateTrae(): unix SIGTERM→轮询→SIGKILL,nap 在非 win32 用 sleep 子进程替代 Atomics.wait - deviceId 口径不变:Linux 客户端 storage.json 只落 aha- 无 纯数字 icube-dc 条目,维持 null 由上层降级提示(实测本机验证) 验证(Ubuntu 26.04 + Trae CN 2.3.92182 deb): detectTrae 修复前 clientInstalled:false/signedIn:true 矛盾态 → 修复后 clientPath=/usr/share/trae-cn/trae-cn、clientInstalled:true; 伪装 /proc exe 名实测 running true→terminate closed true→false 全链 无新依赖,win32 行为零改动。 --- src/traeLocal.js | 108 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 100 insertions(+), 8 deletions(-) diff --git a/src/traeLocal.js b/src/traeLocal.js index 2743e14..788ab7f 100644 --- a/src/traeLocal.js +++ b/src/traeLocal.js @@ -121,10 +121,39 @@ const decodeTasklist = (buf) => { let runningOverride = null; +const UNIX_BINS = [...new Set([...VARIANT_APPS.cn, ...VARIANT_APPS.intl].map((n) => n.toLowerCase().replace(/\s+/g, '-')))]; + +/** unix 下任一变体的运行 pid(Linux 读 /proc//exe;macOS 用 pgrep -x) */ +function unixTraePids() { + if (process.platform === 'linux') { + const out = []; + let entries; + try { entries = fs.readdirSync('/proc'); } catch { return out; } + for (const d of entries) { + if (!/^\d+$/.test(d)) continue; + try { + if (UNIX_BINS.includes(path.basename(fs.readlinkSync(`/proc/${d}/exe`)).toLowerCase())) out.push(Number(d)); + } catch { /* 权限不足或进程已退出,下一个 */ } + } + return out; + } + if (process.platform === 'darwin') { + const out = []; + for (const bin of UNIX_BINS) { + try { + out.push(...execFileSync('pgrep', ['-x', bin], { encoding: 'utf8', timeout: 5000 }) + .trim().split(/\s+/).map(Number).filter(Boolean)); + } catch { /* 该变体没在跑 */ } + } + return out; + } + return []; +} + /** 任一变体的 Trae 客户端是否在运行 */ export function traeRunning() { if (runningOverride !== null) return runningOverride; - if (process.platform !== 'win32') return false; + if (process.platform !== 'win32') return unixTraePids().length > 0; for (const exe of EXE_NAMES) { try { const out = decodeTasklist(execFileSync('tasklist.exe', ['/FI', `IMAGENAME eq ${exe}`, '/NH'], @@ -135,18 +164,65 @@ export function traeRunning() { return false; } -/** 轮询口径(/api/status 用):读 winProc 全量快照,不 spawn;switchTo/terminate 仍走 traeRunning()。 */ +/** 轮询口径(/api/status 用):win32 读 winProc 全量快照,不 spawn;unix 读 /proc 同口径足够轻;switchTo/terminate 仍走 traeRunning()。 */ export function traeRunningCached() { if (runningOverride !== null) return runningOverride; // 测试钩子 + if (process.platform !== 'win32') return traeRunning(); return procRunningCached(EXE_NAMES); } +/** Linux 桌面入口兜底:按 .desktop 的 Name 匹配变体名,解析 Exec 首个实参并校验存在 */ +function linuxDesktopExecPath(name) { + const dataDirs = [...new Set([ + ...(process.env.XDG_DATA_DIRS || '/usr/share:/usr/local/share').split(':').filter(Boolean).map((d) => path.join(d, 'applications')), + path.join(os.homedir(), '.local', 'share', 'applications'), + ])]; + for (const dir of dataDirs) { + let entries; + try { entries = fs.readdirSync(dir); } catch { continue; } + for (const f of entries) { + if (!f.endsWith('.desktop')) continue; + let text; + try { text = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; } + const names = [...text.matchAll(/^Name(?:\[[^\]]*\])?=(.*)$/gm)].map((m) => m[1].trim()); + if (!names.includes(name)) continue; + const m = /^Exec=(.*)$/m.exec(text); + if (!m) continue; + const argv0 = m[1].split(/[\s;]+/).find((t) => t && !/^%[a-zA-Z%]$/.test(t) && t !== "''"); + if (!argv0) continue; + if (argv0.startsWith('/') && fs.existsSync(argv0)) return argv0; + } + } + return null; +} + function clientPathOf(names) { - if (process.platform !== 'win32') return null; - const local = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + if (process.platform === 'win32') { + const local = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + for (const name of names) { + const p = path.join(local, 'Programs', name, name + '.exe'); + if (fs.existsSync(p)) return p; + } + return null; + } + if (process.platform === 'darwin') { + for (const name of names) { + for (const base of ['/Applications', path.join(os.homedir(), 'Applications')]) { + const p = path.join(base, `${name}.app`); + if (fs.existsSync(p)) return p; + } + } + return null; + } + // linux:官方包(deb/rpm)把入口软链到 PATH,先查 PATH 常见目录;查不到再按 .desktop 解析 Exec 兜底 for (const name of names) { - const p = path.join(local, 'Programs', name, name + '.exe'); - if (fs.existsSync(p)) return p; + const bin = name.toLowerCase().replace(/\s+/g, '-'); + for (const dir of ['/usr/bin', '/usr/local/bin', path.join(os.homedir(), '.local', 'bin')]) { + const p = path.join(dir, bin); + try { if (fs.statSync(p).isFile()) return fs.realpathSync(p); } catch { /* 下一个 */ } + } + const d = linuxDesktopExecPath(name); + if (d) return d; } return null; } @@ -227,7 +303,24 @@ export function uidFromJwt(token) { } export function terminateTrae({ timeoutMs = 8000 } = {}) { - if (process.platform !== 'win32') return { closed: false, supported: false }; + const nap = (ms) => { + if (process.platform === 'win32') { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); return; } + try { execFileSync('sleep', [String(ms / 1000)], { stdio: 'ignore', timeout: ms + 2000 }); } catch { /* 尽力等待 */ } + }; + if (process.platform !== 'win32') { + const pids = unixTraePids(); + if (!pids.length) return { closed: false, running: false }; + for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch { /* 已退出 */ } } + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline && unixTraePids().length) nap(250); + const left = unixTraePids(); + if (left.length) { + for (const pid of left) { try { process.kill(pid, 'SIGKILL'); } catch { /* 已退出 */ } } + const hard = Date.now() + 3000; + while (Date.now() < hard && unixTraePids().length) nap(250); + } + return { closed: !unixTraePids().length, running: unixTraePids().length > 0 }; + } if (!traeRunning()) return { closed: false, running: false }; const kill = (force) => { for (const exe of EXE_NAMES) { @@ -237,7 +330,6 @@ export function terminateTrae({ timeoutMs = 8000 } = {}) { } catch { /* 该变体没在跑 */ } } }; - const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); const deadline = Date.now() + timeoutMs; kill(false); while (Date.now() < deadline && traeRunning()) nap(250);