自定义协议 Custom Protocol
本教程共 42 篇 · 第 29 篇 · 更新于 2026-08-03
29. 自定义协议 Custom Protocol
本节目标
- 理解自定义协议(深链)能解决什么问题
- 会在
wails.json里配置protocols - 掌握 Windows / macOS / Linux 三种接收唤起参数的方式
- 会用
net/url解析深链并做路由分发 - 知道深链的安全边界,能写出不被滥用的处理逻辑
29-1 深链是什么
浏览器地址栏里输入 https://wails.io 会打开网页,输入 mailto:me@example.com 会唤起邮件客户端。后者就是自定义协议在起作用——操作系统知道 mailto: 该交给谁处理。
你也可以注册自己的协议,比如 myapp://。注册之后,网页上一个链接:
<a href="myapp://open?id=123">在桌面端打开</a>
用户点一下,系统就会把你的应用拉起来,并把整条 URL 交给它。这种「从外部一键跳进应用内某个具体位置」的能力,通常叫深链(Deep Link)。
典型用途有这么几类:
- 网页端引流到桌面端。用户在官网看到某条内容,点击直接在客户端打开。
- OAuth 登录回调。桌面应用发起第三方授权,浏览器完成登录后回调
myapp://auth?code=xxx,把授权码送回来。 - 邮件、IM 里的跳转链接。团队协作类工具很常用。
配置入口和上一章一样,还是 wails.json。
29-2 在 wails.json 里声明协议
在 info 段里加 protocols 数组:
{
"info": {
"protocols": [
{
"scheme": "myapp",
"description": "My App Protocol",
"role": "Editor"
}
]
}
}
三个字段:
| 字段 | 说明 |
|---|---|
scheme | 协议名,不带 ://。写 myapp,对应的链接就是 myapp://... |
description | 仅 Windows 生效,协议描述 |
role | 仅 macOS 生效,应用相对该类型的角色,对应 CFBundleTypeRole |
Tip
scheme起名要够独特。app、tool、editor这类通用词很可能和别的软件撞车,撞了之后谁后装谁赢,行为不可预期。用产品名或者加个前缀,比如mashangxue-notes。
Warning不要注册
http、https、file、mailto这些标准协议。抢走系统级协议的后果是用户所有网页链接都跑你这儿来,几乎必然被投诉。
29-3 macOS:OnUrlOpen 回调
和文件关联对称,macOS 的深链走的是回调,参数在 mac.Options 里:
package main
import (
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/mac"
)
func main() {
app := NewApp()
err := wails.Run(&options.App{
Title: "wails-open-url",
Width: 1024,
Height: 768,
AssetServer: &assetserver.Options{Assets: assets},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
Mac: &mac.Options{
OnUrlOpen: func(url string) {
app.handleDeepLink(url)
},
},
Bind: []interface{}{
app,
},
})
if err != nil {
println("Error:", err.Error())
}
}
回调参数是完整的 URL 字符串,比如 myapp://open?id=123。
应用没启动时点链接,系统会先启动应用再回调;应用已经在跑时点链接,直接回调。两种情况都走同一个入口,写起来比 Windows 省心。
如果还想支持 Universal Links(也就是用 https:// 开头的链接直接唤起 App),那是苹果的另一套机制,需要在 Info.plist 里加:
<key>NSUserActivityTypes</key>
<array>
<string>NSUserActivityTypeBrowsingWeb</string>
</array>
在 entitlements.plist 里加:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:myawesomeapp.com</string>
</array>
还要在自己的网站根目录放 apple-app-site-association 文件。Wails 这边是 v2.11.0 才加上 macOS Universal Links 支持的,官方文档把它和 OnUrlOpen 写在同一节里,也就是说链接进来之后仍旧交给这个回调处理,你的分发逻辑不用另写一套。剩下的开发者账号申请、域名验证那一套属于苹果生态的专门话题,本教程点到为止。
29-4 Windows:NSIS + 命令行参数
Windows 上协议注册同样是写注册表,同样只有 NSIS 安装包支持:
wails build -nsis
用户点击 myapp:// 链接时,系统启动一个新的应用进程,把 URL 当作命令行参数传进去:
package main
import "os"
func main() {
argsWithoutProg := os.Args[1:]
if len(argsWithoutProg) != 0 {
println("launchArgs", argsWithoutProg)
}
// ... wails.Run(...)
}
「每次都起新进程」在深链场景下问题更突出。用户在网页上连点三下,桌面上蹦出三个窗口,这显然不对。所以深链几乎总是要配合单实例锁:
err := wails.Run(&options.App{
Title: "wails-open-url",
Width: 1024,
Height: 768,
AssetServer: &assetserver.Options{Assets: assets},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
SingleInstanceLock: &options.SingleInstanceLock{
UniqueId: "e3984e08-28dc-4e3d-b70a-45e961589cdc",
OnSecondInstanceLaunch: app.onSecondInstanceLaunch,
},
Bind: []interface{}{
app,
},
})
打开单实例锁之后,第二个进程不会真的起来,它会把参数交给已在运行的实例,然后自己退出。第 30 章会把这套机制讲透。
29-5 Linux:desktop 文件里的 scheme handler
Linux 还是手工打包那一套。关键在 .desktop 文件的 MimeType 字段,写法是 x-scheme-handler/协议名:
[Desktop Entry]
Categories=Office
Exec=/usr/bin/wails-open-url %u
Icon=wails-open-url.png
Name=wails-open-url
Terminal=false
Type=Application
MimeType=x-scheme-handler/myapp;
Exec 里的 %u 依然是必需的,它负责把 URL 传给程序。
安装后脚本只需要刷新桌面数据库:
# 刷新桌面数据库,让协议处理程序注册生效
update-desktop-database /usr/share/applications
nfpm 配置和上一章类似,把二进制、.desktop、图标放到位:
name: "wails-open-url"
arch: "arm64"
platform: "linux"
version: "1.0.0"
maintainer: "FooBarCorp <FooBarCorp@gmail.com>"
description: "Sample Package"
license: "MIT"
contents:
- src: ../bin/wails-open-url
dst: /usr/bin/wails-open-url
- src: ./main.desktop
dst: /usr/share/applications/wails-open-url.desktop
- src: ../appicon.svg
dst: /usr/share/icons/hicolor/scalable/apps/wails-open-url.svg
scripts:
postinstall: ./postInstall.sh
postremove: ./postRemove.sh
打包命令:
nfpm pkg --packager deb --target .
接收方式和 Windows 一致,从 os.Args[1:] 里取。
29-6 解析 URL 并分发
拿到 myapp://note/open?id=123&mode=edit 这样一个字符串,接下来要把它变成应用内的一次跳转。Go 标准库的 net/url 够用了。
package main
import (
"fmt"
"net/url"
"strings"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
type DeepLink struct {
Action string `json:"action"`
Params map[string]string `json:"params"`
}
const allowedScheme = "myapp"
func parseDeepLink(raw string) (*DeepLink, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("链接格式错误: %w", err)
}
if u.Scheme != allowedScheme {
return nil, fmt.Errorf("协议不匹配: %s", u.Scheme)
}
// myapp://note/open → Host = "note", Path = "/open"
action := u.Host + strings.TrimSuffix(u.Path, "/")
params := make(map[string]string)
for k, v := range u.Query() {
if len(v) > 0 {
params[k] = v[0]
}
}
return &DeepLink{Action: action, Params: params}, nil
}
有一个细节容易搞混:myapp://note/open 里,note 会被解析成 Host,/open 才是 Path。想让整段路径都落在 Path 里,链接得写成 myapp:///note/open(三个斜杠)。定协议格式的时候先想清楚,别两种混着用。
分发逻辑写成白名单式的 switch,来路不明的 action 一律忽略:
func (a *App) handleDeepLink(raw string) {
link, err := parseDeepLink(raw)
if err != nil {
runtime.LogWarningf(a.ctx, "忽略无效深链: %v", err)
return
}
switch link.Action {
case "note/open":
id := link.Params["id"]
if !isValidNoteID(id) {
return
}
runtime.EventsEmit(a.ctx, "deeplink:open-note", id)
case "auth/callback":
a.finishOAuth(link.Params["code"], link.Params["state"])
default:
runtime.LogWarningf(a.ctx, "未知深链动作: %s", link.Action)
}
}
前端接收和处理:
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { EventsOn } from "../wailsjs/runtime/runtime";
export function useDeepLink() {
const navigate = useNavigate();
useEffect(() => {
const off = EventsOn("deeplink:open-note", (id: string) => {
navigate(`/notes/${id}`);
});
return () => off();
}, [navigate]);
}
Note和上一章一样,启动瞬间前端可能还没挂载。深链参数同样需要一个「待处理队列」,等前端主动报到之后再补发。做法参考 28-6。
29-7 安全:把深链当成外部输入
深链是敞开的入口。任何网页、任何邮件、任何聊天消息都能构造一条 myapp:// 链接让用户点。所以处理逻辑必须按「不可信输入」的标准来写。
几条底线:
动作白名单。 只处理你显式列出的 action,其余全部丢弃。千万别设计成「参数里传什么方法名就调什么方法」。
参数校验。 ID 检查格式,路径检查是否越界,数字检查范围。别把参数直接拼进 SQL、命令行或者文件路径。
敏感操作要二次确认。 「删除项目」「导出全部数据」这类动作,即使深链里带了参数,也应该弹窗让用户确认。
OAuth 的 state 必须校验。 发起授权时生成随机 state,回调时比对。对不上就丢弃,这是防 CSRF 的标准做法。
Warning见过最危险的设计是
myapp://exec?cmd=...,把参数当命令执行。这等于给任意网页一个在用户电脑上执行命令的口子。任何情况下都不要这么做。
常见误区
误区一:scheme 里带了 ://。 配置里只写 myapp,系统会自己补协议分隔符。
误区二:Windows 上不打 NSIS 包就测。 注册表没写入,链接点了没反应,然后开始怀疑代码。先确认构建方式。
误区三:以为 macOS 也能从 os.Args 拿到 URL。 拿不到,macOS 走的是 Apple Event,必须用 OnUrlOpen。
误区四:myapp://open 和 myapp:///open 混用。 两者解析出来的 Host 和 Path 完全不同,会导致分发逻辑时灵时不灵。统一一种格式。
误区五:开发阶段没法调试。 不用每次都打包。wails dev -appargs "myapp://note/open?id=123" 可以直接把参数喂给应用,解析和分发逻辑先在 dev 模式跑通。但要分清这只是「把字符串塞进来」,不等于真的走了系统协议唤起——社区反馈过 dev 模式下真实唤起时灵时不灵(issue #3362),协议注册这一环最后一定要用正式构建的产物验一遍。
小结
自定义协议和文件关联是一对孪生兄弟:配置都在 wails.json 的 info 段,macOS 走回调(OnUrlOpen 对应 OnFileOpen),Windows 和 Linux 都从命令行参数拿,都需要 NSIS 或手工打包,也都强烈建议配合单实例锁。
区别在于深链的输入完全来自外部,安全要求高一个量级。白名单分发、参数校验、敏感操作二次确认,这三条不是可选项。
下一章就来把反复提到的单实例锁讲清楚。