From 2597d087ad96586c4846c6fb7cbba64dfb882ea3 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 21 Jul 2026 10:49:52 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(windows):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20MSI=20=E4=B8=BB=E7=A8=8B=E5=BA=8F=E4=B8=A5=E6=A0=BC=E5=8D=95?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 仅对带 MSI 安装标识的 Windows 主程序启用单实例门禁,Portable 与特殊运行模式保持原行为 - 重复启动通过命名事件唤醒首个窗口,并兼容 Wails 运行时尚未就绪的激活请求 - 新增并发抢占、窗口激活与安装模式测试,覆盖同一安装仅保留一个主实例 --- internal/app/update_install_mode.go | 6 ++ main.go | 67 +++++++++++++++++ main_test.go | 46 ++++++++++++ windows_single_instance_stub.go | 7 ++ windows_single_instance_windows.go | 75 +++++++++++++++++++ windows_single_instance_windows_test.go | 95 +++++++++++++++++++++++++ 6 files changed, 296 insertions(+) create mode 100644 windows_single_instance_stub.go create mode 100644 windows_single_instance_windows.go create mode 100644 windows_single_instance_windows_test.go diff --git a/internal/app/update_install_mode.go b/internal/app/update_install_mode.go index 172982c7..6f4d0bdc 100644 --- a/internal/app/update_install_mode.go +++ b/internal/app/update_install_mode.go @@ -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 diff --git a/main.go b/main.go index 04de5ceb..ee342994 100644 --- a/main.go +++ b/main.go @@ -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 { diff --git a/main_test.go b/main_test.go index ebcec843..b9be1538 100644 --- a/main_test.go +++ b/main_test.go @@ -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 diff --git a/windows_single_instance_stub.go b/windows_single_instance_stub.go new file mode 100644 index 00000000..ab8d5cdd --- /dev/null +++ b/windows_single_instance_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +func acquireWindowsMSISingleInstance(_ string, _ func()) (func(), bool, error) { + return nil, true, nil +} diff --git a/windows_single_instance_windows.go b/windows_single_instance_windows.go new file mode 100644 index 00000000..f5098e46 --- /dev/null +++ b/windows_single_instance_windows.go @@ -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 +} diff --git a/windows_single_instance_windows_test.go b/windows_single_instance_windows_test.go new file mode 100644 index 00000000..300bc09d --- /dev/null +++ b/windows_single_instance_windows_test.go @@ -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() +}