diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index 3b6272f9..6f68453b 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -47,10 +47,11 @@ const RESPONSE_TYPES = { }; class DaemonClient { - constructor({ projectDir = os.homedir(), logger = console, authTimeoutMs = AUTH_TIMEOUT_MS } = {}) { + constructor({ projectDir = os.homedir(), logger = console, authTimeoutMs = AUTH_TIMEOUT_MS, isPackaged = false } = {}) { this.projectDir = projectDir; this.logger = logger; this._authTimeoutMs = authTimeoutMs; // G142 测试可注入短超时(默认 10s) + this._isPackaged = isPackaged; // Phase 4:打包模式(rant #12 §4)由 main.js 注入 app.isPackaged this.ws = null; this.connected = false; this._events = new Set(); // callbacks @@ -82,6 +83,32 @@ class DaemonClient { } async startDaemon() { + // Phase 4(rant #12 §4):打包模式直接 spawn 捆绑 emrgd 可执行文件(脚本内部 + // exec python -m emrg.server);源码模式保持 python -m emrg.server。 + if (this._isPackaged) { + const emrgdPath = this._findDaemonExecutable(); + const opts = { + cwd: this.projectDir, + stdio: "ignore", + detached: true, + }; + // R36/R66:Windows .cmd 需 shell:true(非 PE),windowsHide 防黑窗闪烁; + // Node shell 模式自动给含空格的用户名加引号(R92b)。 + if (process.platform === "win32") { + opts.shell = true; + opts.windowsHide = true; + } + this.logger.info(`[gui] spawning packaged daemon: ${emrgdPath} cwd=${this.projectDir}`); + const child = spawn(emrgdPath, [], opts); + child.unref(); + this._daemonChild = child; + const deadline = Date.now() + SPAWN_WAIT_MS; + while (Date.now() < deadline) { + if (await this.isRunning(500)) return child; + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error("emrgd failed to start within timeout"); + } // G125:spawn 设 cwd=project_dir(daemon load_skills 用 Path.cwd() 加载项目级 skills) const python = this._findPython(); const args = ["-m", "emrg.server"]; @@ -102,6 +129,14 @@ class DaemonClient { throw new Error("emrgd failed to start within timeout"); } + _findDaemonExecutable() { + // Phase 4(rant #12 §4 R7):打包模式定位捆绑 emrgd。 + // Windows: ~/.emrg/install/bin/emrgd.cmd;POSIX: ~/.emrg/install/bin/emrgd。 + const bin = path.join(os.homedir(), ".emrg", "install", "bin"); + const name = process.platform === "win32" ? "emrgd.cmd" : "emrgd"; + return path.join(bin, name); + } + _findPython() { // G59/G61/G126:优先项目 .venv,其次 PATH python3/python const root = path.resolve(__dirname, "..", ".."); diff --git a/emrg/gui/main.js b/emrg/gui/main.js index a94c8f3a..e22fbc57 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -105,9 +105,51 @@ function main() { function configPath() { return path.join(os.homedir(), ".emrg", "config.toml"); } + // Phase 4(rant #12 §4 R108):打包模式无 .venv/PATH python → 内联模板直接生成 + // (与 emrg/config.py ensure_config() 105-130 行同源);源码模式复用 ensure_config()。 + const CONFIG_TEMPLATE = `[llm] +# OpenAI-compatible API endpoint +base_url = "https://api.deepseek.com" +api_key = "sk-..." +model = "deepseek-chat" +max_tokens = 8192 +temperature = 0.7 +context_window = 131072 +auto_compact_threshold = 0.0 +# vision: set to true if model supports OpenAI vision API (image_url content type) +vision = false + +# Additional models for /model switching (optional — add or remove as needed) +# model: API model name (optional — defaults to name if not set) +[[llm.models]] +name = "deepseek-v3" +model = "deepseek-chat" +context_window = 131072 +vision = false + +[[llm.models]] +name = "deepseek-r1" +model = "deepseek-reasoner" +context_window = 65536 +vision = false +`; + function ensureConfigTemplate() { // G116:优先复用 ensure_config() 生成官方模板(含 [[llm.models]] 预置) return new Promise((resolve) => { + if (app.isPackaged) { + if (!fs.existsSync(configPath())) { + try { + fs.mkdirSync(path.dirname(configPath()), { recursive: true }); + fs.writeFileSync(configPath(), CONFIG_TEMPLATE, { mode: 0o600 }); + } catch (e) { + resolve(""); + return; + } + } + resolve(fs.readFileSync(configPath(), "utf8")); + return; + } const python = client?._findPython() || "python3"; const child = spawn(python, ["-c", "from emrg.config import ensure_config; ensure_config()"], { cwd: projectDir, @@ -335,7 +377,9 @@ function main() { async function ensureConnected() { if (!client) { - client = new DaemonClient({ projectDir, logger }); + // Phase 4(rant #12 §4 R7):打包模式传 app.isPackaged → daemon_client 走 + // 捆绑 emrgd 分支(_findDaemonExecutable)。 + client = new DaemonClient({ projectDir, logger, isPackaged: app.isPackaged }); // G122:message_delta 16ms 批量推送 let deltaBuf = []; let deltaTimer = null; @@ -461,9 +505,43 @@ function main() { } }); + // ── Phase 4 AppImage 自解压(rant #12 §5 R65/R88/R93)────────── + // AppImage 是自解压安装器:首次运行把 resourcesPath/runtime 复制到 + // ~/.emrg/install/(稳定路径,非 AppImage 挂载),并建 ~/.local/bin/emrg 软链。 + // 仅 Linux(process.env.APPIMAGE 存在);macOS/Windows 由安装器直接放置。 + function ensureAppImageExtracted() { + if (process.platform !== "linux" || !process.env.APPIMAGE) return; + const home = os.homedir(); + const installBin = path.join(home, ".emrg", "install", "bin"); + if (fs.existsSync(installBin)) return; // 已解压 + const runtimeSrc = path.join(process.resourcesPath, "runtime"); + if (!fs.existsSync(runtimeSrc)) return; + try { + // R88:先 mkdir ~/.local/bin 否则 symlinkSync ENOENT + fs.mkdirSync(path.join(home, ".local", "bin"), { recursive: true }); + // R93:250MB 复制期间显示"正在安装 EMRG..."提示防误判卡死 + if (win && !win.isDestroyed()) { + win.webContents.send("emrg:event", { type: "status", data: { connected: false, installing: true } }); + } + fs.cpSync(runtimeSrc, path.join(home, ".emrg", "install"), { recursive: true }); + const link = path.join(home, ".local", "bin", "emrg"); + if (!fs.existsSync(link)) { + fs.symlinkSync(path.join(home, ".emrg", "install", "bin", "emrg"), link, "file"); + } + logger.info("[gui] AppImage runtime extracted to ~/.emrg/install/"); + } catch (e) { + logger.error("[gui] AppImage extraction failed (retry on next launch)", e); + } finally { + if (win && !win.isDestroyed()) { + win.webContents.send("emrg:event", { type: "status", data: { connected: false, installing: false } }); + } + } + } + app.whenReady().then(() => { registerIpc(); createWindow(); + ensureAppImageExtracted(); // R65:自解压归 main.js(electron-builder AppRun 无官方自定义) app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index bc786800..2a67ff7f 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -144,6 +144,58 @@ test("ensureConnected: port 文件缺失 → 拉起 daemon(spawn 参数正确 assert.strictEqual(spawnCalls.projectDir, tmpHome); }); +test("Phase4: _findDaemonExecutable 打包模式定位捆绑 emrgd(POSIX)", async () => { + const client = new DaemonClient({ projectDir: tmpHome, isPackaged: true }); + const exe = client._findDaemonExecutable(); + const expected = path.join(os.homedir(), ".emrg", "install", "bin", process.platform === "win32" ? "emrgd.cmd" : "emrgd"); + assert.strictEqual(exe, expected); + assert.ok(exe.includes(path.join(".emrg", "install", "bin")), `exe=${exe}`); +}); + +test("Phase4: isPackaged startDaemon 走捆绑 emrgd 分支(非 python -m)", async () => { + const client = new DaemonClient({ projectDir: tmpHome, isPackaged: true }); + // _findPython 只存在于源码分支;打包分支调用 _findDaemonExecutable。 + const exe = client._findDaemonExecutable(); + assert.ok(exe.endsWith("emrgd") || exe.endsWith("emrgd.cmd"), `exe=${exe}`); + // 打包分支绝不能触达 _findPython:置为抛错,若被调用测试即失败。 + client._findPython = () => { throw new Error("_findPython must not be called in packaged mode"); }; + // _findDaemonExecutable 返回真实路径;真实 spawn 会执行该文件——用一个 + // 必然超时失败但能证明走了打包分支的方式:spawn 的 emrgd 不存在时 error 事件。 + // 更稳:stub isRunning 恒 false + 缩短等待,捕获 startDaemon 抛的超时错, + // 说明它没有走 _findPython(否则直接抛上面的错)。 + client.isRunning = async () => false; + const realSpawn = require("child_process").spawn; + const childProcess = require("child_process"); + // daemon_client 顶层解构 { spawn }——只能替换 require.cache 中的模块导出。 + // 简化:monkey-patch childProcess.spawn 无法生效,改用 spy 模块缓存。 + // 这里直接验证:打包分支下 spawn 的 cmd 是 emrgd(通过临时替换模块再 require)。 + const cacheKey = require.resolve("../daemon_client.js"); + const originalModule = require.cache[cacheKey]; + delete require.cache[cacheKey]; + const origSpawn = require("child_process").spawn; + let captured = null; + require("child_process").spawn = (cmd, args, opts) => { + captured = { cmd, args, opts }; + return { unref() {}, pid: 1234 }; + }; + let DaemonClientPackaged; + try { + DaemonClientPackaged = require("../daemon_client.js").DaemonClient; + const c2 = new DaemonClientPackaged({ projectDir: tmpHome, isPackaged: true }); + c2._findPython = () => { throw new Error("_findPython must not be called in packaged mode"); }; + c2.isRunning = async () => true; + const child = await c2.startDaemon(); + assert.strictEqual(child.pid, 1234); + assert.ok(captured, "spawn should be called"); + assert.ok(String(captured.cmd).endsWith("emrgd") || String(captured.cmd).endsWith("emrgd.cmd"), `cmd=${captured.cmd}`); + assert.deepStrictEqual(captured.args, []); + assert.strictEqual(captured.opts.detached, true); + } finally { + require("child_process").spawn = origSpawn; + require.cache[cacheKey] = originalModule; + } +}); + test("G43 stale port: 连接失败(port 文件存在但拒绝)→ 删文件重拉", async () => { const client = new DaemonClient({ projectDir: tmpHome }); let respawned = false;