首页 / Wails 入门教程 / 自定义协议 Custom Protocol

Wails 入门教程

自定义协议 Custom Protocol

本教程共 42 篇 · 第 29 篇 · 更新于 2026-08-03

Wails桌面开发深链Deep Link自定义协议

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 起名要够独特。apptooleditor 这类通用词很可能和别的软件撞车,撞了之后谁后装谁赢,行为不可预期。用产品名或者加个前缀,比如 mashangxue-notes

Warning

不要注册 httphttpsfilemailto 这些标准协议。抢走系统级协议的后果是用户所有网页链接都跑你这儿来,几乎必然被投诉。

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://openmyapp:///open 混用。 两者解析出来的 Host 和 Path 完全不同,会导致分发逻辑时灵时不灵。统一一种格式。

误区五:开发阶段没法调试。 不用每次都打包。wails dev -appargs "myapp://note/open?id=123" 可以直接把参数喂给应用,解析和分发逻辑先在 dev 模式跑通。但要分清这只是「把字符串塞进来」,不等于真的走了系统协议唤起——社区反馈过 dev 模式下真实唤起时灵时不灵(issue #3362),协议注册这一环最后一定要用正式构建的产物验一遍。

小结

自定义协议和文件关联是一对孪生兄弟:配置都在 wails.jsoninfo 段,macOS 走回调(OnUrlOpen 对应 OnFileOpen),Windows 和 Linux 都从命令行参数拿,都需要 NSIS 或手工打包,也都强烈建议配合单实例锁。

区别在于深链的输入完全来自外部,安全要求高一个量级。白名单分发、参数校验、敏感操作二次确认,这三条不是可选项。

下一章就来把反复提到的单实例锁讲清楚。