From bae227c8be6dc692deef451ad5543ca45df5e58b Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 27 Aug 2026 23:03:20 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20gui=20=E2=80=94=20restore=20upgrade=20b?= =?UTF-8?q?anner=20in=20React=20shell=20(handle=20upgrade=20events=20in=20?= =?UTF-8?q?daemonBridge=20+=20GUI-only=20relaunch=20IPC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- emrg/gui/main.js | 10 ++++ emrg/gui/preload.js | 1 + .../src/components/DialogHost.test.tsx | 1 + .../renderer/src/components/Shell.test.tsx | 29 ++++++++++ emrg/gui/renderer/src/components/Shell.tsx | 58 +++++++++++++++++++ .../gui/renderer/src/lib/daemonBridge.test.ts | 16 +++++ emrg/gui/renderer/src/lib/daemonBridge.ts | 24 +++++++- emrg/gui/renderer/src/shell.css | 2 + emrg/gui/test/preload-api.test.js | 11 ++-- 9 files changed, 146 insertions(+), 6 deletions(-) diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 362db329..7c6f51d6 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -382,6 +382,16 @@ vision = false return { ok: true }; }); + // 升级横幅重启(rant 2026-08-27T21:54:51):仅重启 GUI 进程本身,绝不触碰 + // daemon —— 服务端是 EMRG 生命本体(MANIFESTO 最高原则),不得走上面的 + // restartDaemon stop 链。新 GUI 进程启动后复用已在运行的 daemon;若 daemon + // 本身也需用新代码重启,由 heartbeat 的 installed≠current 驱动用户再次点击。 + ipcMain.handle("emrg:relaunchGui", async () => { + app.relaunch(); + app.exit(0); + return { ok: true }; + }); + ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => { logger.info(`[gui:switch] REQUEST sid=${sessionId} projectPath=${projectPath}`); if (!validateSessionId(sessionId)) { diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index faecc91e..454699e0 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -11,6 +11,7 @@ const api = { sendMessage: (payload) => ipcRenderer.invoke("emrg:sendMessage", payload), listSessions: () => ipcRenderer.invoke("emrg:listSessions"), restartDaemon: () => ipcRenderer.invoke("emrg:restartDaemon"), + relaunchGui: () => ipcRenderer.invoke("emrg:relaunchGui"), switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload), newSession: (payload) => ipcRenderer.invoke("emrg:newSession", payload), deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload), diff --git a/emrg/gui/renderer/src/components/DialogHost.test.tsx b/emrg/gui/renderer/src/components/DialogHost.test.tsx index 809ee791..97f3c19d 100644 --- a/emrg/gui/renderer/src/components/DialogHost.test.tsx +++ b/emrg/gui/renderer/src/components/DialogHost.test.tsx @@ -30,6 +30,7 @@ function appState(over: Partial = {}): DaemonAppState { busyBySid: {}, ownStreamRidBySid: {}, disconnectedBySid: {}, + upgradeBanner: null, ...over, }; } diff --git a/emrg/gui/renderer/src/components/Shell.test.tsx b/emrg/gui/renderer/src/components/Shell.test.tsx index 12829a17..12c0e7ed 100644 --- a/emrg/gui/renderer/src/components/Shell.test.tsx +++ b/emrg/gui/renderer/src/components/Shell.test.tsx @@ -36,6 +36,7 @@ function mockEmrg() { const taskDelete = vi.fn().mockResolvedValue({ ok: true }); const taskTemplateList = vi.fn().mockResolvedValue([{ name: "journal" }]); const sendRant = vi.fn().mockResolvedValue({ ok: true, count: 11 }); + const relaunchGui = vi.fn().mockResolvedValue({ ok: true }); (window as unknown as { emrg?: unknown }).emrg = { onEvent, sendMessage, @@ -52,6 +53,7 @@ function mockEmrg() { taskDelete, taskTemplateList, sendRant, + relaunchGui, }; return { listeners, @@ -66,6 +68,7 @@ function mockEmrg() { taskDelete, taskTemplateList, sendRant, + relaunchGui, emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)), }; } @@ -421,4 +424,30 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => { await waitFor(() => expect(m.listRants).toHaveBeenCalledTimes(2)); expect(screen.queryByTestId("rant-dialog")).not.toBeInTheDocument(); }); + + it("upgrade 事件 → 渲染升级横幅;重启按钮调 relaunchGui(仅 GUI,不碰 daemon);dismiss 后同版本不重现", async () => { + const m = mockEmrg(); + render(wrapper()); + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + // 初始无横幅 + expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument(); + // 心跳 upgrade 帧(installed 0.2.84 ≠ current 0.2.83) + act(() => { + m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } }); + }); + const banner = await screen.findByTestId("upgrade-banner"); + expect(banner).toBeInTheDocument(); + // from→to 文案(en 词典) + expect(screen.getByText(/upgraded from 0\.2\.83 to 0\.2\.84/i)).toBeInTheDocument(); + // 重启按钮 → relaunchGui(仅 GUI 进程重启,绝不走 restartDaemon stop 链) + fireEvent.click(screen.getByTestId("upgrade-banner-restart")); + await waitFor(() => expect(m.relaunchGui).toHaveBeenCalledTimes(1)); + // dismiss → 隐藏;同版本心跳重发不重现(vanilla lastKnownVersion 语义) + fireEvent.click(screen.getByTestId("upgrade-banner-dismiss")); + expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument(); + act(() => { + m.emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } }); + }); + expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument(); + }); }); diff --git a/emrg/gui/renderer/src/components/Shell.tsx b/emrg/gui/renderer/src/components/Shell.tsx index 37d4eeea..e2b7076b 100644 --- a/emrg/gui/renderer/src/components/Shell.tsx +++ b/emrg/gui/renderer/src/components/Shell.tsx @@ -61,6 +61,8 @@ interface WorkspaceBridge { taskDelete(p: { name: string }): Promise; taskTemplateList(): Promise<{ name: string }[]>; sendRant(p: { message: string; project?: string }): Promise<{ ok?: boolean; count?: number }>; + /** 升级横幅重启:仅重启 GUI 进程本身(emrg:relaunchGui),绝不触碰 daemon */ + relaunchGui?(): Promise; } function wsBridge(): WorkspaceBridge | undefined { @@ -85,6 +87,11 @@ export function Shell() { const [taskForm, setTaskForm] = useState<{ task: TaskRec | null } | null>(null); const [rantOpen, setRantOpen] = useState(false); const [taskTypes, setTaskTypes] = useState([]); + // 升级横幅(rant 2026-08-27T21:54:51:React 迁移丢 upgrade 事件):dismissed 记录 + // 已提示过的 installed 版本(vanilla state.lastKnownVersion 语义——心跳每 15s 重发 + // 同一版本,不重复弹);restarting 防重复点击(relaunch 后进程即退出)。 + const [dismissedUpgrade, setDismissedUpgrade] = useState(null); + const [restarting, setRestarting] = useState(false); // open_sessions 广播到达且尚无激活会话 → 自动选第一个(vanilla 同语义) useEffect(() => { @@ -358,8 +365,59 @@ export function Shell() { } } + /** 升级横幅重启:调 emrg:relaunchGui(仅重启 GUI 进程,不碰 daemon —— + * 服务端是 EMRG 生命本体,不得走 restartDaemon 的 stop 链)。失败 → 系统消息。 */ + async function handleUpgradeRestart() { + const b = wsBridge(); + if (!b?.relaunchGui) return; + setRestarting(true); + try { + await b.relaunchGui(); + // 成功:进程即将退出重启,无需清理(restarting 状态随进程消亡) + } catch (e) { + setRestarting(false); + transcript.addSystemMessage( + t("app.upgradeRestartFailed", { msg: e instanceof Error ? e.message : String(e) }), + activeSid, + ); + } + } + + const upgradeBanner = appState.upgradeBanner; + const showUpgradeBanner = + upgradeBanner && upgradeBanner.installed !== "" && upgradeBanner.installed !== dismissedUpgrade; + return (
+ {showUpgradeBanner ? ( +
+ + {upgradeBanner.current && upgradeBanner.current !== upgradeBanner.installed + ? t("app.upgradeBannerMsgFromTo", { from: upgradeBanner.current, to: upgradeBanner.installed }) + : t("app.upgradeBannerMsg", { version: upgradeBanner.installed })} + + + +
+ ) : null}
✦ EMRG diff --git a/emrg/gui/renderer/src/lib/daemonBridge.test.ts b/emrg/gui/renderer/src/lib/daemonBridge.test.ts index aaf6a436..7ba6e5b5 100644 --- a/emrg/gui/renderer/src/lib/daemonBridge.test.ts +++ b/emrg/gui/renderer/src/lib/daemonBridge.test.ts @@ -175,4 +175,20 @@ describe("createDaemonBridge", () => { expect(st.connected).toBe(false); expect(st.sessions).toHaveLength(0); }); + + it("upgrade → store.upgradeBanner;同 installed 心跳重发不重建(dedupe)", () => { + const { emit, bridge } = setup(); + emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } }); + expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.83", installed: "0.2.84" }); + // 心跳每 15s 重发同一版本 → 引用不变(Object.is no-op,不触发 re-render) + const before = bridge.store.get(); + emit({ type: "upgrade", data: { current_version: "0.2.83", installed_version: "0.2.84" } }); + expect(bridge.store.get()).toBe(before); + // 新 installed 版本 → 更新 + emit({ type: "upgrade", data: { current_version: "0.2.84", installed_version: "0.2.85" } }); + expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" }); + // 空 installed → 忽略(dev 运行无版本数据) + emit({ type: "upgrade", data: { current_version: "0.2.85", installed_version: "" } }); + expect(bridge.store.get().upgradeBanner).toEqual({ current: "0.2.84", installed: "0.2.85" }); + }); }); diff --git a/emrg/gui/renderer/src/lib/daemonBridge.ts b/emrg/gui/renderer/src/lib/daemonBridge.ts index 141aafb4..206771be 100644 --- a/emrg/gui/renderer/src/lib/daemonBridge.ts +++ b/emrg/gui/renderer/src/lib/daemonBridge.ts @@ -58,10 +58,16 @@ export interface ErrorData { message?: string; } +/** upgrade 事件载荷(main.js 心跳检测 installed_version ≠ current_version → 专用事件) */ +export interface UpgradeData { + current_version?: string; + installed_version?: string; +} + /** 统一事件帧(onEvent 回调入参) */ export interface DaemonEventFrame { type: string; - data: Record & { chunks?: DeltaChunk[] } & Partial; + data: Record & { chunks?: DeltaChunk[] } & Partial; sid?: string | null; } @@ -86,6 +92,8 @@ export interface DaemonAppState { ownStreamRidBySid: Record; /** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */ disconnectedBySid: Record; + /** upgrade 事件(心跳检测 installed ≠ current → "重启生效"横幅;null=无待重启提示) */ + upgradeBanner: { current: string; installed: string } | null; } const SID_NULL = "__emrg_null_sid__"; @@ -106,6 +114,7 @@ export function createDaemonAppStore(): SnapshotStore { busyBySid: {}, ownStreamRidBySid: {}, disconnectedBySid: {}, + upgradeBanner: null, }); } @@ -316,6 +325,19 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge { } break; } + case "upgrade": { + // 心跳每 15s 检测到 installed ≠ current 都会重发;同一 installed 版本只 + // 写一次 store(vanilla lastKnownVersion 语义:不重复弹,dismiss 后不再出现)。 + const up = data as UpgradeData; + const installed = up.installed_version || ""; + if (installed && installed !== store.get().upgradeBanner?.installed) { + store.update((s) => ({ + ...s, + upgradeBanner: { current: up.current_version || s.currentVersion, installed }, + })); + } + break; + } default: // 未知事件类型:忽略(vanilla 同语义,未来类型静默兼容) break; diff --git a/emrg/gui/renderer/src/shell.css b/emrg/gui/renderer/src/shell.css index 96241df2..680eba00 100644 --- a/emrg/gui/renderer/src/shell.css +++ b/emrg/gui/renderer/src/shell.css @@ -10,6 +10,8 @@ font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--bg); color: var(--text-1); + /* 升级横幅 #upgrade-banner 用 absolute 定位(vanilla #app 为 positioned ancestor) */ + position: relative; } .react-shell-header { diff --git a/emrg/gui/test/preload-api.test.js b/emrg/gui/test/preload-api.test.js index aaf247cf..251cccab 100644 --- a/emrg/gui/test/preload-api.test.js +++ b/emrg/gui/test/preload-api.test.js @@ -3,12 +3,12 @@ * preload-api.test.js — window.emrg preload 桥 API 面守卫测试(React 迁移红线 #1)。 * * 背景(cycle cyc20260826-131007 发现):React 迁移设计文档 §2.2 声称 preload - * API 46 个方法,实际 preload.js 已增长到 52 个 invoke 方法 + onEvent 订阅 + * API 46 个方法,实际 preload.js 已增长到 53 个 invoke 方法 + onEvent 订阅 * (设计文档口径过时)。此前 build-config.test.js 只抽查 listFiles/readFile 两个 * API——没有完整 API 面的回归守卫。React 化迁移(Batch 0-5)的红线就是 * "window.emrg 方法集合原样保留",本测试把整个契约钉死: * - * 1. 全部 52 个 invoke 方法 + onEvent 必须存在(防误删/改名) + * 1. 全部 53 个 invoke 方法 + onEvent 必须存在(防误删/改名) * 2. 每个方法必须调用 ipcRenderer.invoke("emrg:<同名频道>")(防频道漂移) * 3. onEvent 必须订阅 emrg:event 广播(防事件通道漂移) * @@ -23,12 +23,13 @@ const path = require("node:path"); const PRELOAD = fs.readFileSync(path.join(__dirname, "..", "preload.js"), "utf8"); -/** 冻结的期望 API 面:52 invoke 方法(按 preload.js 实际顺序)+ onEvent */ +/** 冻结的期望 API 面:53 invoke 方法(按 preload.js 实际顺序)+ onEvent */ const EXPECTED_INVOKE_METHODS = [ "init", "sendMessage", "listSessions", "restartDaemon", + "relaunchGui", "switchSession", "newSession", "deleteSession", @@ -115,8 +116,8 @@ function parseApiBlock(block) { const api = parseApiBlock(extractApiBlock()); -test("window.emrg exposes the full 53-member contract (52 invoke + onEvent)", () => { - // 52 invoke 方法全部存在(防误删/改名) +test("window.emrg exposes the full 54-member contract (53 invoke + onEvent)", () => { + // 53 invoke 方法全部存在(防误删/改名) for (const name of EXPECTED_INVOKE_METHODS) { assert.ok(api.invoke.has(name), `缺少 invoke 方法 ${name}`); }