记录踩坑解决, ⭐收藏起来, 遇到问题把他丢给AI

上篇我说想用 Bun + Electrobun 做一个 AI IDE(BunCode)。那篇偏选型与架构,没怎么写界面细节。
这篇讲一个看似简单、实际把我卡了好几轮的问题:
我想做一个自定义标题栏 ------自己画的关闭 / 最小化 / 最大化按钮,点击时要有 Windows 原生的 DWM 动画,并且点击任务栏图标能在「最小化 ↔ 还原」之间来回切换。
听起来就是三个按钮 + 几个事件嘛。结果踩了 4 个坑,最后一个坑(任务栏点击切换)连 ElectroBun 的官方 issue 都挂在那(#395),上游没修。
下面按踩坑顺序讲,最后给一份能跑的完整方案。
坑 1 hidden 会丢掉 DWM 系统动画
ElectroBun 创建窗口时有个 titleBarStyle 参数。我一开始选了 hidden(无边框),因为我想完全自己画标题栏。

结果一测试,问题来了:点击最小化 / 关闭时,窗口是"啪"地瞬间消失,没有 Windows 那种缩小进任务栏 / 淡出的动画。
查了一圈才明白根因:Windows 的标题栏动画(DWM 的 WM_NC* 相关的过渡)依赖窗口带 WS_CAPTION 样式位。titleBarStyle: "hidden" 在 Windows 上会把这个样式位去掉,于是 DWM 认为"这不是个有标题栏的窗口",动画直接不应用。
结论先记下来:
titleBarStyle: "hiddenInset"必须保留 ------ 它在 Windows 上保留WS_CAPTION,DWM 的最小化 / 关闭 / 最大化动画才正常。退回hidden会丢动画。
坑 2 hiddenInset 会露出原生标题栏盖住自定义标题栏
既然 hidden 丢动画,那就改回 hiddenInset 呗?

一试,动画回来了,但新的问题:Windows 上 hiddenInset会显示一条原生标题栏,正好盖在我自己画的自定义 TitleBar 上面。两层标题栏叠在一起,丑到没法看。
这里的矛盾是:
-
• 想要系统动画 → 必须保留
WS_CAPTION→ 但hiddenInset又把它画出来了,盖住自定义栏 -
• 想要纯自定义 → 去掉标题栏 → 又丢动画
(第一篇里我把这个记为"待解决",当时以为这是死局,只能放弃系统动画用 CSS 模拟。后来才找到下面这套解法。)
坑 3 拖拽千万别用 JS 模拟 setFrame
自定义标题栏做出来了,下一步是拖拽移动窗口。
我第一反应是:监听鼠标事件,mousemove 里调 mainWindow.setFrame(x, y, w, h) 来改位置。结果一拖,窗口直接变成 0×0,表现得像被最小化了。
根因:setFrame 在拖拽这种高频调用下,某些边界会把窗口尺寸算成 0。ElectroBun 提供了原生拖拽类 (electrobun-webkit-app-region-drag),应该直接用这个,而不是自己算坐标。
前端只要给标题栏根元素加一个类,里面的按钮区加 no-drag 就行:
bash
<Flex className="electrobun-webkit-app-region-drag"
onDoubleClick={() => rpc.request.windowMaximize()}>
<div style={{ flex: 1 }} />
<Flex className="electrobun-webkit-app-region-no-drag">
<Button type="text" icon={<Minus/>} onClick={() => rpc.request.windowMinimize()} />
<Button type="text" icon={<Square/>} onClick={() => rpc.request.windowMaximize()} />
<Button type="text" icon={<X/>} onClick={() => rpc.request.windowClose()} />
</Flex>
</Flex>
styles/index.css 里补一个兼容 shim:
bash
.electrobun-webkit-app-region-no-drag { -webkit-app-region: no-drag; }
双击标题栏 windowMaximize() 是兜底------因为 ElectroBun 没启用 NonClientRegion,原生"双击标题栏最大化"缺失,得靠 RPC 自己补。
坑 4 核心坑 任务栏点击切换点自己图标怎么都不缩小
前面三个坑都好绕。第 4 个坑,才是真正让我怀疑人生的。

4.1 需求
标准 Windows 行为:
-
• 窗口在前台 → 点任务栏自己的图标 → 最小化
-
• 窗口已最小化 → 点任务栏自己的图标 → 还原
这两个状态来回切换。我之前已经有"点击别的程序图标会触发我们最小化"的能力(带 DWM 动画,正常)。但点自己图标就是不动。
4.2 排查 缩小的信号从哪来
我一开始在后端挂了三套检测:
测下来发现诡异现象:
-
• 点别的程序图标 → 我们失去前台 →
blur/ 轮询检测到 →ShowWindow(SW_MINIMIZE)→ 最小化(带动画)✅ -
• 点自己任务栏图标 → 窗口仍是前台 ,
blur不触发,hook 也等不到SC_MINIMIZE→ 毫无反应 ❌
4.3 真凶 ElectroBun 不转发 SC_MINIMIZE
查到 ElectroBun 的 issue #395:在使用 hiddenInset 这种自定义标题栏时,框架不把 WM_SYSCOMMAND / SC_MINIMIZE 转发给 WndProc。
所以:
-
• hook 永远等不到
SC_MINIMIZE→ 不触发ShowWindow -
• 点自己图标时任务栏自己不抢焦点 → 窗口仍是前台 →
blur也不触发
两条路全断。"恢复能用"是因为 SC_RESTORE 真实可达 + 原生 OS 对最小化窗口的任务栏点击本来就会还原,所以那条方向幸存。结果是:恢复半边工作,缩小半边静默失效。
我当时还专门验证了一下:窗口前台时,点别的程序图标会带动画最小化------这证明 blur / 轮询这套检测本身是好的,唯一缺口就是"点自己图标"这一个场景。
4.4 解法 用 FFI 给窗口补样式位让系统原生处理
既然应用层抓不到信号,那就别让应用层处理------让 Windows 自己处理。
WS_MINIMIZEBOX 和 WS_MAXIMIZEBOX 这两个样式位,决定了"点任务栏图标时系统是否原生切换最小化 / 最大化"。ElectroBun 的窗口默认没给齐。我们用 FFI 直接补上:
bash
const user32 = dlopen("user32.dll", {
GetWindowLongPtrW: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i64 },
SetWindowLongPtrW: { args: [FFIType.ptr, FFIType.i32, FFIType.i64], returns: FFIType.i64 },
SetWindowPos: { args: [FFIType.ptr, FFIType.ptr, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.u32], returns: FFIType.bool },
FindWindowW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.ptr },
});
const GWL_STYLE = -16;
const WS_MINIMIZEBOX = 0x00020000;
const WS_MAXIMIZEBOX = 0x00010000;
const SWP_FRAMECHANGED = 0x0020, SWP_NOMOVE = 0x0002, SWP_NOSIZE = 0x0001, SWP_NOZORDER = 0x0004, SWP_NOACTIVATE = 0x0010;
function applyTaskbarStyle(hwnd: Pointer) {
const style = Number(user32.symbols.GetWindowLongPtrW(hwnd, GWL_STYLE));
const want = style | WS_MINIMIZEBOX | WS_MAXIMIZEBOX;
if (want === style) return;
user32.symbols.SetWindowLongPtrW(hwnd, GWL_STYLE, want);
user32.symbols.SetWindowPos(
hwnd, null, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED | SWP_NOACTIVATE,
);
logger.info(`taskbar style applied: 0x${want.toString(16)}`);
}
HWND 用 FindWindowW("Bun IDE") 抓------窗口标题要和 BrowserWindow 构造时一致。补完样式位后,点自己任务栏图标就变成系统原生切换了,带 DWM 动画 ,完全不再依赖 hook 去猜 SC_MINIMIZE。
这里有个细节:补样式位要在 HWND 可用之后做。ElectroBun 启动时 HWND 未必立即可抓,所以我加了"立即试一次 + 轮询里再试一次"的兜底,抓到就
applyTaskbarStyle一次。
4.5 三路检测保留处理失去前台场景
虽然"点自己图标"交给系统了,但"点别的程序 / Alt+Tab 导致我们失去前台"这种情况,还是得靠之前的检测。所以最终方案是补样式位 + 三路兜底并存:
| 信号 | 作用 | | :-- | :-- | | FFI 补 WS_MINIMIZEBOX / WS_MAXIMIZEBOX | 点自己图标 → 系统原生切换(带动画) | | WH_CALLWNDPROCRET hook | 部分环境 SC_MINIMIZE/SC_RESTORE 转发时生效 | | 原生 blur / focus | 失焦→最小化 / 聚焦且最小化→还原 | | GetForegroundWindow 轮询 | 兜底 blur 不触发、前台确实变化的场景 |
入口只一行:
bash
installTaskbarToggle({ title: "Bun IDE" });
三个信号统一用 FFI ShowWindow(hwnd, SW_MINIMIZE | SW_RESTORE) 切换,再用 blockUntil 350ms 防竞态。
4.6 一个已知的副作用
因为任务栏点击与应用失焦在系统层面无法区分,所以切到别的窗口 / Alt+Tab 也会触发我们最小化。这个我接受------它带来的收益(点自己图标能原生切换)远大于这点代价。
最终方案 三个按钮 系统动画 任务栏切换

整合下来,后端的窗口 RPC 非常简单:
bash
windowClose: () => { globalThis.mainWindow?.close(); },
windowMinimize: () => { globalThis.mainWindow?.minimize(); },
windowMaximize: () => {
const win = globalThis.mainWindow;
if (!win) return false;
const maximized = win.isMaximized?.() ?? false;
maximized ? win.unmaximize?.() : win.maximize?.();
return !maximized;
},
创建窗口时保留 hiddenInset(保住动画),标题和 FindWindowW 用的字符串一致:
bash
const mainWindow = new BrowserWindow({
title: "Bun IDE",
url: hmrUrl ?? "views://mainview/index.html",
titleBarStyle: "hiddenInset",
frame,
rpc,
});
globalThis.mainWindow = mainWindow;
installTaskbarToggle({ title: "Bun IDE" });
验证清单 真机
后端 BrowserWindow 在启动时创建,HMR 改不了------必须关掉旧窗口重跑 bun dev:
看后端日志有没有 taskbar style applied: 0x...,确认样式位补上了。
重要:绝对不要改
node_modules里 ElectroBun 的原生代码(issue #395 上游没修),所有兜底都在应用层 FFI 实现。
写在最后
这个坑之所以"难",不是因为代码量大,而是因为失败信号藏在操作系统和框架的缝隙里:
-
• 系统动画依赖
WS_CAPTION,但自定义标题栏又想去掉它 -
• 点自己图标的信号被框架吞了,应用层永远收不到
-
• 最后的解法不在 ElectroBun 的 API 里,而在 Windows 的窗口样式位上------用 FFI 直接补
WS_MINIMIZEBOX / WS_MAXIMIZEBOX,把控制权交还给系统
一句话总结这次的收获:当框架截断了你需要的系统消息,就别在应用层猜了,直接用 FFI 把窗口样式补回系统期望的样子,让 Windows 自己干活。
项目名:BunCode(暂未开源)
技术栈:Bun · Electrobun · React 19 · TypeScript · Ant Design · Monaco Editor · Zustand
固定每周更新开发进度,关注不错过下一篇。