为什么选择了Wails 3 ?

Wails 3 最大的改变在于它不再强绑定于某个特定的前端框架,且引入了多窗口支持更轻量级的 Runtime。它允许你在不启动主窗口的情况下运行后端服务,这正是我们实现“系统托盘”和“后台演示服务”的基础。

在 v2 中,我们习惯于自动生成的 wailsjs 文件夹。但在 v3 中,这一逻辑被进一步标准化。

当你运行开发指令时,Wails 会扫描你的 Go 结构体方法,并将其映射为前端可以调用的 JavaScript 函数。这个过程在 v3 中被称为 Generate 过程。

Wails 3 通信灵魂

我们在前端调用在后端定义的 HandleConnect 返回自定义结构体,这在 Wails 3 中是前后端通信的灵魂。

在 Wails 3 开发中,最核心的动作就是:后端做功,前端表现

当你调用 MoleService.HandleConnect() 时,Go 后端会产生一个结果。在本项目中,我们需要同时返回一个 Code(状态码)和一个 Content(数据内容)。

为了实现这一点,我们定义了一个结构体:
type Response struct { Code int; Content string }

虽然 Go 内部使用的是结构体,但前端 JavaScript 只能读懂 JSON 对象。Wails 3 内部会自动帮你完成这个“翻译”过程。

但是,如果你想让前端看到的字段名是小写的(例如 res.code 而不是 res.Code),你必须在 Go 结构体定义时加上“注解”。

type Response struct {
    Code    int    `json:"code"`
    Content string `json:"content"`
}

记住,所有通过 bindings 调用的 Go 方法,在前端返回的都是一个 Promise 对象。这意味着你必须使用 await 或者 .then() 来接收数据,否则你拿到的将是一个永不开启的“盲盒”。

制作客户端界面

我们定义了客户端的宽度为600px,对于固定 600px 宽度的桌面应用,如何平衡“功能堆积”与“空间留白”是 UI 设计的核心。在 Wails 3 中,我们通过 application.WebviewWindowOptions 固定了窗口宽度为 600px。这是一个经典尺寸,但也给排版带来了挑战。

呼吸感来自于一致的间距比例。在固定宽度 UI 中,最忌讳使用绝对像素(px)去堆砌每一个角落。我们应该定义一套全局的 CSS 变量,例如:

  • --gap-sm: 8px;
  • --gap-md: 16px;

避免文字窒息,你在本项目中看到的“帮助指引”和“入门说明”,我们显式设置了 text-indent: 0。这是因为在 600px 宽度下,首行缩进会严重破坏左侧视觉基准线,导致每一行看上去都长短不一。保持左侧对齐,利用 line-height(行高)来提供纵向的呼吸空间,才是桌面端 UI 的上策。

响应式与动态计算,即使宽度固定,内部组件(如日志列表、配置表单)的宽度依然需要根据父容器动态调整。现代 CSS 不再需要依赖 JavaScript 来计算宽度,我们可以直接在样式表里写逻辑。

例如,让一个容器宽度保持在“父级总宽减去两侧边距”:
width: calc(100% - 40px);

视觉伪装:禁止缩放,为了保持“App 感”,我们通常会在 HTML 的 Meta 标签中禁止用户缩放,并使用 CSS 属性来防止文本被用户意外选中导致界面发蓝:
user-select: none;

系统托盘

在桌面应用中,系统托盘(System Tray)是区分“网页”与“真正的桌面软件”的关键标志,它赋予了应用“长驻后台”的能力。Wails 3 对系统托盘的支持比 v2 更加模块化。在本项目中,由于我们要维持 FRP 隧道的连接,不能让窗口一关就杀掉进程。

  1. 拦截关闭动作,在 main.go 的窗口配置中,有一个关键的回调函数 ShouldClose
    通常情况下,点击关闭按钮会销毁窗口。但在演示工具中,我们通过以下逻辑实现“隐遁”:
ShouldClose: func(window *application.WebviewWindow) bool {
    window.Hide() // 仅仅是隐藏
    return false  // 告诉系统:不要真的关闭我
}
  1. 彻底退出的逻辑,当应用“隐遁”后,用户只能通过系统托盘菜单来彻底退出。在托盘菜单的“彻底退出”回调中,我们需要手动调用 app.Quit()。注意,此时必须显式停止我们开启的本地 Demo 服务(52025 端口),否则可能导致端口残留。

  2. 托盘的视觉反馈,你可以为托盘设置图标(Icon)和菜单(Menu)。在 Wails 3 中,这可以通过简单的几行代码实现:

systemTray := app.NewSystemTray()
systemTray.SetMenu(myMenu)

时空隧道:FRP 隧道原理与子进程管理

我们开始硬核的技术深挖。我们将揭开内网穿透的神秘面纱,并探讨在 Wails 3 后端如何启动和管理外部二进制程序。要实现内网穿透,本质上是在一台拥有公网 IP 的服务器 (frps) 和你的本地机器 (frpc) 之间建立一条加密的 TCP 长连接隧道。

  1. FRP 的角色分配
  • 服务端 (frps): 运行在公网,负责接收外部请求并转发。
  • 客户端 (frpc): 运行在本地,负责接收服务端转发的流量并传给你的本地服务(如 9090 端口)。
  1. 在 Wails 3 中集成 frpc

在 Wails 3 开发中,很多底层功能(如 FRP、ffmpeg、docker)我们选择直接调用成熟的二进制程序。这种“胶水开发”的核心在于子进程管理。我们通常不建议重新写一遍 FRP 的逻辑,而是直接调用其编译好的二进制文件。在 Go 后端,我们可以使用标准库来“拉起”这个程序。

// 构建命令:./frpc -c ./frpc.toml
cmd := exec.Command("./frpc", "-c", "config.toml")
cmd.Start()

这里使用cmd.Run(),会阻塞主进程,直到外部程序退出。这在 UI 应用中不可接受。使用cmd.Start(),立即启动外部程序并继续执行后面的代码。这正是 Wails 应用需要的。

  1. 生命周期绑定

管理子进程最怕“孤儿进程”。当你的 Wails 应用崩溃或意外退出时,必须确保启动的 frpc 也能随之关闭。

解决方法: 在应用退出事件中显式调用进程终止命令。

func (s *MoleService) KillFRP() {
    if s.cmd != nil && s.cmd.Process != nil {
        // 强制终结子进程
        s.cmd.Process.Kill()
    }
}
  1. 实时日志捕获

为了让前端能看到 FRP 的运行状态,我们需要重定向子进程的 Stdout。通过管道(Pipe)读取数据,再利用 Wails 的 Events 实时推送到界面。

有了后台运行的子进程,如果前端是一片死寂,用户会感到不安。我们将攻克 Wails 3 的 Events(事件) 系统,实现日志的实时“流水线”。在桌面应用中,后端主动与前端沟通的唯一桥梁就是 事件系统 (Events)

当我们通过 os/exec 启动 FRP 后,可以使用 StdoutPipe 获取它的输出流。这就像是在子进程和主进程之间接了一根水管。

stdout, _ := cmd.StdoutPipe()
scanner := bufio.NewScanner(stdout)
go func() {
    for scanner.Scan() {
        line := scanner.Text()
        // 将这一行发送给前端
        s.pushLogToFrontend(line)
    }
}()

在 Wails 3 中,后端推送事件不再需要复杂的上下文,直接通过全局应用对象即可操作。
这种模式被称为 Pub/Sub (发布/订阅)。

// 后端推送
app.EmitEvent("frp-log", message)

前端通过 Events.On 建立监听。为了避免内存泄漏,当组件销毁或连接断开时,我们需要调用返回的取消监听函数。
在你的 main.js 中,这就是为什么我们写了 const unsubscribe = Events.On(...)

为了提升用户体验,日志列表通常需要“自动吸附底部”。
我们在使用 calc 和 JavaScript 的 scrollHeight 属性在这里配合使用,能确保最新的日志永远出现在用户视野内。

客户端集成小程序广告

纯粹的免费服务难以支撑高昂的服务器成本。通过“扫码验证 + 激励广告”构建闭环,是开发者与用户双赢的选择。

在我们的 Wails 3 应用中,HandleConnect 接口不再是简单的“开关”。

  • 后端检查: 收到连接请求,先查 Redis:该用户(OpenID)是否已获得当天的授权?
  • 触发验证: 若未授权,返回自定义状态码 1002,前端捕获后立即展示小程序码。

用户扫码进入小程序后,触发 激励视频广告 (Rewarded Video Ad)
这种模式的优势在于:用户通过贡献约 15-30 秒的注意力,换取后续数小时的免广告服务。

只有当小程序端确认广告播放完毕后,才会通知 Go 后端:

// 小程序端逻辑
videoAd.onClose((res) => {
  if (res && res.isEnded) {
    // 用户完整观看了广告
    wx.request({ url: "/api/v1/ad/success", data: { sid: this.data.sid } });
  }
});

后端收到请求,在 Redis 中将对应的 SessionID 标记为 authorized: true,此时桌面端的“连接”按钮才会真正生效。

在桌面端显示扫码弹窗时,应用应通过 WebSocket 实时监听状态。一旦扫码或广告完成,弹窗应自动消失并立即启动 FRP 隧道,这种“无缝感”是提升软件高级感的关键。

我们利用微信小程序作为“安全网关”,通过 OpenID 实现身份标识,并确保演示通道不被滥用。在本项目中,扫码不仅仅是为了展示广告,更核心的目的是建立安全审计追踪

当用户扫描小程序码并授权登录后,微信服务器会返回一个加密的字符串。对于同一个小程序,每个微信用户的这个字符串是固定且唯一的。

  • 作用: 它是我们在后台区分“张三”和“李四”的唯一凭证。
  • 隐私性: 它不同于微信号,不会泄露用户的真实联系方式,具有极高的安全性。

它的扫码验证流程如下:

  1. 触发: 前端调用 HandleConnect,后端检测到需要验证,返回 Code: 2
  2. 展示: 前端弹出带有参数(如 SessionID)的小程序码。
  3. 核验: 用户扫码,小程序将 OpenID 与该 SessionID 绑定。
  4. 通知: 后端验证通过,通过 app.EmitEvent 通知桌面端关闭弹窗并继续。

通过在 Go 后端将 OpenID 与当前 FRP 隧道绑定,我们可以实现:

  • 频率限制: 同一个用户每小时只能连接 3 次。
  • 内容追溯: 确保演示环境符合法律法规要求。

在我们的演示类软件中,安全性往往不仅取决于代码逻辑,更取决于对用户权限的限制。为了防止演示工具被用于非法用途(如搭建违规代理),我们需要在 UI 和后端实施“双重锁定”。作为开发者,我们不仅要实现功能,更要学会限制功能。

  1. 锁定配置。为什么要锁定配置?内网穿透技术若被恶意利用(如映射敏感端口),开发者可能面临封号甚至更严重的法律责任。通过将端口锁定为特定的演示端口(如 52025),我们可以确保:
  • 可控性: 流量只能流向我们预设的本地演示页面。
  • 不可篡改: 即使是资深用户,也无法通过 UI 更改映射目标。
  1. UI 层的“物理隔离”。在 HTML 中,我们通过两个关键动作锁定输入框:
  • 逻辑锁定: 使用核心属性让用户无法输入。
  • 视觉锁定: 通过 CSS 设置 cursor: not-allowed 和颜色变灰,给予用户明确的不可操作反馈。
<!-- 核心实现 -->
<input type="number" value="52025" ________ tabindex="-1" />
  1. 后端内核的“硬编码”。千万不要只在前端做限制。在 Go 后端逻辑中,我们应该直接使用常量定义端口,而不是读取前端传来的参数。即使黑客通过浏览器控制台绕过 UI 修改了值,后端的“硬核”逻辑依然会保持服务在安全范围内运行。

  2. 明确告知,不要让用户在猜测中点击。在配置页面增加“演示模式:配置已锁定”的显著提示,是一种专业的交互体现,能极大减少无效的技术支持请求。

为了让用户不看看到运行成功后一片空白。当内网穿透成功、本地服务启动后,用户看到的第一个页面就是落地页(Landing Page)。这不仅是成功的证明,更是推广“广告和资源”的黄金位置。在 Wails 3 中,我们不希望用户运行程序时还要带着一堆 .html 文件夹。我们需要实现“单文件即走”。

当用户看到“🎉 穿透成功”时,他们的多巴胺分泌会处于高峰期。此时是展示 Wails 3 实战开发笔记的最佳时机。

为了防止图片路径丢失或 HTML 文件被误删,我们使用 Go 的原生嵌入功能。这能将你的 index.html 变成二进制文件中的一段切片。

//go:embed index.html
var landingHTML string

func handler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html")
    fmt.Fprint(w, landingHTML)
}

由于落地页是在用户本地浏览器打开的,为了避免相对路径导致的加载失败,我们将小程序码等图片转换为 Base64 编码直接嵌入 HTML 标签中。
src=“data:image/png;base64,iVBORw…”

在编写落地页 CSS 时,尽量使用 内联样式 或 <style> 标签。这样可以保证无论用户的网络状况如何,页面渲染的视觉效果始终如一,不会出现样式缺失的问题。

落地页面完成后,我们需要提供服务来运行这个落地页。我们没有使用大型框架,仅用 Go 原生标准库搭建一个轻量级的“临时驿站”。在 Wails 3 项目中,我们有时需要在应用内部启动一个真正的 HTTP 服务。

为什么不用 Gin 或 Fiber?虽然第三方框架很强大,但对于“演示页面”这种极其简单的需求,Go 原生的 net/http 是最完美的选择。它无需额外依赖,且能让二进制文件保持极其轻量的体积。

我们可以把 ServeMux 想象成一个交通指挥官。它根据用户访问的 URL 路径(如 //status),将流量指引到不同的 Go 函数中。

// 创建一个私有的多路复用器
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    // 渲染你的演示成功 HTML
})

我们在客户端的配置页面锁定了本地52025 端口,现在我们将使用这个端口启动本地服务。通过 http.Server 结构体,我们可以更优雅地控制服务的开启与关闭,而不是直接使用简单的 ListenAndServe。

我们可以利用 Go 的 embed 特性,将 HTML、CSS 和图片直接打包进二进制文件中,实现真正的“单文件分发”。用户无需额外携带一个 index.html 即可看到精美的演示效果。

在开启本地服务端口时,虽然我们指定了52025端口,但是最让用户沮丧的就是点击连接后提示“端口已被占用”。所以我们需要编写一套自动化的“避障”逻辑。例如我们的演示服务默认监听 52025 端口,但如果用户的 Prometheus 或其他程序占用了它,服务就会直接崩溃。

判断一个端口是否可用的最稳妥方法,就是尝试去“占有”它,如果成功了再立刻释放。
在 Go 中,我们使用网络库的监听功能:

// 核心探测函数
func IsPortOpen(port int) bool {
    address := ":" + strconv.Itoa(port)
    ln, err := net.Listen("tcp", address)
    if err != nil {
        return false // 端口已被占用
    }
    ln.Close() // 探测完毕,立即释放
    return true
}

如果 52025 被占用了,我们不应该报错退出,而是应该尝试 52026、52027… 直到找到一个干净的端口。
这种“自动偏移”逻辑能极大提升软件的鲁棒性。

为什么不使用 8080?8080、8000、9090 是开发者的“重灾区”。在设计演示工具时,选择 49152 之后 的动态端口段(Dynamic Ports)是国际公认的最佳实践,这能规避 90% 以上的常见软件冲突。

资源清理

在桌面应用开发中,生命周期的完整性决定了软件的品质。我们应该让应用在退出时“体面”地释放所有资源。管理应用的“死法”和“活法”同样重要。

为什么不能直接关闭窗口?我们设置了托盘模式,点击窗口的 X 只是隐藏。因此,我们必须在托盘菜单中设计一个【彻底退出】按钮。

对于我们前面启动的 HTTP 服务,不能直接暴力杀掉进程,否则可能会导致 TCP 连接处于 TIME_WAIT 状态,长时间占用端口。

在 Go 中,我们应该调用 server.Shutdown()

// 优雅关闭演示服务
func (s *MoleService) StopLocalDemoServer() {
    if s.server != nil {
        s.server.Shutdown(context.Background())
    }
}

我们在前面启动的 FRP 子进程,必须在应用退出前被显式杀死。
利用 Wails 3 的事件监听,可以实现自动化清理:

app.OnEvent(events.Common.AppWillTerminate, func(event *application.CustomEvent) {
    frpCmd.Process.Kill() // 终结 FRP
})

当一切资源(端口、进程、临时文件)清理完毕后,调用 Wails 的退出指令,应用将安全地从系统进程列表中消失。

封装打包

我们已经完成了所有功能,是时候发布它了。在 Wails 3 中,打包(Build)不仅仅是编译,它是一次资源的重组与合规的审查。

静态资源与二进制嵌入,我们在前面使用了 go:embed。在打包阶段,Wails 3 会自动处理前端的编译(npm build)并将其嵌入 Go 代码。

  • 重点: 确保你的 frpc 二进制文件也被放入了正确的目录,并随应用一同分发。

在 Windows 下,一个没有图标、没有版本信息的 .exe 看起来就像是木马。我们需要定义图标和打包信息:

  • wails.json: 你需要在这里定义应用的图标路径、版本号以及是否需要管理员权限(UAC)。
  • 优化: 使用 UPX 压缩技术可以显著减小体积,让原本 50MB 的程序瘦身至 15MB 左右。

我们有条件更应该给应用签名与公证。即使你本地编译成功,发给别人时也会提示“无法验证开发者”。你需要使用开发证书进行签名,对于MacOS还需要上传至 Apple 官方服务器进行公证。

在跨平台发布的“坑”

  • 路径问题: 不同系统的资源路径斜杠不同(/ vs \),建议使用 path/filepath 处理。
  • 乱码问题: 在 Windows 终端输出时,确保字符集已处理为 UTF-8。

一个Wails应用基本介绍和应用就完毕了。感谢你的阅读!