首页 / Wails 入门教程 / 单实例锁 Single Instance

Wails 入门教程

单实例锁 Single Instance

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

Wails桌面开发单实例SingleInstanceLockOptions

30. 单实例锁 Single Instance

本节目标

  • 说清什么样的应用需要单实例、什么样的不需要
  • 会配置 options.SingleInstanceLock 并生成合适的 UniqueId
  • 看懂 SecondInstanceData 里的 ArgsWorkingDirectory
  • 会在收到第二实例通知时把窗口正确地拉到前台
  • 了解三个平台各自的实现机制和随之而来的限制

30-1 为什么要限制多开

先说清楚:多开本身不是错。终端模拟器、文本编辑器多开几个窗口很正常。

真正需要单实例的是这几类情况。

独占资源。 应用要写一个本地数据库文件、监听一个固定端口、占用某个硬件设备。两个进程同时抢,轻则报错,重则数据损坏。

文件关联和深链。 这是最主要的场景。用户在资源管理器里选中五个文件一起打开,系统会启动五个进程。用户在网页上连点三次深链,桌面上冒出三个窗口。都不是预期行为。

常驻托盘类应用。 用户忘了它已经在后台跑着,又从开始菜单点了一次,结果托盘上出现两个图标。

Wails 把这套机制封装成了 SingleInstanceLock,配置在应用选项里。

30-2 最小配置

wails.Runoptions.App 里加一个 SingleInstanceLock 字段:

package main

import (
	"github.com/wailsapp/wails/v2"
	"github.com/wailsapp/wails/v2/pkg/options"
	"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)

func main() {
	app := NewApp()

	err := wails.Run(&options.App{
		Title:            "wails-open-file",
		Width:            1024,
		Height:           768,
		AssetServer:      &assetserver.Options{Assets: assets},
		BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
		OnStartup:        app.startup,
		SingleInstanceLock: &options.SingleInstanceLock{
			UniqueId:               "e3984e08-28dc-4e3d-b70a-45e961589cdc",
			OnSecondInstanceLaunch: app.onSecondInstanceLaunch,
		},
		Bind: []interface{}{
			app,
		},
	})
	if err != nil {
		println("Error:", err.Error())
	}
}

两个字段:

  • UniqueId:应用的唯一标识。Windows 和 macOS 拿它生成命名互斥体的名字,Linux 拿它生成 dbus 名称。
  • OnSecondInstanceLaunch:第二个实例启动时触发的回调。

UniqueId 建议直接用 UUID,随便找个工具生成一个然后写死在代码里。

Warning

UniqueId 千万别在不同项目之间复制粘贴。两个应用用了同一个 ID,它们会互相把对方当成「自己的第二个实例」,结果就是 A 应用开着的时候 B 应用启动不了。这种 bug 排查起来非常费劲。

配好之后行为就变了:第二次启动应用时,新进程发现锁已被占用,会把自己的命令行参数发给第一个实例,然后立刻退出。用户看到的效果是「点了没反应」——所以回调里必须做点什么,至少把窗口亮出来。

30-3 处理第二实例的数据

回调签名是这样:

func (a *App) onSecondInstanceLaunch(secondInstanceData options.SecondInstanceData)

SecondInstanceData 里有两样东西:

字段说明
Args第二个实例收到的命令行参数
WorkingDirectory第二个实例启动时所在的工作目录

WorkingDirectory 别忽略。命令行里用户可能传的是相对路径,比如 myapp ./notes/a.md。第一个实例的工作目录和第二个可能完全不同,不用这个字段去拼绝对路径,你会找不到文件。

官方给出的完整示例是这样:

var wailsContext *context.Context

// NewApp creates a new App application struct
func NewApp() *App {
	return &App{}
}

// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
	wailsContext = &ctx
}

func (a *App) onSecondInstanceLaunch(secondInstanceData options.SecondInstanceData) {
	secondInstanceArgs = secondInstanceData.Args

	println("user opened second instance", strings.Join(secondInstanceData.Args, ","))
	println("user opened second from", secondInstanceData.WorkingDirectory)
	runtime.WindowUnminimise(*wailsContext)
	runtime.Show(*wailsContext)
	go runtime.EventsEmit(*wailsContext, "launchArgs", secondInstanceArgs)
}

有两个地方值得单独说。

第一,窗口不会自动弹出来。 回调触发不代表窗口会获得焦点。窗口可能被最小化了,可能藏在别的应用后面。所以要手动调 runtime.WindowUnminimise 恢复最小化,再调 runtime.Show 显示窗口。

第二,emit 前面加了 go 这是为了不阻塞回调。事件投递如果因为某些原因卡住,回调迟迟不返回会影响后续处理。

把它整理成更贴近实际项目的写法:

package main

import (
	"context"
	"path/filepath"
	"strings"

	"github.com/wailsapp/wails/v2/pkg/options"
	"github.com/wailsapp/wails/v2/pkg/runtime"
)

type App struct {
	ctx context.Context
}

func (a *App) startup(ctx context.Context) {
	a.ctx = ctx
}

func (a *App) onSecondInstanceLaunch(data options.SecondInstanceData) {
	// 把窗口拉到用户面前
	runtime.WindowUnminimise(a.ctx)
	runtime.Show(a.ctx)

	// 相对路径要基于第二实例的工作目录解析
	var files []string
	for _, arg := range data.Args {
		if strings.HasPrefix(arg, "-") {
			continue // 跳过命令行开关
		}
		p := arg
		if !filepath.IsAbs(p) {
			p = filepath.Join(data.WorkingDirectory, p)
		}
		files = append(files, filepath.Clean(p))
	}

	if len(files) > 0 {
		go runtime.EventsEmit(a.ctx, "launchArgs", files)
	}
}

前端接收:

import { useEffect } from "react";

export function useLaunchArgs(onFiles: (files: string[]) => void) {
  useEffect(() => {
    const off = window.runtime.EventsOn("launchArgs", (files: string[]) => {
      onFiles(files);
    });
    return () => off();
  }, [onFiles]);
}
Note

Linux 上部分窗口管理器为了防止应用抢焦点,会拒绝把窗口强行提到最前。这不是 Wails 的 bug,是桌面环境的策略。表现通常是任务栏图标闪烁提示,用户点一下才切过去。

30-4 三个平台的实现机制

了解底层实现能帮你判断「这个坑该找谁」。

Windows:用命名互斥体(named mutex)实现锁,名字由 UniqueId 生成。数据通过一个共享窗口,用 SendMessage 传给第一个实例。

macOS:同样用命名互斥体做锁。数据走 NSDistributedNotificationCenter,也就是系统的分布式通知中心。

Linux:用 dbus 实现。dbus 名称由 UniqueId 生成,数据也通过 dbus 传递。

由此能推出几条实际结论。

Linux 环境如果没有跑 dbus(某些精简的容器、纯 TTY 环境),单实例锁可能不工作。这在服务器上不常见——桌面应用本来也不该跑在那儿——但用 Docker 做 CI 测试时可能撞上。

Windows 的命名互斥体是有作用域的。不同用户会话之间默认互不干扰,也就是说 A 用户和 B 用户可以各开一个实例。这通常正是你想要的。

数据传递通道都是系统级的进程间通信,容量不是无限的。别指望通过 Args 传几兆的内容过去,它设计出来就是传几个路径的。

30-5 安全:第二实例的数据不可信

官方文档专门强调了这一点:单实例锁没有实现任何安全通信协议

这意味着什么?意味着理论上任何本地进程都可以模仿「第二个实例」,往你的应用里塞数据。你的回调收到的 ArgsWorkingDirectory,都要当成来路不明的输入对待。

具体该怎么防:

校验路径。filepath.Clean 规整,检查有没有 .. 越界,确认文件确实存在、扩展名是你支持的。

校验参数格式。 深链的 URL 要走第 29 章讲的白名单分发,不要因为「它是从自己的第二实例来的」就放松检查。

限制数量。 用户一次拖十个文件正常,一次来一万个就不正常了。加个上限,防止界面被撑爆。

const maxLaunchFiles = 50

func (a *App) onSecondInstanceLaunch(data options.SecondInstanceData) {
	runtime.WindowUnminimise(a.ctx)
	runtime.Show(a.ctx)

	files := sanitizeArgs(data.Args, data.WorkingDirectory)
	if len(files) > maxLaunchFiles {
		files = files[:maxLaunchFiles]
	}
	if len(files) > 0 {
		go runtime.EventsEmit(a.ctx, "launchArgs", files)
	}
}

30-6 和文件关联、深链配合

前两章反复提到单实例锁,现在可以把三者串起来看。

不开单实例锁时的链路:

用户双击文件 → 系统启动新进程 → 新进程从 os.Args 读路径 → 新窗口

开了单实例锁之后:

用户双击文件 → 系统启动新进程 → 新进程发现锁被占
              → 参数发给第一个实例 → 新进程退出
              → 第一个实例 OnSecondInstanceLaunch 触发 → 已有窗口置前并加载文件

所以完整的启动参数处理要分两条路:

func (a *App) startup(ctx context.Context) {
	a.ctx = ctx

	// 路径一:本进程就是第一个实例,参数在自己的 os.Args 里
	if args := os.Args[1:]; len(args) > 0 {
		wd, _ := os.Getwd()
		a.enqueueLaunchArgs(sanitizeArgs(args, wd))
	}
}

// 路径二:后来的实例把参数转交过来
func (a *App) onSecondInstanceLaunch(data options.SecondInstanceData) {
	runtime.WindowUnminimise(a.ctx)
	runtime.Show(a.ctx)
	a.enqueueLaunchArgs(sanitizeArgs(data.Args, data.WorkingDirectory))
}

enqueueLaunchArgs 就是第 28 章讲的那个待处理队列:前端没准备好就先存着,准备好了立刻发。两条路径复用同一份逻辑,不容易出岔子。

macOS 上还要加第三条路径——Mac.OnFileOpenMac.OnUrlOpen 回调。它们也调 enqueueLaunchArgs,逻辑依然共用。

常见误区

误区一:wails dev 下测不出效果。 dev 模式的进程管理和正式运行不一样,测单实例最好用 wails build 出来的产物。

误区二:忘了在回调里显示窗口。 用户双击文件,什么都没发生,会以为程序坏了。WindowUnminimiseShow 是标配。

误区三:以为回调里能直接操作前端 DOM。 不能。Go 侧只能通过事件通知前端,界面变更由 React 自己完成。

误区四:在回调里做耗时操作。 读大文件、发网络请求这些丢到 goroutine 里去,别卡住回调。

误区五:把 UniqueId 写成应用名。 「MyApp」这种字符串重名概率不低。老老实实用 UUID。

小结

单实例锁的配置只有两个字段,但它牵着一整条链路:文件关联、深链、托盘常驻,都要靠它来避免多开。

落地时抓住三个动作。生成一个专属的 UUID,别和任何项目重复。回调里先把窗口显示出来,别让用户以为点了没反应。把收到的参数当成外部输入来校验,路径要规整、格式要检查、数量要限制。

再加一条工程建议:os.ArgsOnSecondInstanceLaunch 两条路径共用同一个处理函数,配合待处理队列,跨平台的启动参数逻辑就能收敛成一份代码。

下一章讲本地开发联调,把 wails dev 这条命令背后的东西拆开看。