为什么选择了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 时,尽量使用 内联样式 或