FrpGUI 开发踩坑与解决方案记录
August 18, 2026 · View on GitHub
适用版本:frp 客户端 GUI(Go + Shirei v0.6.6 IMGUI),Windows / macOS / Linux 跨平台。 本文汇总从项目搭建到系统托盘落地过程中踩过的坑、根因分析与最终修复,供后续维护与二次开发参考。
目录
- Shirei 窗口隐藏/恢复后白屏(内容哈希跳过渲染)
- 系统托盘:独立宿主窗口 CreateWindowExW 失败(错误 1400)
- GWLP_WNDPROC 常量溢出
- 子类化 WndProc 必须在主线程执行
- 拦截 WM_CLOSE 必须放行真正的退出
- 字体选择弹窗列表溢出、无滚动条
- settings.json 持久化路径与窗口居中
- 窗口图标:.ico 无法被标准 image 解码
- 跨平台文件拆分的编译陷阱
- 文件对话框依赖 sqweek/dialog 的 cgo 问题
- 进程管理:切勿误杀用户自己的 frpc
- 构建参数与 go.mod / go.sum
- 字体/语言热重载设计
- 停止 frpc 误报「连接失败」(Windows Kill 退出码)
- 瞬时提示不要用定时器 / goroutine
- 构建产物直接运行缺语言包 / 主题
- go:embed 语言包漂移(嵌入与源不同步)
- 托盘/渲染循环外触发弹窗不立即显示(隐藏窗口 + 缺帧调度)
- 开机自启用「当前用户」机制,不弹 UAC/sudo
- 裸
go build在仓库根留下 frpguiapp.exe 残留 - 字体不内嵌:
go:embed对 TTF 几乎不压缩(字体嵌入体积实测) - Go 测试源码必须与被测包同目录;不要建独立
test/文件夹 - 自绘文件选择器:Windows 盘根
filepath.Dir返回自身,无法跨盘 - 导入/导出共用 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=false 且 dirty=true,哈希跳过条件不再成立,整帧被强制重绘。
教训
任何想「刷新/强制重绘 Shirei 窗口」的场景,都要留意这个内容哈希跳过优化。光靠 RequestNextFrame() / InvalidateRect() 不够——它们只保证 onPaint 被调用,但 produceFrame 仍可能因哈希命中而吞掉绘制。要让它真正画,要么像本例发 WM_SIZE(置 havePresented=false),要么主动改变 UI 哈希。
2. 系统托盘:独立宿主窗口 CreateWindowExW 失败(错误 1400)
现象 托盘初始化时进程直接挂掉/没有托盘图标,而且从现象上看不清到底是「没启动托盘」还是「已经退出了」。
根因
最初为接收托盘回调(Shell_NotifyIconW 的 WM_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.go、process_windows.go/process_other.go、tray_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.6、github.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,再走 WindowsGetUserPreferredUILanguages),再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=true;Start() 启动时清除。抽出纯函数 exitStatus(err, intentional)——err==nil 或 intentional(主动停止)都返回 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>/lang,cwd 仅作开发期回退)。构建脚本此前只输出二进制,没把运行时资产带过去——产物目录里根本没有 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) 隐藏):
- 窗口是隐藏的,弹窗即便被绘制也画在不可见的窗口 DC 上 → 用户根本看不到;
- 触发
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 或写不进注册表/目录的报错?
结论(关键) 本项目的三个平台实现都走 「当前用户」级 自启位置,完全不需要提权,也不会弹任何权限请求框:
| 平台 | 自启位置 | 是否需提权 |
|---|---|---|
| Windows | HKEY_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\...\Run的FrpGUI值(值为带引号的完整 exe 路径)。isAutostartEnabled()改为与写入时的全路径精确比对(原先只Containsbasename,会在「不同目录下同名 exe」时误判已启用)。HKCU 写入不需要管理员;若程序以普通用户运行却去写 HKLM 才会触发 UAC,本项目不会。 - Linux:
.desktop的Exec=字段里空格是参数分隔符,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.go的windowsDrives()用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