FrpGUI 开发踩坑与解决方案记录

August 18, 2026 · View on GitHub

适用版本:frp 客户端 GUI(Go + Shirei v0.6.6 IMGUI),Windows / macOS / Linux 跨平台。 本文汇总从项目搭建到系统托盘落地过程中踩过的坑、根因分析与最终修复,供后续维护与二次开发参考。


目录

  1. Shirei 窗口隐藏/恢复后白屏(内容哈希跳过渲染)
  2. 系统托盘:独立宿主窗口 CreateWindowExW 失败(错误 1400)
  3. GWLP_WNDPROC 常量溢出
  4. 子类化 WndProc 必须在主线程执行
  5. 拦截 WM_CLOSE 必须放行真正的退出
  6. 字体选择弹窗列表溢出、无滚动条
  7. settings.json 持久化路径与窗口居中
  8. 窗口图标:.ico 无法被标准 image 解码
  9. 跨平台文件拆分的编译陷阱
  10. 文件对话框依赖 sqweek/dialog 的 cgo 问题
  11. 进程管理:切勿误杀用户自己的 frpc
  12. 构建参数与 go.mod / go.sum
  13. 字体/语言热重载设计
  14. 停止 frpc 误报「连接失败」(Windows Kill 退出码)
  15. 瞬时提示不要用定时器 / goroutine
  16. 构建产物直接运行缺语言包 / 主题
  17. go:embed 语言包漂移(嵌入与源不同步)
  18. 托盘/渲染循环外触发弹窗不立即显示(隐藏窗口 + 缺帧调度)
  19. 开机自启用「当前用户」机制,不弹 UAC/sudo
  20. go build 在仓库根留下 frpguiapp.exe 残留
  21. 字体不内嵌:go:embed 对 TTF 几乎不压缩(字体嵌入体积实测)
  22. Go 测试源码必须与被测包同目录;不要建独立 test/ 文件夹
  23. 自绘文件选择器:Windows 盘根 filepath.Dir 返回自身,无法跨盘
  24. 导入/导出共用 io 结果分支:导出误改「已加载」配置名

1. Shirei 窗口隐藏/恢复后白屏(内容哈希跳过渲染)

现象 将窗口最小化到托盘(实际是 ShowWindow(SW_HIDE)),再从托盘恢复(SW_RESTORE)。恢复后界面长时间渲染不出来,要等很久、或者必须「晃到某个按钮上」才会瞬间显示出来。

根因(关键) 阅读 Shirei 源码(v0.6.6,win32backend_windows.go)后发现:produceFrame() 有一个内容哈希跳过(skip)优化——当满足 havePresented && 内容哈希未变 && 尺寸未变 时,直接 skipped=true 跳过整帧绘制,不调用 renderAndPresent

恢复窗口时 UI 内容其实没变,所以哈希仍命中 → 被跳过 → 白屏。这与现象完美吻合:

  • 普通鼠标移动只把 dirty=true,但 produceFrame 因哈希命中仍被跳过 → 不画;
  • 只有悬停按钮改变了 UI 哈希(按钮高亮态变化)→ skipped=false → 真正重绘 → 看着像「晃到按钮才秒显」。

两次误判(都错):① 以为是 hwnd 定时器停摆;② 以为是消息循环被 GetMessage 阻塞、InvalidateRect 绕不过去。其实 InvalidateRect 确实触发了 WM_PAINT → onPaint → produceFrame,但哈希命中照样被吞。

修复restoreMainWindow() 里,恢复窗口后主动发一条 WM_SIZE

procShowWindow.Call(uintptr(mainHWND), swRestore)
procSetForegroundWin.Call(uintptr(mainHWND))
// 关键:WM_SIZE 让 Shirei 把 havePresented=false,从而跳过「哈希跳过」条件,强制整帧重绘
procSendMessageW.Call(uintptr(mainHWND), wmSize, swRestore, 0)
procInvalidateRect.Call(uintptr(mainHWND), 0, 0)

WM_SIZE 正是 Windows 自己「最小化→恢复」也会发的消息,标准无害。wmSize 处理会把 havePresented=falsedirty=true,哈希跳过条件不再成立,整帧被强制重绘。

教训 任何想「刷新/强制重绘 Shirei 窗口」的场景,都要留意这个内容哈希跳过优化。光靠 RequestNextFrame() / InvalidateRect() 不够——它们只保证 onPaint 被调用,但 produceFrame 仍可能因哈希命中而吞掉绘制。要让它真正画,要么像本例发 WM_SIZE(置 havePresented=false),要么主动改变 UI 哈希。


2. 系统托盘:独立宿主窗口 CreateWindowExW 失败(错误 1400)

现象 托盘初始化时进程直接挂掉/没有托盘图标,而且从现象上看不清到底是「没启动托盘」还是「已经退出了」。

根因 最初为接收托盘回调(Shell_NotifyIconWWM_APP+1 通知),单独创建了一个 HWND_MESSAGE 类型的隐藏宿主窗口(CreateWindowExW(..., HWND_MESSAGE, ...))。在本机环境下该调用返回错误 1400(ERROR_INVALID_WINDOW_HANDLE,无效窗口句柄),导致整个托盘初始化中途 log.Fatal/panic 中止,后续图标注册、子类化全都没跑。

修复 彻底去掉独立宿主窗口,直接把 Shirei 的主窗口当作 notify 窗口(NOTIFYICONDATA.hWnd 指向主窗口句柄)。托盘 goroutine 用 FindWindowW("shireiWindowClass", 0) 轮询拿到主窗口句柄后再注册图标并子类化其 WndProc。这样不再依赖任何我们自己创建的窗口。

教训 在不确定的 Windows 环境下,不要假设能凭空 CreateWindowExW 出一个 HWND_MESSAGE 窗口。优先复用现有窗口句柄(Shirei 主窗口)作为通知目标。


3. GWLP_WNDPROC 常量溢出

现象 SetWindowLongPtrW(hwnd, GWLP_WNDPROC, ...) 用常量 -4(即 GWL_WNDPROC)时报 uintptr 溢出/负值错误。

根因 GWLP_WNDPROC 在有 64 位指针的平台上值是 -4,但 Go 字面量 -4 直接转 uintptr 会溢出。

修复 改用 Go 的按位取反写法:

const gwlpWndProc = ^uintptr(3) // 等价于 -4,但安全

教训 Windows 索引类常量(GWLP_* / GWL_*)写进 uintptr 时一律用 ^uintptr(n) 表达负值,避免字面量溢出。


4. 子类化 WndProc 必须在主线程执行

现象 在托盘 goroutine 里直接 SetWindowLongPtrW + syscall.NewCallback 子类化主窗口,行为异常甚至崩溃。

根因 Win32 的窗口过程回调(NewCallback)与主窗口的消息循环都绑定在 Shirei 创建窗口的那个线程(主线程/UI 线程)。在别的 goroutine 里改 WndProc、或把回调当普通 goroutine 函数用,跨线程操作窗口会出问题。GetMessage 在主线程阻塞等待消息,子线程发的 UI 操作也不会被及时处理。

修复 设计一条跨线程命令通道 trayCmdCh chan func()(在跨平台文件 tray_common.go 中声明,保证所有平台都编译得到)。托盘 goroutine 只把「子类化」「切换 frpc」等需要在主线程执行的闭包 queueOnUI() 投到通道;RootView(Shirei 每帧回调,运行在主线程)负责 drain 并执行这些闭包。

教训 Win32 UI 操作必须回归主线程。跨 goroutine 不要直接碰窗口,用「通道 + 主循环每帧消费」的模式把任务派发回 UI 线程。


5. 拦截 WM_CLOSE 必须放行真正的退出

现象 想实现「托盘运行时关闭→最小化;退出菜单→真正退出」,但退出菜单点了却关不掉。

根因 子类化后的 trayMainProc 拦截了 WM_CLOSE 并一律 SW_HIDE,没有把「退出」路径放行给默认窗口过程(DefWindowProc),于是 WM_CLOSE 永远不会走到真正销毁窗口的逻辑。

修复 用一个 trayExiting 标志区分两种语义:

  • trayExiting == true(退出菜单触发):先停 frpc、移除托盘图标,然后调用原函数指针 origMainProc,让 WM_CLOSE 正常流转到销毁
  • 否则:拦截 WM_CLOSE,改为 SW_HIDE 最小化到托盘。

教训 子类化只是「拦截并改写」,需要放行时务必把消息交回原始 WndProc,否则默认行为(含退出)会丢失。


6. 字体选择弹窗列表溢出、无滚动条

现象 「字体」弹窗里系统字体很多,列表直接超出弹窗区域,且没有滚动条。

根因 列表容器最初用了 NoClip()(不裁剪),且没有任何高度约束;即时模式 IMGUI 下容器不会自动限制自身高度去触发内部滚动。再加 Grow(1) 只会让列表尽量撑大,不会缩小超出部分。

修复 列表容器改为 Clip()(开启裁剪),并加 MaxHeight(boxH-120) 限制最大高度,超出即可滚动;同时把当前选中字体重排到列表顶部、给选中行加深背景色以突出。

教训 Shirei(即时模式)里带滚动的长列表必须显式 Clip() + 限高,否则既溢出又无滚动条。


7. settings.json 持久化路径与窗口居中

需求 把用户偏好(语言、字体)持久化到「软件所在目录」的 settings.json,且窗口默认居中到主显示器中心。

  • os.UserConfigDir() 会把配置放到系统级用户目录(如 %AppData%),不符合「软件同目录」诉求。
  • app.CenterWindow() 必须在 app.Run() 之前调用,否则不生效;Shirei 的居中由后端按平台处理。

修复

  • initPrefs()os.Executable() 取 exe 路径,filepath.Dir(exe)/settings.json 作为配置路径(找不到 exe 时回退 cwd);settings.json 已被 .gitignore 排除,不入库。
  • main()SetupWindow(...) 之后、Run(...) 之前调用 app.CenterWindow()

8. 窗口图标:.ico 无法被标准 image 解码

现象 想用 .ico 给窗口标题栏/任务栏设图标,Go 标准库 image 解码失败。

根因 标准 image 包不支持 .ico 解码。

修复(双轨)

  • 窗口图标(标题栏/任务栏/Alt-Tab):go:embed icon/icon.png 内联 PNG,运行时 app.SetupIconBytes(iconPNG)(须在 Run 前调用)。Shirei 用 image 解码 PNG 生成 HICON。
  • EXE 文件图标(资源管理器里看到的):用 icon_windows.rc + windres 生成 resource_windows.syso 内嵌多尺寸 .ico,仅在 Windows 构建生效。

教训 窗口运行时图标用 PNG(embed),文件图标用 .syso 资源,两者职责不同不要混。


9. 跨平台文件拆分的编译陷阱

//go:build windows / //go:build !windows 拆分平台相关实现时,容易漏掉:

  • 符号在所有平台都必须存在。例如 RootView 引用了 trayCmdCh,若只在 tray_windows.go 里声明,非 Windows 编译会因找不到符号失败。所以把跨平台都要用到的 trayCmdCh 放到无 build tag 的 tray_common.go
  • build tag 必须互斥且齐全,否则某个平台「零定义」或「双定义」都会编译失败。

修复 平台特定实现成对出现:locale_windows.go/locale_other.goprocess_windows.go/process_other.gotray_windows.go/tray_other.go(tray_other 为 Windows 之外的空实现,保持默认关闭=退出)。跨平台共享的符号放到无 tag 文件。


10. 文件对话框依赖 sqweek/dialog 的 cgo 问题

现象 此前 macOS/Linux 上用 sqweek/dialog 做「导入/导出配置」文件选择对话框时,会引入 cgo 依赖。

cgo 会破坏「纯 Go 单一二进制、零依赖、随手交叉编译」的目标——在 Windows 上编译 macOS/Linux 版本时,cgo 需要对应平台的 C 工具链,否则失败。

现状/规划 已解决。sqweek/dialog 已彻底移除(不在 go.mod/go.sum 与任何 import 中)。「导入/导出配置」文件选择对话框改为自绘纯 Go IMGUI 实现(filepicker.go),Win/macOS/Linux 三平台均 CGO_ENABLED=0 单二进制,无需 cgo 工具链。

教训 追求「零依赖单一二进制」时,谨慎引入任何带 cgo 的第三方包,尤其是只想做文件选择这种边缘交互。


11. 进程管理:切勿误杀用户自己的 frpc

现象 调试托盘时,为了「清理卡死的 frpc」,一度把机器上所有 frpc 进程都杀了,结果把用户正在使用的 frpc 也误杀,造成业务中断。

教训 只管理「由本程序自己 os/exec 启动」的 frpc 子进程,按 PID 精确停止;绝不taskkill /im frpc.exe /f 之类的宽匹配去杀全局进程。清理只针对本程序产生的孤儿进程,且要先和用户确认。


12. 构建参数与 go.mod / go.sum

构建命令(Windows,无控制台窗口)

go build -ldflags="-s -w -H windowsgui" -o FrpGUI.exe ./cmd/frpguiapp
  • -H windowsgui:生成 GUI 子系统程序,运行时不弹黑框(Windows 专属)。
  • -s -w:去掉符号表与调试信息,减小体积。
  • 其它平台:go build -o FrpGUI ./cmd/frpguiapp(去掉 -H windowsgui)。

go.mod 与 go.sum 是什么

  • go.mod模块身份 + 依赖版本声明。记录模块路径(如 FrpGUI)、Go 版本、以及直接/间接依赖及其语义化版本(如 go.hasen.dev/shirei v0.6.6github.com/pelletier/go-toml/v2 v2.x)。
  • go.sum依赖哈希指纹。记录每个依赖模块(及其 go.mod)的加密哈希,用于校验下载到的依赖未被篡改、保证可复现构建。它不决定「用哪个版本」,只保证「拿到的版本是当初锁定的那个」。

教训 改依赖后用 go mod tidy 同步二者;提交时 go.mod/go.sum 必须成对入库。


13. 字体/语言热重载设计

字体

  • 运行时用 Shirei 的 AllFontFaces() 动态枚举系统全部字体,弹窗列出,可一键「恢复系统默认」。
  • 选中字体重排到列表顶部并加深背景,方便回看。

语言

  • 语言包是「放文件即生效」:lang/<语言码>.toml。新增语言只需丢一个 toml(含 lang_name 键),无需改代码;语言菜单动态列出每个包自己的显示名。
  • 默认语言按 OS 区域推断(detectOSLang:先看 LC_ALL/LC_MESSAGES/LANG,再走 Windows GetUserPreferredUILanguages),再 matchLang 匹配可用包,失败回退 en
  • 所有界面文案经 T(key) / Tf(key, args...) 取用,键缺失返回 !key 便于开发期发现漏翻。

教训 多语言/多字体这类「易扩展」需求,用「数据驱动 + 运行时枚举」比硬编码分支更省维护成本。


14. 停止 frpc 误报「连接失败」(Windows Kill 退出码)

现象 在 Windows 上点「停止」按钮停下 frpc 后,状态栏却显示「连接失败」,看起来像是停得不对。

根因(关键) Windows 下主动停止走 TerminateProcess(Kill),frpc 因此以退出码 1 结束;旧逻辑在后台等待 goroutine 里只要 cmd.Wait() 返回非 nil 就一律判为 conn_failed。而 Linux/macOS 用 SIGTERM 优雅退出(码 0)。于是「用户主动停止」被错误地当成「崩溃 / 连接失败」。

修复FrpcManager 增加 stopping 标记:Stop() 确认正在运行时置 stopping=trueStart() 启动时清除。抽出纯函数 exitStatus(err, intentional)——err==nilintentional(主动停止)都返回 stopped,否则返回 conn_failed。退出等待 goroutine 据此设置状态,不再把主动停止误报为失败。

教训 跨平台进程管理要区分「主动停止」与「异常退出」:Windows 的 Kill 退出码与优雅退出不同,不能把一切非零退出都当失败。


15. 瞬时提示(如「已复制」)不要用定时器 / goroutine

现象 复制日志后要在「复制」按钮旁显示一条带时间戳的「已复制」提示,3 秒后消失;且 3 秒内连续复制要刷新(不叠加、不新开定时器)。

根因(关键) IMGUI 是即时模式、每帧重绘,引入 time.AfterFunc / 独立 goroutine 去「定时消失」会与渲染循环抢状态,且连续触发会叠加多个定时器、难以清理。

修复 采用「渲染循环每帧检查到期」模式:setCopyHint() 只写一个 logCopyHint 字符串 + logCopyHintUntil 时间戳(仅主线程写,无需锁);渲染时若 time.Now().Before(until) 则显示并在该帧调用 RequestNextFrame() 维持刷新,到期则当帧清空。连续复制只刷新 until 时间戳——天然覆盖、无叠加。

教训 Shirei / IMGUI 下的瞬时 UI 状态,优先用「记到期时间 + 每帧判到期」而非定时器 / goroutine;既无竞态,又天然支持连续触发的覆盖刷新。


16. 构建产物直接运行缺语言包 / 主题

现象 go run ./tools 把二进制输出到 build/windows-amd64/FrpGUI.exe 后,从那个目录双击运行,界面缺失语言(只剩内置 3 套)与主题(只剩内置 2 套),像没带上资产。

根因(关键) 应用按「可执行文件同级」查找 lang/theme/langDirs() / themeDirs() 优先 <exe_dir>/langcwd 仅作开发期回退)。构建脚本此前只输出二进制,没把运行时资产带过去——产物目录里根本没有 lang/theme/

修复 tools/build.go 编译完成后调用 copyAsset(),把 lang/theme/frpc.example.toml 递归复制到输出目录(纯 Go 的 copyDir / copyFile,无 cgo)。现在 build/<平台>-amd64/ 自带完整资产,可直接运行。

教训 凡是「按 exe 同级解析资源」的应用,构建步骤必须把运行时资产一起拷进产物目录,否则换个目录运行就缺资源。


17. go:embed 语言包漂移(嵌入与源不同步)

现象 为让「删光 lang/*.toml 仍可用」,把 zh-CN / zh-TW / en 经 go:embed 编译进二进制;但若只在仓库根 lang/ 改了文案,重新构建后界面没变。

根因(关键) 嵌入的是 cmd/frpguiapp/builtinlang/ 下的 toml 快照,与仓库根 lang/ 是两处;不主动同步,改根目录那份不会进二进制。

修复 tools/build.go 新增 syncBuiltinLangs(),在 go build 之前把 lang/{en,zh-CN,zh-TW}.toml 同步进 builtinlang/。约定「仓库根 lang/ 为唯一来源」,内嵌只是编译期快照——改语言包只改一处,构建自动同步。

教训go:embed 打包「本也以文件形式存在的资源」时,务必在构建前加一步从权威源同步,避免双份真相、改了不生效。


18. 托盘/渲染循环外触发弹窗不立即显示(隐藏窗口 + 缺帧调度)

现象 frpc 运行中,从系统托盘右键菜单选「退出」应弹「此操作将停止 frpc 并断开连接,是否继续?」确认框;但实际只有把鼠标移到软件窗口上,弹窗才出现,否则毫无变化。

根因(关键) 托盘菜单回调(trayMainProc,即 Shirei 主窗口的 Win32 WndProc)运行在渲染循环之外,且此时程序多处于「最小化到托盘」状态(窗口被 ShowWindow(SW_HIDE) 隐藏):

  1. 窗口是隐藏的,弹窗即便被绘制也画在不可见的窗口 DC 上 → 用户根本看不到;
  2. 触发 openConfirm 的路径不在 RootView 帧内,没有 RequestNextFrame() 唤醒下一帧;Shirei 的帧循环是按需调度的,没有输入事件就不会产生新帧——于是连「隐藏窗口上的那一帧」都不会发生,直到鼠标移到窗口产生输入才重绘。

修复 新增 trayConfirmExit()restoreMainWindow() 把隐藏窗口显示出来 → 再 openConfirm() 弹确认框 → 最后 requestFrame()shirei.RequestNextFrame())强制调度下一帧。三件事缺一不可:

  • 只恢复窗口不请求帧:窗口可见了,但帧循环仍空闲,弹窗可能要等输入才出现(不稳定);
  • 只请求帧不恢复窗口:帧画了,但画在隐藏窗口上,用户看不到。 另外在 confirmPopup() 顶部加 RequestNextFrame(),让弹窗在打开期间持续驱动帧循环,确保遮罩/按钮即时绘制、可交互(与第 15 节「瞬时提示」用的同一模式),无论弹窗从菜单还是托盘触发都稳健。

教训

  • 任何「在渲染循环之外、且窗口可能处于隐藏态」触发的弹窗/对话框(典型:Win32 托盘/消息回调),都必须 显式恢复窗口可见性 + RequestNextFrame() 唤醒帧,否则会出现「只有鼠标移到窗口才显示」的诡异现象。
  • 与本文档第 1 节(隐藏→恢复需发 WM_SIZE 破除内容哈希跳过)是同一类问题的两个切面:第 1 节解决「画在隐藏窗口上」,本节解决「画了但帧循环没被唤醒」;二者常常要一起用。

19. 开机自启用「当前用户」机制,不弹 UAC/sudo

现象 / 疑问 实现「开机自启」开关时,容易担心:这功能是不是要向系统索要管理员/root 权限?用户点击「开启自启」会不会弹出 UAC、sudo 或写不进注册表/目录的报错?

结论(关键) 本项目的三个平台实现都走 「当前用户」级 自启位置,完全不需要提权,也不会弹任何权限请求框:

平台自启位置是否需提权
WindowsHKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run否(HKCU 当前用户可写)
Linux~/.config/autostart/*.desktop(XDG 规范)否(用户家目录可写)
macOS~/Library/LaunchAgents/*.plist否(用户家目录可写)

只有「全机级 / 所有用户」自启(如 Windows 的 HKLM、Linux 的 /etc/xdg/autostart、macOS 的 /Library/LaunchAgents)才需要管理员权限——本项目刻意不使用,因此没有 UAC/sudo 风险。

几个平台实现细节(已核实并加固)

  • Windows:写 HKCU\...\RunFrpGUI 值(值为带引号的完整 exe 路径)。isAutostartEnabled() 改为与写入时的全路径精确比对(原先只 Contains basename,会在「不同目录下同名 exe」时误判已启用)。HKCU 写入不需要管理员;若程序以普通用户运行却去写 HKLM 才会触发 UAC,本项目不会。
  • Linux.desktopExec= 字段里空格是参数分隔符,exe 路径含空格会断裂;已对路径做 \ 转义。XDG 自启文件放在用户家目录,无需 root。
  • macOS~/Library/LaunchAgents 下的 plist 由 launchd 在下次登录时自动加载(无需手动 launchctl load);Label 应使用规范标识符 com.frpguiapp.gui(原先误用了文件名 com.frpguiapp.gui.plist 当 Label,已修正)。同样放在用户家目录,无需管理员。

教训

  • 做「开机自启」时优先选当前用户位置:免提权、免弹框、普通权限即可写入,体验最顺。
  • 若业务真的需要「为所有用户自启」,再考虑全机级位置并准备好处理提权 UI(那才是会弹 UAC/sudo 的地方),不要默认就往那走。
  • 跨平台自启的「权限」问题,本质就是「写的位置归不归当前用户管」;归当前用户管就不弹框,归系统管就弹框。

20. 裸 go build ./cmd/frpguiapp 在仓库根留下 frpguiapp.exe 残留

现象 / 疑问 每次跑构建或编译验证,仓库根目录就冒出一个 frpguiapp.exe(Windows 下),但正式构建脚本 go run ./tools 的产物明明在 build/windows-amd64/FrpGUI.exe

根因 tools/build.go 的构建命令显式指定了 -o build/<平台>-<goarch>/FrpGUI(.exe),产物都在 build/ 下,不会污染根目录。仓库根的 frpguiapp.exe 来自go build ./cmd/frpguiapp(未指定 -o:Windows 上 go build 默认把可执行文件命名为 frpguiapp.exe 并落到当前工作目录(仓库根)。把「仅编译检查」直接写成 go build ./cmd/frpguiapp 就会触发此残留。

为什么不会误提交 .gitignore*.exe 已经把 frpguiapp.exe 忽略了,git 不会跟踪它;只是看着困惑、污染根目录。

正确做法

  • 正式构建一律用 go run ./tools [windows|linux|darwin],产物在 build/
  • 只做编译/类型检查、不想落盘时,用 go vet ./cmd/frpguiapp,或 go build -o NUL ./cmd/frpguiapp(Windows)/ go build -o /dev/null ./cmd/frpguiapp(POSIX);不要裸跑 go build ./cmd/frpguiapp
  • 若根目录已残留,直接 rm frpguiapp.exe 即可(可重建,build/ 下仍有可用的 FrpGUI.exe)。

教训

  • Windows 上 go build <包> 不带 -o 会按目录名生成 <目录名>.exe 落在 cwd;把它当「编译检查」会留下残留。
  • 验证能否编译通过,优先 go vet(不产二进制),或显式 -o 到临时/忽略位置。

21. 字体不内嵌:go:embed 对 TTF 几乎不压缩(字体嵌入体积实测)

现象 / 疑问 想「内置一个字体」解决冷门语种缺字,会撑大二进制多少?

实测(独立 module 验证) 在独立 Go module 用 //go:embed 嵌入本机 17.77MB 的 Noto Sans SC 可变字体:

  • 基线 exe:1.60MB
  • 嵌入后 exe:18.55MB,增量 16.96MB
  • 压缩比 delta/raw = 1.001,即 go:embed 对 TTF 几乎不压缩,实付 ≈ 字体文件大小

推论

  • 单个字体只覆盖一个文字区;要内嵌整族 Noto 覆盖 800+ 冷门语种需数十个字体(数百 MB),得不偿失。
  • 当前 FrpGUI ≈ 7.75MB:嵌入 Noto Sans SC 可变 ≈ 24.7MB,静态单字重 ≈ 15.8–17.8MB,仅嵌基础拉丁 Noto 仅 +0.1–1MB。
  • 内嵌只应作为最后兜底,且优先静态单字重(体积可控)。

正确做法(本项目已采用)

  • 不内嵌任何字体。
  • 缺字经 Shirei 字体子系统 + 应用层「字体回退链」自动回退系统字体(已枚举 16 个覆盖字体:Malgun Gothic 韩 / Leelawadee UI 泰 / Microsoft JhengHei 繁中 / Yu Gothic 日 / Ebrima·Nyala 埃塞 / Nirmala UI Indic 等,仅采用系统真实存在的)。
  • 冷门语种仍显示方块/空白时,请用户自行在 OS 安装对应字体(Noto Sans 系列 / 系统自带韩文泰文阿拉伯字体),重启或刷新字体菜单即自动识别,无需改程序或重编译。
  • 详见 README「字体策略:不内置任何字体」与踩坑文档第 13 节(字体/语言热重载设计)。

教训

  • 评估「内置资源」前先实测 embed 压缩率;TTF/字体类资源 embed 几乎不压缩,体积代价 ≈ 原文件大小。
  • 不要为「防缺字」盲目内嵌整族字体;优先系统字体回退链。

22. Go 测试源码必须与被测包同目录;不要建独立 test/ 文件夹

现象 / 疑问 想把所有「跑测试的源码」统一放到一个 test/ 文件夹,跟正常业务源码分开,更整洁。

根因 / 约束

  • Go 的 *_test.go 必须与所属 package 同目录go test 才认;但 go build 自动忽略 *_test.go,不会进二进制。
  • 因此「与源码同目录」既满足测试发现,又不污染发布物——这本身就是 Go 官方约定的「统一位置」。
  • 把测试搬到独立的顶层 test/ 目录,要么 go test ./... 找不到,要么被迫拆成独立 module(反而更乱、还需单独 go.mod)。

本项目做法

  • 测试源码统一位置 = 与被测包同目录:cmd/frpguiapp/*_test.go(22 个)+ tools/sbom_test.go
  • 已删除仓库根的 test/(原为「字体嵌入体积实测」的独立 module,属一次性验证脚本;验证完留着只是噪音,且会误导别人以为那是测试标准位置)。

附:验证命令的坑

  • gofmt/go vet/go test 要用 PATH 上的 go;不要误把其它语言的二进制路径(如 python 解释器路径)当 go 用,否则命令根本没执行。
  • 配合 ... | head 之类管道时,管道成功会掩盖上游失败、让 && echo "OK" 误报通过。验证是否真通过,看命令本身的退出码(${PIPESTATUS[0]} 或分段运行),不要只看管道后的 echo。

教训

  • Go 项目「测试统一位置」就是包目录内,而非单独的顶层 test/
  • 一次性验证脚本若做成独立 module,验证完即删,别长期留在仓库根。
  • 校验/测试命令务必确认用的是真 go 且看真实退出码,别被管道回声骗过。

23. 自绘文件选择器:Windows 盘根 filepath.Dir 返回自身,无法跨盘

现象 导入配置弹窗里,软件在 D 盘时,在 D:\ 根目录点「上一级」无法回到「此电脑」,只能选 D 盘里的文件,选不到 C 盘 / E 盘的东西。

根因 自绘文件选择器用 filepath.Dir(dir) 计算「上一级」。在 Windows 盘根上,filepath.Dir("D:\\") 返回 "D:\\" 自身(盘根被当作 volume name,Dir 认为已到顶层),于是 parent != ioPickerDir 永远为假,向上导航被锁死在当前盘。

修复 引入虚拟根哨兵 ioComputerRoot = "::computer::"isDriveRoot() 判定:

  • 在盘根点「上一级」→ 跳到「此电脑」虚拟根,列出全部可用逻辑驱动器(filepicker_windows.gowindowsDrives()syscall.NewLazyDLL("kernel32").GetLogicalDrives 位掩码枚举,空软驱/光驱不会卡住);
  • 点列表里的驱动器直接进入该盘根(如 C:\E:\),即可跨盘选文件;
  • 目录行在虚拟根显示本地化标签「此电脑」(io_computer),已加进全部 19 个语言包;虚拟根处「打开/保存」按钮禁用(无可导出文件),并加防御防止把文件名拼到哨兵路径下写出非法文件;
  • 非 Windows 平台由 filepicker_other.go 占位(驱动器枚举是 Windows 概念,行为不变)。

教训 自绘文件选择器做「上一级」导航时,不能依赖 filepath.Dir 在盘根/根目录的行为——盘根之上还有「此电脑 / 驱动器列表」这一层,必须显式建模(虚拟根 + 驱动器枚举),否则会被 filepath.Dir 的「到顶返回自身」特性锁死在单盘内。


24. 导入/导出共用 io 结果分支:导出误改「已加载」配置名

现象 导出配置后,控制区「已加载:」被改成了刚导出的文件名,误导用户以为当前配置档案被切换成了导出的那份。

根因 handlers.go 处理文件选择结果时,导入与导出共用同一个回调分支;导出分支里错误地写了 appState.cfgName = filepath.Base(p),把「当前激活的配置文件名(已加载)」与「最近一次导出的路径名」混为一谈。但导出本质是「另存为」,不该改变当前档案。

修复 删除导出分支里那行 appState.cfgName = ...;导出只在日志里记一条 log_exported(含完整路径),不触碰 cfgName(它由启动加载 / 导入 / 切换预设维护)。顺带移除因此变空的 path/filepath 导入。

教训 「导入 = 加载文件并使其成为当前档案」与「导出 = 另存为、不改变当前档案」语义相反;二者共用同一结果回调时,必须按 action 区分分支,绝不能在导出分支里动「当前配置 / 已加载」状态。


最后更新:2026-08-18