feat(windows): 支持 MSI 主程序严格单实例启动

- 仅对带 MSI 安装标识的 Windows 主程序启用单实例门禁,Portable 与特殊运行模式保持原行为
- 重复启动通过命名事件唤醒首个窗口,并兼容 Wails 运行时尚未就绪的激活请求
- 新增并发抢占、窗口激活与安装模式测试,覆盖同一安装仅保留一个主实例
This commit is contained in:
Syngnat
2026-07-21 10:49:52 +08:00
parent b75fd37b05
commit 2597d087ad
6 changed files with 296 additions and 0 deletions

View File

@@ -31,6 +31,12 @@ func resolveCurrentUpdateInstallMode() updateInstallMode {
return resolveUpdateInstallModeForExecutable(runtime.GOOS, updateResolveInstallTarget())
}
// IsWindowsMSIInstallExecutable reports whether executablePath belongs to a
// GoNavi MSI installation. The marker is packaged next to the stable MSI exe.
func IsWindowsMSIInstallExecutable(goos string, executablePath string) bool {
return resolveUpdateInstallModeForExecutable(goos, executablePath) == updateInstallModeMSI
}
func resolveUpdateInstallModeForExecutable(goos string, executablePath string) updateInstallMode {
if !strings.EqualFold(strings.TrimSpace(goos), "windows") {
return updateInstallModeUnknown

67
main.go
View File

@@ -7,6 +7,7 @@ import (
"runtime"
"runtime/debug"
"strings"
"sync"
aiservice "GoNavi-Wails/internal/ai/service"
"GoNavi-Wails/internal/app"
@@ -26,6 +27,51 @@ import (
)
const nativeSelectCurrentLineEvent = "gonavi:native-select-current-line"
const windowsMSISingleInstanceID = "CDD6BF2F-ED1E-4345-A0AB-DCDB7E15FB23"
type primaryWindowActivator struct {
mu sync.Mutex
ctx context.Context
pending bool
show func(context.Context)
}
func (a *primaryWindowActivator) requestActivation() {
if a == nil {
return
}
a.mu.Lock()
ctx := a.ctx
if ctx == nil {
a.pending = true
a.mu.Unlock()
return
}
show := a.show
a.mu.Unlock()
if show != nil {
show(ctx)
}
}
func (a *primaryWindowActivator) bindRuntimeContext(ctx context.Context) {
if a == nil || ctx == nil {
return
}
a.mu.Lock()
a.ctx = ctx
activatePending := a.pending
a.pending = false
show := a.show
a.mu.Unlock()
if activatePending && show != nil {
show(ctx)
}
}
func shouldEnableWindowsMSISingleInstance(goos string, executablePath string) bool {
return app.IsWindowsMSIInstallExecutable(goos, executablePath)
}
func main() {
// 大结果集导出88W+ 行JSON 编解码会产生 5-8 倍内存副本,
@@ -34,9 +80,29 @@ func main() {
// 代价是 CPU 开销略增,但导出/导入场景属 I/O 密集型GC 开销可忽略。
debug.SetGCPercent(50)
executablePath, executableErr := os.Executable()
if runSpecialMode(os.Args[1:]) {
return
}
primaryActivator := &primaryWindowActivator{show: wailsRuntime.WindowShow}
if executableErr != nil {
logger.Warnf("检测 MSI 单实例模式失败:%v", executableErr)
} else if shouldEnableWindowsMSISingleInstance(runtime.GOOS, executablePath) {
releaseSingleInstance, isPrimary, err := acquireWindowsMSISingleInstance(
windowsMSISingleInstanceID,
primaryActivator.requestActivation,
)
if err != nil {
logger.Errorf("启用 MSI 单实例模式失败:%v", err)
return
}
if !isPrimary {
return
}
if releaseSingleInstance != nil {
defer releaseSingleInstance()
}
}
// Create an instance of the app structure
application := app.NewApp()
@@ -87,6 +153,7 @@ func main() {
Menu: appMenu,
OnStartup: func(ctx context.Context) {
runtimeCtx = ctx
primaryActivator.bindRuntimeContext(ctx)
lifecycleCtx := ctx
if nativeWindowManager != nil {
if err := nativewindow.InitializeLifecycle(nativeWindowManager, ctx); err != nil {

View File

@@ -1,6 +1,9 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/wailsapp/wails/v2/pkg/menu"
@@ -9,6 +12,49 @@ import (
"github.com/wailsapp/wails/v2/pkg/options/windows"
)
func TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI(t *testing.T) {
installDir := t.TempDir()
executablePath := filepath.Join(installDir, "GoNavi.exe")
if shouldEnableWindowsMSISingleInstance("windows", executablePath) {
t.Fatal("Portable executable unexpectedly enabled single-instance mode")
}
if err := os.WriteFile(filepath.Join(installDir, ".gonavi-msi-install"), []byte("MSI"), 0o644); err != nil {
t.Fatalf("WriteFile MSI marker: %v", err)
}
if !shouldEnableWindowsMSISingleInstance("windows", executablePath) {
t.Fatal("MSI executable did not enable single-instance lock")
}
if shouldEnableWindowsMSISingleInstance("darwin", executablePath) {
t.Fatal("non-Windows executable unexpectedly enabled single-instance mode")
}
}
func TestPrimaryWindowActivatorQueuesRequestsUntilRuntimeStartup(t *testing.T) {
type activationContextKey struct{}
ctx := context.WithValue(context.Background(), activationContextKey{}, "ready")
activatedWith := make([]context.Context, 0, 2)
activator := primaryWindowActivator{
show: func(runtimeCtx context.Context) {
activatedWith = append(activatedWith, runtimeCtx)
},
}
activator.requestActivation()
if len(activatedWith) != 0 {
t.Fatalf("activation ran before startup context was available: %d", len(activatedWith))
}
activator.bindRuntimeContext(ctx)
if len(activatedWith) != 1 || activatedWith[0] != ctx {
t.Fatalf("queued activation contexts = %#v, want startup context", activatedWith)
}
activator.requestActivation()
if len(activatedWith) != 2 || activatedWith[1] != ctx {
t.Fatalf("live activation contexts = %#v, want startup context twice", activatedWith)
}
}
func TestIsLowMemoryMode(t *testing.T) {
tests := []struct {
name string

View File

@@ -0,0 +1,7 @@
//go:build !windows
package main
func acquireWindowsMSISingleInstance(_ string, _ func()) (func(), bool, error) {
return nil, true, nil
}

View File

@@ -0,0 +1,75 @@
//go:build windows
package main
import (
"errors"
"fmt"
"sync"
"golang.org/x/sys/windows"
)
// The named auto-reset event is both the lifetime lease and the activation
// mailbox. A signal remains pending while the Wails window is still starting.
func acquireWindowsMSISingleInstance(uniqueID string, onSecondInstance func()) (func(), bool, error) {
eventName, err := windows.UTF16PtrFromString(`Local\GoNavi-` + uniqueID + `-activate`)
if err != nil {
return nil, false, fmt.Errorf("build single-instance event name: %w", err)
}
activationEvent, eventErr := windows.CreateEvent(nil, 0, 0, eventName)
if errors.Is(eventErr, windows.ERROR_ALREADY_EXISTS) {
signalErr := windows.SetEvent(activationEvent)
windows.CloseHandle(activationEvent)
if signalErr != nil {
return nil, false, fmt.Errorf("activate primary GoNavi window: %w", signalErr)
}
return nil, false, nil
}
if eventErr != nil {
return nil, false, fmt.Errorf("create single-instance activation event: %w", eventErr)
}
stopEvent, err := windows.CreateEvent(nil, 1, 0, nil)
if err != nil {
windows.CloseHandle(activationEvent)
return nil, false, fmt.Errorf("create single-instance stop event: %w", err)
}
done := make(chan struct{})
go func() {
defer close(done)
for {
event, waitErr := windows.WaitForMultipleObjects(
[]windows.Handle{stopEvent, activationEvent},
false,
windows.INFINITE,
)
if waitErr != nil {
return
}
switch event {
case windows.WAIT_OBJECT_0:
return
case windows.WAIT_OBJECT_0 + 1:
if onSecondInstance != nil {
onSecondInstance()
}
default:
return
}
}
}()
var releaseOnce sync.Once
release := func() {
releaseOnce.Do(func() {
_ = windows.SetEvent(stopEvent)
<-done
windows.CloseHandle(stopEvent)
windows.CloseHandle(activationEvent)
})
}
return release, true, nil
}

View File

@@ -0,0 +1,95 @@
//go:build windows
package main
import (
"fmt"
"os"
"sync"
"testing"
"time"
)
func TestAcquireWindowsMSISingleInstanceRejectsAndSignalsSecondLaunch(t *testing.T) {
uniqueID := fmt.Sprintf("test-%d-%d", os.Getpid(), time.Now().UnixNano())
activated := make(chan struct{}, 1)
releasePrimary, isPrimary, err := acquireWindowsMSISingleInstance(uniqueID, func() {
activated <- struct{}{}
})
if err != nil {
t.Fatalf("acquire primary single-instance lock: %v", err)
}
if !isPrimary || releasePrimary == nil {
t.Fatalf("first acquisition = primary %v release %v, want primary with release", isPrimary, releasePrimary != nil)
}
t.Cleanup(releasePrimary)
releaseSecond, isPrimary, err := acquireWindowsMSISingleInstance(uniqueID, nil)
if err != nil {
t.Fatalf("acquire secondary single-instance lock: %v", err)
}
if isPrimary || releaseSecond != nil {
t.Fatalf("second acquisition = primary %v release %v, want rejected secondary", isPrimary, releaseSecond != nil)
}
select {
case <-activated:
case <-time.After(2 * time.Second):
t.Fatal("secondary acquisition did not activate the primary instance")
}
releasePrimary()
releaseReplacement, isPrimary, err := acquireWindowsMSISingleInstance(uniqueID, nil)
if err != nil {
t.Fatalf("reacquire single-instance lock after release: %v", err)
}
if !isPrimary || releaseReplacement == nil {
t.Fatalf("replacement acquisition = primary %v release %v, want new primary", isPrimary, releaseReplacement != nil)
}
releaseReplacement()
}
func TestAcquireWindowsMSISingleInstanceAllowsOnlyOneConcurrentPrimary(t *testing.T) {
uniqueID := fmt.Sprintf("concurrent-test-%d-%d", os.Getpid(), time.Now().UnixNano())
const launchCount = 32
type claimResult struct {
release func()
isPrimary bool
err error
}
start := make(chan struct{})
results := make(chan claimResult, launchCount)
var claims sync.WaitGroup
for range launchCount {
claims.Add(1)
go func() {
defer claims.Done()
<-start
release, isPrimary, err := acquireWindowsMSISingleInstance(uniqueID, nil)
results <- claimResult{release: release, isPrimary: isPrimary, err: err}
}()
}
close(start)
claims.Wait()
close(results)
primaryCount := 0
var releasePrimary func()
for result := range results {
if result.err != nil {
t.Fatalf("concurrent single-instance claim failed: %v", result.err)
}
if result.isPrimary {
primaryCount++
releasePrimary = result.release
} else if result.release != nil {
t.Fatal("secondary concurrent claim unexpectedly returned a release function")
}
}
if primaryCount != 1 || releasePrimary == nil {
t.Fatalf("concurrent primary count = %d release %v, want exactly one primary", primaryCount, releasePrimary != nil)
}
releasePrimary()
}