mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-06 22:53:35 +08:00
✨ feat(windows): 支持 MSI 主程序严格单实例启动
- 仅对带 MSI 安装标识的 Windows 主程序启用单实例门禁,Portable 与特殊运行模式保持原行为 - 重复启动通过命名事件唤醒首个窗口,并兼容 Wails 运行时尚未就绪的激活请求 - 新增并发抢占、窗口激活与安装模式测试,覆盖同一安装仅保留一个主实例
This commit is contained in:
@@ -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
67
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 {
|
||||
|
||||
46
main_test.go
46
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
|
||||
|
||||
7
windows_single_instance_stub.go
Normal file
7
windows_single_instance_stub.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
func acquireWindowsMSISingleInstance(_ string, _ func()) (func(), bool, error) {
|
||||
return nil, true, nil
|
||||
}
|
||||
75
windows_single_instance_windows.go
Normal file
75
windows_single_instance_windows.go
Normal 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
|
||||
}
|
||||
95
windows_single_instance_windows_test.go
Normal file
95
windows_single_instance_windows_test.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user