一、 项目设计核心思想
本项目的核心定位是内网穿透的一键化管理。参考 ngrok 的服务模式,通过自建 frp 服务器 提供稳定中转,将复杂的配置封装在 Wails 客户端中。
- 商业闭环:通过微信小程序激励视频获取连接权限(2小时有效期)。
- 用户体验:一键连接、自动分配二级域名、配置持久化。
二、 前端架构:原生 JS 的三维交互
为了保持轻量,前端放弃了重量级框架,采用原生 JS 与 Wails 运行时通信。
- 控制面板 (Dashboard):状态驱动 UI。涉及扫码弹窗逻辑、广告验证状态机。
- 配置页面 (Settings):表单处理。重点在于 Local Port 的保存与通过 Wails Bind 将数据下发给 Go 后端。
- 运行日志 (Logs):虚拟黑屏终端。难点在于实时流式展示后端 frpc 吐出的日志。
三、 后端核心技术要点
将按照应用生命周期逻辑,对以下模块进行深度归纳:
a. 应用原生窗口定义
- 结构化管理:AppManager 模式
// AppManager 统一管理应用和窗口
type AppManager struct {
App *application.App
MainWindow application.Window
}
var manager = &AppManager{}
采用了 AppManager 结构体来统一持有 App 实例和 MainWindow 实例。
知识点:在 Wails 3 中,不再像 v2 那样通过上下文(ctx)传递,而是鼓励通过对象持有的方式管理窗口引用。这方便了后续在任何 Service 中通过 manager.MainWindow 直接操控窗口(如置顶、隐藏、发送事件)。
- Service 注入机制
ms := NewMoleService()
Services: []application.Service{
application.NewService(ms),
},
知识点:这是 Wails 3 的重大改进。通过 application.NewService 将自定义的 MoleService(核心逻辑类)注册进去。
作用:Service 会在应用启动时自动初始化,并且其公开方法可以被前端 JS 直接调用,实现了前后端的“无感通讯”。
- 跨平台窗口与生命周期策略
manager.App = application.New(application.Options{
Name: "FRP管理客户端",
Description: "一个实现自动内网穿透的管理工具",
LogLevel: slog.LevelDebug,
Services: []application.Service{
application.NewService(ms),
},
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(assets),
},
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: false,
},
Windows: application.WindowsOptions{
DisableQuitOnLastWindowClosed: true,
},
OnShutdown: func() {
// 清理资源
ms.Cleanup()
},
})
针对 Windows 和 Mac 做了不同的退出策略,这是为了实现托盘运行的关键一步:
- Windows 策略:DisableQuitOnLastWindowClosed: true。即使点击了关闭按钮,后端进程依然运行,配合托盘图标可以实现“后台挂机”。
- Mac 策略:ApplicationShouldTerminateAfterLastWindowClosed: false(为了保持穿透稳定,设为 false)。
- 资源清理:OnShutdown 回调中执行 ms.Cleanup()。这确保了用户退出客户端时,后台运行的 frpc 进程会被强制杀死,杜绝进程残留。
- 精细化原生窗口配置
manager.MainWindow = manager.App.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "main",
Title: "FRP 控制面板",
Width: 675, // 设置宽度
Height: 575, // 设置高度
DisableResize: true,
MaxWidth: 675,
MaxHeight: 575,
Mac: application.MacWindow{
InvisibleTitleBarHeight: 50,
Backdrop: application.MacBackdropTranslucent,
TitleBar: application.MacTitleBarHiddenInset,
// macOS 禁用缩放也会自动禁用全屏按钮
},
BackgroundColour: application.NewRGB(27, 38, 54),
URL: "/",
})
- UI 规格控制:通过 DisableResize、MaxWidth/Height 严格限制了窗口尺寸(675x575),这对于简单的工具软件能保证 UI 布局不走样。
- Mac 特色适配:
- TitleBarHiddenInset:隐藏标题栏但保留红绿灯按钮,这是现代 macOS 应用(如 Raycast, Warp)的主流设计。
- MacBackdropTranslucent:开启原生窗口的毛玻璃半透明效果,提升质感。
- 静态资源托管:AssetFileServerFS(assets) 利用 Go 的 embed 功能,将所有 HTML/JS/CSS 打包进二进制文件。
b. 设备 ID (Machine ID) 的唯一性生成
知识点:如何生成机器指纹,确保用户在小程序看广告后,服务器能准确推送到对应的客户端。
func (s *MoleService) getUniqueDeviceID() string {
appID := "91demo.top"
// 1. 获取平台信息 (如: windows, darwin, linux)
osName := runtime.GOOS
// 2. 获取架构信息 (如: amd64, arm64)
archName := runtime.GOARCH
// 3. 获取当前毫秒级时间戳
timestamp := time.Now().UnixNano() / int64(time.Millisecond)
// 4. 组合原始字符串
rawString := fmt.Sprintf("%s-%s-%s-%d", appID, osName, archName, timestamp)
// 5. 计算 SHA256 哈希
hash := sha256.Sum256([]byte(rawString))
// 6. 转为十六进制字符串并截取前 16 位
result := "mc" + fmt.Sprintf("%x", hash)[:16]
return result
}
指纹合成(Fingerprinting)使用 appID (“91demo.top”) 确保 ID 具备命名空间属性,防止与其他应用冲突,引入 runtime.GOOS 和 runtime.GOARCH,使用 UnixNano 毫秒级时间戳,意味着每次生成的 ID 都是不同的。现在的业务场景是“扫码后绑定连接”,这种动态 ID 保证了安全性(旧链接自动失效)。当用户将配置文件拷贝到另一台电脑并不影响,当需要绑定设备时,就需要获取设备序列号,网卡,硬盘等信息。
c. 小程序码交互与扫码逻辑
这个知识点很硬核,牵涉到了小程序码生成,扫码,以及信息交换逻辑。
func (s *MoleService) getSubDomain(devID string) string {
apiURL := "https://91demo.top/subdomain"
ts := Now()
content := fmt.Sprintf("%s%d", devID, ts)
sign := HmacSign(content, secretKey)
postData := map[string]any{
"device_id": devID,
"timestamp": ts,
"signature": sign,
}
res, err := requestBackend("POST", apiURL, postData)
if err != nil {
log.Println("获取子域名错误1,", err)
return ""
}
if !res.Success {
log.Println("获取子域名错误2,", res.Error)
return ""
}
return res.Subdomain
}
func (s *MoleService) getMpCode(devID string, subDomain string) string {
apiURL := "https://91demo.top/mpcode" // 你的后台地址
ts := Now()
content := fmt.Sprintf("%s%d", devID, ts)
sign := HmacSign(content, secretKey)
postData := map[string]any{
"device_id": devID,
"sub_domain": subDomain,
"timestamp": ts,
"signature": sign,
}
res, err := requestBackend("POST", apiURL, postData)
if err != nil {
log.Println("获取小程序码错误1,", err)
return ""
}
if !res.Success {
log.Println("获取小程序码错误2,", res.Error)
return ""
}
return res.MpCode
}
当系统第一次启动的时候,会生成设备ID,然后根据设备ID获取子域名,也就是说,当第一次启动的时候,就确定了设备ID和子域名。当这些都获取成功后,会再次请求,获取专属小程序码,这个小程序码包含了设备ID和子域名。当用户扫码时会跳转到观看广告页面。这里不会绑定身份,不管谁看都可以。
已经准备好了材料,当客户端计算上次看广告的时间距离当前时间已经超过了两个小时,那么就会弹出小程序码弹窗。
func (s *MoleService) HandleStartFrp() Response {
// 1. 检查广告状态
if !s.checkAdStatus() {
// 启动一个协程监控广告状态
go func() {
for {
if s.isFinished.Load() {
s.startFrp() // 启动 FRP
// 通知主界面更新 UI
s.emitAdStatus("done", "验证成功")
break
}
time.Sleep(2 * time.Second)
}
}()
// 4. 后端建立 WebSocket (使用 gorilla/websocket)
s.StartWS()
return Response{
Code: 2,
Content: s.config.MpCode,
}
}
// 3. 如果已看广告,直接启动 FRP
s.startFrp()
url := s.GetDomainURL()
return Response{
Code: 1,
Content: url,
}
}
这一段 HandleStartFrp 函数是整个应用业务逻辑的中枢调度器。它完美展示了如何通过 Go 的并发特性处理异步业务(广告验证)与长连接管理。
当 checkAdStatus 失败(用户未看广告)时,程序并未阻塞,而是启动了一个新的 Goroutine (协程) 运行匿名函数。使用 s.isFinished.Load() 来表示用户是否观看广告,这使用了 sync/atomic 包来处理跨协程的布尔标志位,这在多线程环境下是保证线程安全的标准做法,避免了数据竞争。每 2 秒检查一次状态,一旦验证通过,立即触发 startFrp()。
调用 s.StartWS() 建立WebSocket,用来接听用户是否完成观看广告。 由于“看广告”是在移动端(微信小程序)完成的,服务端需要通过 WebSocket 主动向桌面客户端“推送”完成信号。客户端不再死等 API 返回,而是通过长连接静默等待,极大提升了 UI 的响应速度。
s.emitAdStatus(“done”, “验证成功”) 使用事件驱动的 UI 更新 (Emit Event),这是 Wails 的核心优势之一。后端逻辑层可以主动触发前端的 JS 回调。当协程发现广告看完后,直接发送事件,前端收到后自动关闭扫码弹窗并切换到连接成功界面,实现了数据驱动视图。
使用了一种典型的状态机驱动。根据当前业务状态(是否看广告),返回不同的数据结构,让前端逻辑保持极简。其中:
- Code 2:代表“需要看广告”。返回 MpCode(小程序码),告诉前端:“去展示扫码界面”。
- Code 1:代表“已看过广告”。直接返回 DomainURL,告诉前端:“显示你的内网穿透地址”。
当完整观看广告后,后端会启动frpc。
d. 内嵌 frpc 资源的打包与管理
在启动frpc之前,我们需要先找到frpc。
// 仅打包 Windows 平台的两个架构
//go:embed resources/bin/frpc_windows_amd64.exe resources/bin/frpc_windows_arm64.exe
var frpcBin embed.FS
// 定义各架构对应的文件名
var frpcMap = map[string]string{
"amd64": "resources/bin/frpc_windows_amd64.exe",
"arm64": "resources/bin/frpc_windows_arm64.exe",
}
const frpcTargetName = "frpc.exe"
这里使用 //go:embed 将 frpc 二进制文件打包进程序,方便分发,可以避免分发时缺少文件。上面是Windows的平台,还有Linux和Mac平台。
func (s *MoleService) PrepareFrpEnv() (string, string, error) {
binDir := s.getFrpBinDir()
// 1. 确定 frpc 路径
frpcPath := filepath.Join(binDir, frpcTargetName) // frpcTargetName 是你在条件编译文件里定义的名称
// 如果文件不存在则从 embed 释放
if _, err := os.Stat(frpcPath); os.IsNotExist(err) {
arch := runtime.GOARCH
data, err := frpcBin.ReadFile(frpcMap[arch])
if err != nil {
return "", "", err
}
if err := os.WriteFile(frpcPath, data, 0755); err != nil {
return "", "", err
}
}
// 2. 确定 toml 路径并写入
tomlPath := filepath.Join(binDir, "frpc.toml")
if _, err := os.Stat(tomlPath); os.IsNotExist(err) {
tomlContent := s.genFrpConfig()
if err := os.WriteFile(tomlPath, []byte(tomlContent), 0600); err != nil {
return "", "", err
}
}
return frpcPath, tomlPath, nil
}
这是客户端环境初始化逻辑,它在运行前,会把frpc二进制解压到bin目录,然后生成Frpc配置文件。这里动态生成配置文件有两个好处,一是免除了用户配置繁琐性,二是可以动态生成一些广告相关参数。
模块通过 “内存内嵌 -> 架构匹配 -> 自动释放 -> 动态写库” 的流水线,将一个复杂的内网穿透工具简化成了对用户完全透明的本地环境。它是客户端能够“一键连接”的技术基石。
e. 动态启动 frpc 进程
func (s *MoleService) startFrp() {
// 1. 创建命令
s.frpCmd = exec.Command(frpcPath, "-c", tomlPath)
// 2. 针对 Windows 隐藏黑窗口
if runtime.GOOS == "windows" {
// 使用 windows 专用的属性隐藏窗口
s.frpCmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}
// 创建管道获取输出
stdout, _ := s.frpCmd.StdoutPipe()
stderr, _ := s.frpCmd.StderrPipe()
// 启动一个定时器,每 250ms 检查一次缓存并发送
ticker := time.NewTicker(250 * time.Millisecond)
go func() {
for {
select {
case <-ticker.C:
s.flushLogs()
case <-s.ctx.Done():
ticker.Stop()
return
}
}
}()
// 2. 合并读取日志的函数
readLog := func(reader io.ReadCloser) {
// 关键点:函数结束时关闭 reader,确保系统资源释放
defer reader.Close()
scanner := bufio.NewScanner(reader)
// 当进程退出,管道关闭时,Scan() 会自动返回 false,循环结束
for scanner.Scan() {
line := scanner.Text()
s.logMu.Lock()
s.logBuffer = append(s.logBuffer, line) // 将日志存入切片
s.logMu.Unlock()
}
}
// 启动进程
if err := s.frpCmd.Start(); err != nil {
s.mu.Unlock()
s.emitLog("frpc 进程启动失败,", err.Error())
log.Printf("启动 frpc 失败: %v", err)
return
}
s.mu.Unlock()
go readLog(stdout)
go readLog(stderr)
go func() {
// Wait 会阻塞直到进程结束
_ = s.frpCmd.Wait()
s.mu.Lock()
defer s.mu.Unlock()
// 清理句柄并重置运行状态
s.frpCmd = nil
// 区分退出的原因
if s.isManualStop.Load() {
s.emitLog("frpc 进程已退出")
} else {
s.emitLog("警告:frpc 进程异常退出,请检查配置或网络")
// 这里可以触发 Wails 事件通知前端 UI 变更为“停止”状态
s.emitFrpStatus("stop")
}
}()
// 发送自定义事件,通知前端关闭弹窗
log.Printf("frpc 已启动,PID: %d,配置文件: %s", s.frpCmd.Process.Pid, tomlPath)
}
这是整个客户端的核心,它是动力引擎,不仅负责外部二进制程序的调用,还精细的处理了进程生命周期,日志流转,以及跨平台细节优化。
使用s.mu.Lock()可以确保在多线程环境下(比如用户连点按钮)不会启动多个frpc进程,保证了状态的唯一性。
通过syscall.SysProcAttr{HideWindow: true} 解决了 Windows 环境下弹出 CMD 黑窗口的痛点,使应用表现得像一个原生 GUI 程序。
由于frpc会产生大量日志,每一行日志通过Wails Event立即发送给JS,会造成前端渲染压力过载,导致UI掉帧或者卡顿。这里使用了日志切片,后端协程readLog负责将扫描到的日志先存入切片,使用logMu保证并发写入安全。然后启动一个250ms的定时器,通过flushLogs批量将缓冲区内的日志一次性推送到前端。这样可以极大地降低前后端通信频率,保证UI流程。
最后就是资源清理,当发生异常,或者关闭时,清理s.frpCmd = nil,为下一次“一键连接”扫清障碍。
通过“OS 进程控制 + 线程安全缓冲区 + 定时批处理日志”,实现了一个工业级的子进程管理器。它不仅能稳定驱动 frpc,还通过优雅的日志处理方案,兼顾了后端运行的高效性与前端显示的流畅度。
f. 系统托盘 (System Tray) 与生命周期
manager.MainWindow.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
// Hide the window
manager.MainWindow.Hide()
// Cancel the event so it doesn't get destroyed
e.Cancel()
})
systemTray := manager.App.SystemTray.New()
// Use the template icon on macOS so the clock respects light/dark modes.
if runtime.GOOS == "darwin" {
systemTray.SetTemplateIcon(icons.SystrayMacTemplate)
}
// 2. 定义左键点击逻辑:显示并聚焦窗口
systemTray.OnClick(func() {
if manager.MainWindow != nil {
manager.MainWindow.Show()
manager.MainWindow.Focus()
}
})
// 3. 定义右键菜单
menu := manager.App.NewMenu()
menu.Add("显示窗口").OnClick(func(ctx *application.Context) {
manager.MainWindow.Show()
manager.MainWindow.Focus()
})
menu.AddSeparator() // 分割线
menu.Add("退出").OnClick(func(ctx *application.Context) {
manager.App.Quit()
})
systemTray.SetMenu(menu)
这是系统托盘的代码,为客户端注入了“灵魂”,它将一个普通的窗口程序转化为了一个常驻后台的系统服务工具。在 Wails 3 中,系统托盘(System Tray)与生命周期钩子(Hooks)的配合是提升用户体验的关键。
通过 RegisterHook 监听 events.Common.WindowClosing,调用 manager.MainWindow.Hide() 而非销毁。e.Cancel(),这告诉操作系统:“别真的关掉这个进程,我只是把它藏起来了”。这保证了内网穿透服务的持续性。用户点击“叉号”只是关闭 UI,后台的 frpc 依然稳定运行。
对于系统托盘 (System Tray) ,我们需要多平台适配,例如macOS 模板图标:SetTemplateIcon 确保图标在 macOS 的黑夜/白天模式下自动反色,保持原生质感。
系统托盘菜单的交互逻辑:
- OnClick (左键):实现“一键唤回”。通过 Show() 和 Focus() 组合,确保窗口从后台弹出并直接置于用户视觉中心。
- 右键菜单 (Context Menu):通过 manager.App.NewMenu() 构建功能矩阵。区分了“显示窗口”与“退出程序”,给用户明确的心理预期。
在菜单中调用 manager.App.Quit()。这会触发前面定义的 OnShutdown 钩子,从而执行 ms.Cleanup()(关闭 frpc 进程),完成整个应用资源的闭环清理。
四、 总结与反思
通过使用 Wails 3 与 Go 的结合,我成功将原本复杂的 frp 配置过程简化为了“扫码-连接”的直觉化操作。回顾整个开发过程,这不仅是一次对跨平台桌面开发的尝试,更是一次关于用户体验与商业逻辑结合的实践。
- 技术层面的核心收获
在本项目中,我完成了以下几个关键的技术验证:
- 资源极简化:通过 go:embed 实现了 frpc 二进制文件的内嵌与按需释放,达成了“单文件绿色运行”的目标。
- 通信异步化:利用 Goroutine 配合 WebSocket 解决了跨端(小程序到桌面端)的状态同步难题。
- UI 流畅性:通过日志批处理(Ticker + Buffer)机制,在高频日志输出与前端渲染性能之间找到了完美的平衡点。
- 原生集成:深入应用了 Wails 3 的窗口钩子(Hooks)与系统托盘 API,使程序具备了成熟工具软件应有的常驻后台能力。
- 下一阶段的演进方向
虽然核心逻辑已经跑通,但要成为一个真正面向大众的“产品”,后续还有一段路要走:
- 安全加固:目前的签名机制虽已成型,但后续还需在 代码混淆 和 二进制加壳 上下功夫,防止核心逻辑被破解。
- 合规化合集:针对 macOS 的 App Notarization(公证) 和 Windows 的 代码签名证书 是提升用户信任感、避免系统误报毒的必经之路。
- 分发与增长:技术实现只是起点,如何编写更具吸引力的宣传文案、如何建立稳定的用户反馈渠道,将决定这个工具能走多远。
- 最后的感悟
开发这个 frp 管理客户端的过程中,我深刻体会到:好的技术不应该增加用户的认知负担,而应该通过底层的复杂来换取表层的简单。
我深知一个产品从‘能用’到‘商业化’还有巨大的鸿沟,包括上架、品牌化、合规化等繁琐流程。作为一个独立开发者,我或许没有足够的精力去完成每一个商业环节。
但当我看到‘内网穿透’这一专业的概念,最终被简化为一个绿色的‘连接成功’状态时,那种创造的成就感已然足够。这篇文章记录下的每一个坑位和解决方案,才是我未来在技术路上最真实、最宝贵的资产。
- 后续
在完成核心功能的开发后,我去简单了解一下这方面的知识。我面临了一个所有穿透类工具开发者都会遇到的终极问题:“技术中立”能否作为逃避监管风险的挡箭牌?
作为个人开发者,将这样一款“一键式”工具公开发布,虽然能获得流量和成就感,但随之而来的合规风险是不容忽视的:
- 域名内容监管:由于客户端自动分配的是我主域名下的二级域名,根据“谁接入谁负责”的原则,一旦用户利用该工具发布违规内容(如钓鱼、色情或非法信息),域名备案主体将面临直接的法律追责。
- 内容审计的缺位:个人开发者往往缺乏足够的力量去构建一套像大厂那样严密的协议层审计系统。在无法确保 100% 识别非法流量的情况下,盲目开放公共服务是极其危险的。
- 技术与责任的平衡:我始终认为,技术的进步是为了降低生产力的成本,而不是为了降低违规的成本。
因此,我做出了一个决定: 本项目仅作为个人技术研究与归纳,并不对外提供公网商业化服务。在文章中,我详细记录了所有技术坑位,旨在分享 Wails 3 与 Go 处理多进程管理的经验。
对于同样想开发此类工具的同学,我建议在发布前务必考虑以下防护策略:
- 接入实名认证:通过小程序或手机号,将流量行为追溯到具体自然人。
- 限制协议类型:仅开放非 Web 类的 TCP/UDP 转发,减少敏感内容暴露风险。
- 自建服务器模式:鼓励用户自备 frp 服务器,开发者仅提供易用的客户端界面,实现“工具”与“服务”的解耦。
这种“知止”的态度,是个人开发者在技术探索路上保护自己的最好方式。
经过深思熟虑,我决定移除原有的“小程序码验证”与“动态子域名分配”等强绑定业务逻辑,回归工具本质,将其重塑为一个纯粹、通用的 frp 自动化管理客户端。
现在的它支持连接用户自建的服务器,彻底实现了“工具与服务脱钩”,不仅消除了合规层面的后顾之忧,也赋予了用户更高的自由度。目前该项目已正式在 GitHub 开源,欢迎感兴趣的开发者参考或共建。
🔗 项目地址:littletow/mole-go
“代码因分享而有价值,工具因纯粹而更长久。”