-
+
-
v{{ version }}
+
+ {{ instanceName }}
+ v{{ version }}
+
@@ -98,18 +102,46 @@ const onSwitchActivity = async (e: Event) => {
.brand {
display: flex;
- align-items: center;
+ align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
}
+.brand-content {
+ flex: 1;
+ min-width: 0;
+}
+
.brand h1 {
font-size: 15px;
font-weight: 600;
color: var(--text-strong);
letter-spacing: -.01em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.brand-meta {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+.instance-pill {
+ padding: 3px 6px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-sm);
+ color: var(--muted);
+ font-size: 11px;
+ white-space: nowrap;
+ max-width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.brand-sub {
@@ -117,6 +149,16 @@ const onSwitchActivity = async (e: Event) => {
color: var(--muted);
margin-top: 3px;
letter-spacing: .02em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.brand-meta .stat-pill {
+ max-width: 120px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
}
.brand + .activity-box {
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 764d958..ae10f53 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -1,9 +1,19 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
-import App from './App.vue'
+import { GetInstanceName } from '@wails/go/app/App'
+import { installInstanceStorage } from './utils/instanceStorage'
-const app = createApp(App)
-const pinia = createPinia()
+const bootstrap = async () => {
+ let instanceName = '默认实例'
+ try {
+ instanceName = await GetInstanceName() || instanceName
+ } catch {}
-app.use(pinia)
-app.mount('#app')
+ installInstanceStorage(instanceName)
+ const { default: App } = await import('./App.vue')
+ const app = createApp(App)
+ app.use(createPinia())
+ app.mount('#app')
+}
+
+void bootstrap()
diff --git a/frontend/src/utils/instanceStorage.ts b/frontend/src/utils/instanceStorage.ts
new file mode 100644
index 0000000..56f47f0
--- /dev/null
+++ b/frontend/src/utils/instanceStorage.ts
@@ -0,0 +1,26 @@
+let installed = false
+
+// WebView 的 localStorage 会在同一个应用标识下跨窗口共享;在加载界面前
+// 为每个后端实例加前缀,避免选择、监控参数和窗口状态互相覆盖。
+export const installInstanceStorage = (instanceName: string) => {
+ if (installed || typeof Storage === 'undefined') return
+ installed = true
+
+ const suffix = encodeURIComponent(instanceName || '默认实例')
+ const prefix = `douyu-go:${suffix}:`
+ const storage = Storage.prototype
+ const getItem = storage.getItem
+ const setItem = storage.setItem
+ const removeItem = storage.removeItem
+ const key = (value: string) => prefix + value
+
+ storage.getItem = function (name: string) {
+ return getItem.call(this, key(name))
+ }
+ storage.setItem = function (name: string, value: string) {
+ return setItem.call(this, key(name), value)
+ }
+ storage.removeItem = function (name: string) {
+ return removeItem.call(this, key(name))
+ }
+}
diff --git a/go.mod b/go.mod
index 3df081d..50d1858 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
github.com/wailsapp/wails/v2 v2.12.0
github.com/xuri/excelize/v2 v2.9.1
golang.org/x/image v0.42.0
+ golang.org/x/sys v0.33.0
)
require (
@@ -42,6 +43,5 @@ require (
github.com/xuri/nfp v0.0.1 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
- golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.38.0 // indirect
)
diff --git a/internal/activity/activity.go b/internal/activity/activity.go
index edae655..bb7a300 100644
--- a/internal/activity/activity.go
+++ b/internal/activity/activity.go
@@ -115,8 +115,8 @@ var registry = map[string]*Activity{
xiaodian.ID: xiaodian,
}
-// DefaultActivityID 默认活动,新分支默认启用「电竞手册」。
-const DefaultActivityID = "peacematch"
+// DefaultActivityID 是新建配置时默认打开的活动。
+const DefaultActivityID = "xiaodian"
// List 返回所有注册活动,按注册顺序(精英手册在前)。
func List() []*Activity {
diff --git a/internal/app/app.go b/internal/app/app.go
index ad16f3f..0a57032 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -30,10 +30,12 @@ type App struct {
scheduler *seckill.Scheduler
logStore *logger.MemoryLogStore
- traceMu sync.RWMutex
- trace *trace.Recorder
- paths types.AppPaths
- font *truetype.Font
+ traceMu sync.RWMutex
+ trace *trace.Recorder
+ paths types.AppPaths
+ font *truetype.Font
+ instanceLease *config.InstanceLease
+ instanceName string
cfgMu sync.RWMutex
cfg types.Config
@@ -122,10 +124,23 @@ func New() *App {
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
- a.paths = config.DefaultAppPaths()
+ lease, instanceErr := config.AcquireInstancePaths()
+ if instanceErr != nil {
+ a.paths = config.DefaultAppPaths()
+ a.instanceName = "默认实例"
+ } else {
+ a.paths = lease.Paths()
+ a.instanceLease = lease
+ a.instanceName = lease.Name()
+ }
configMigrated, configMigrationErr := config.MigrateLegacyFile(a.paths.ConfigFile, a.paths.LegacyConfigFile)
a.logStore = logger.NewMemoryLogStore(800)
a.closeLog = logger.InitLogging(a.logStore, a.paths.LogDir)
+ if instanceErr != nil {
+ logger.LogEvent("系统", "warn", "实例隔离初始化失败,已回退默认目录: %v", instanceErr)
+ } else {
+ logger.LogEvent("系统", "success", "已启动%s,数据目录: %s", a.instanceName, a.paths.DataDir)
+ }
if configMigrationErr != nil {
logger.LogEvent("配置", "warn", "旧配置迁移失败: %s: %v", a.paths.LegacyConfigFile, configMigrationErr)
} else if configMigrated {
@@ -182,6 +197,10 @@ func (a *App) Shutdown(ctx context.Context) {
if a.closeLog != nil {
a.closeLog()
}
+ if a.instanceLease != nil {
+ a.instanceLease.Release()
+ a.instanceLease = nil
+ }
}
// GetVersion 返回应用版本号。
@@ -191,3 +210,11 @@ func (a *App) GetVersion() string {
}
return constants.Version
}
+
+// GetInstanceName 返回当前自动分配的本地实例名称。
+func (a *App) GetInstanceName() string {
+ if a.instanceName == "" {
+ return "默认实例"
+ }
+ return a.instanceName
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 0c00dab..6eab75e 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -17,11 +17,22 @@ import (
// DefaultAppPaths 收集运行时路径,支持环境变量覆盖。
// 默认数据存放在当前用户的应用配置目录,而非工作目录,避免 macOS 应用包目录无写权限。
func DefaultAppPaths() types.AppPaths {
- dataDir := resolvePath(envOrDefault("DOUYU_DATA_DIR", defaultAppDataDir()))
+ return appPathsForDataDir(defaultDataDir(), true)
+}
+
+func defaultDataDir() string {
+ return resolvePath(envOrDefault("DOUYU_DATA_DIR", defaultAppDataDir()))
+}
+
+func appPathsForDataDir(dataDir string, useLegacyFiles bool) types.AppPaths {
configFile := filepath.Join(dataDir, constants.DefaultConfigFile)
accountFile := filepath.Join(dataDir, constants.DefaultAccountFile)
- legacyConfigFile := resolvePath(constants.LegacyDefaultConfigFile)
- legacyAccountFile := resolvePath(constants.LegacyDefaultAccountFile)
+ legacyConfigFile := ""
+ legacyAccountFile := ""
+ if useLegacyFiles {
+ legacyConfigFile = resolvePath(constants.LegacyDefaultConfigFile)
+ legacyAccountFile = resolvePath(constants.LegacyDefaultAccountFile)
+ }
if value := os.Getenv("DOUYU_CONFIG"); value != "" {
configFile = resolvePath(value)
@@ -51,12 +62,16 @@ func DefaultAppPaths() types.AppPaths {
// defaultLogDir 在 wails dev 中把日志放在项目工作目录,便于开发时直接查看;
// 正式包始终使用用户应用数据目录,避免写入应用包或受限目录。
func defaultLogDir(dataDir string) string {
- if os.Getenv("devserver") != "" || os.Getenv("frontenddevserverurl") != "" {
+ if isWailsDev() {
return resolvePath(constants.DefaultLogDir)
}
return filepath.Join(dataDir, constants.DefaultLogDir)
}
+func isWailsDev() bool {
+ return os.Getenv("devserver") != "" || os.Getenv("frontenddevserverurl") != ""
+}
+
func defaultAppDataDir() string {
if dir, err := os.UserConfigDir(); err == nil && dir != "" {
return filepath.Join(dir, constants.DefaultAppDataDirName)
diff --git a/internal/config/instance.go b/internal/config/instance.go
new file mode 100644
index 0000000..f40e7cd
--- /dev/null
+++ b/internal/config/instance.go
@@ -0,0 +1,97 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "douyu_go/internal/constants"
+ "douyu_go/internal/types"
+)
+
+const maxInstanceSlots = 256
+
+// InstanceLease 代表当前进程持有的实例槽位;必须在程序退出时释放。
+type InstanceLease struct {
+ paths types.AppPaths
+ name string
+ lock instanceLock
+ releaseOnce sync.Once
+}
+
+// AcquireInstancePaths 按顺序占用空闲实例。第一个进程使用默认实例,
+// 后续进程分别使用实例 2、实例 3,以隔离账号、配置与运行数据。
+func AcquireInstancePaths() (*InstanceLease, error) {
+ baseDir := defaultDataDir()
+ instanceRoot := filepath.Join(baseDir, "instances")
+ lockDir := filepath.Join(instanceRoot, ".locks")
+ if err := osMkdirAll(lockDir); err != nil {
+ return nil, fmt.Errorf("创建实例锁目录失败: %w", err)
+ }
+
+ for slot := 1; slot <= maxInstanceSlots; slot++ {
+ lock, acquired, err := tryAcquireInstanceLock(filepath.Join(lockDir, instanceLockFileName(slot)))
+ if err != nil {
+ return nil, fmt.Errorf("锁定%s失败: %w", instanceName(slot), err)
+ }
+ if !acquired {
+ continue
+ }
+
+ dataDir := baseDir
+ if slot > 1 {
+ dataDir = filepath.Join(instanceRoot, fmt.Sprintf("instance-%d", slot))
+ }
+ paths := appPathsForDataDir(dataDir, slot == 1)
+ if slot > 1 && isWailsDev() && os.Getenv("DOUYU_LOG_DIR") == "" {
+ paths.LogDir = resolvePath(filepath.Join(constants.DefaultLogDir, "instances", fmt.Sprintf("instance-%d", slot)))
+ }
+ return &InstanceLease{
+ paths: paths,
+ name: instanceName(slot),
+ lock: lock,
+ }, nil
+ }
+ return nil, fmt.Errorf("最多支持同时运行 %d 个实例", maxInstanceSlots)
+}
+
+// Paths 返回当前实例独立的数据路径。
+func (l *InstanceLease) Paths() types.AppPaths {
+ if l == nil {
+ return types.AppPaths{}
+ }
+ return l.paths
+}
+
+// Name 返回面向用户的实例名称。
+func (l *InstanceLease) Name() string {
+ if l == nil {
+ return ""
+ }
+ return l.name
+}
+
+// Release 释放实例锁,允许新启动的程序复用该实例。
+func (l *InstanceLease) Release() {
+ if l == nil || l.lock == nil {
+ return
+ }
+ l.releaseOnce.Do(func() {
+ _ = l.lock.Close()
+ })
+}
+
+func instanceName(slot int) string {
+ if slot <= 1 {
+ return "默认实例"
+ }
+ return fmt.Sprintf("实例 %d", slot)
+}
+
+func instanceLockFileName(slot int) string {
+ if slot <= 1 {
+ return "default.lock"
+ }
+ return fmt.Sprintf("instance-%d.lock", slot)
+}
diff --git a/internal/config/instance_lock.go b/internal/config/instance_lock.go
new file mode 100644
index 0000000..a69fa72
--- /dev/null
+++ b/internal/config/instance_lock.go
@@ -0,0 +1,11 @@
+package config
+
+import "os"
+
+type instanceLock interface {
+ Close() error
+}
+
+func osMkdirAll(path string) error {
+ return os.MkdirAll(path, 0700)
+}
diff --git a/internal/config/instance_lock_unix.go b/internal/config/instance_lock_unix.go
new file mode 100644
index 0000000..0fa9d5d
--- /dev/null
+++ b/internal/config/instance_lock_unix.go
@@ -0,0 +1,36 @@
+//go:build !windows
+
+package config
+
+import (
+ "errors"
+ "os"
+ "syscall"
+)
+
+type unixInstanceLock struct {
+ file *os.File
+}
+
+func tryAcquireInstanceLock(path string) (instanceLock, bool, error) {
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
+ if err != nil {
+ return nil, false, err
+ }
+ if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
+ _ = file.Close()
+ if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
+ return nil, false, nil
+ }
+ return nil, false, err
+ }
+ return &unixInstanceLock{file: file}, true, nil
+}
+
+func (l *unixInstanceLock) Close() error {
+ if l == nil || l.file == nil {
+ return nil
+ }
+ _ = syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
+ return l.file.Close()
+}
diff --git a/internal/config/instance_lock_windows.go b/internal/config/instance_lock_windows.go
new file mode 100644
index 0000000..063c4fd
--- /dev/null
+++ b/internal/config/instance_lock_windows.go
@@ -0,0 +1,41 @@
+//go:build windows
+
+package config
+
+import (
+ "errors"
+
+ "golang.org/x/sys/windows"
+)
+
+type windowsInstanceLock struct {
+ handle windows.Handle
+}
+
+func tryAcquireInstanceLock(path string) (instanceLock, bool, error) {
+ handle, err := windows.CreateFile(
+ windows.StringToUTF16Ptr(path),
+ windows.GENERIC_READ|windows.GENERIC_WRITE,
+ 0,
+ nil,
+ windows.OPEN_ALWAYS,
+ windows.FILE_ATTRIBUTE_NORMAL,
+ 0,
+ )
+ if err != nil {
+ if errors.Is(err, windows.ERROR_SHARING_VIOLATION) || errors.Is(err, windows.ERROR_ACCESS_DENIED) {
+ return nil, false, nil
+ }
+ return nil, false, err
+ }
+ return &windowsInstanceLock{handle: handle}, true, nil
+}
+
+func (l *windowsInstanceLock) Close() error {
+ if l == nil || l.handle == windows.InvalidHandle {
+ return nil
+ }
+ err := windows.CloseHandle(l.handle)
+ l.handle = windows.InvalidHandle
+ return err
+}
diff --git a/internal/config/instance_test.go b/internal/config/instance_test.go
new file mode 100644
index 0000000..5d0af6d
--- /dev/null
+++ b/internal/config/instance_test.go
@@ -0,0 +1,49 @@
+package config
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestAcquireInstancePathsSeparatesConcurrentInstances(t *testing.T) {
+ baseDir := t.TempDir()
+ t.Setenv("DOUYU_DATA_DIR", baseDir)
+ t.Setenv("DOUYU_CONFIG", "")
+ t.Setenv("DOUYU_USERS", "")
+ t.Setenv("DOUYU_LOG_DIR", "")
+ t.Setenv("DOUYU_SUCCESS_DIR", "")
+ t.Setenv("devserver", "")
+ t.Setenv("frontenddevserverurl", "")
+
+ first, err := AcquireInstancePaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer first.Release()
+ if first.Name() != "默认实例" || first.Paths().DataDir != baseDir {
+ t.Fatalf("first instance = %q, %q", first.Name(), first.Paths().DataDir)
+ }
+
+ second, err := AcquireInstancePaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer second.Release()
+ wantSecondDir := filepath.Join(baseDir, "instances", "instance-2")
+ if second.Name() != "实例 2" || second.Paths().DataDir != wantSecondDir {
+ t.Fatalf("second instance = %q, %q", second.Name(), second.Paths().DataDir)
+ }
+ if second.Paths().LegacyConfigFile != "" || second.Paths().LegacyAccountFile != "" {
+ t.Fatal("附加实例不应迁移默认实例或旧工作目录数据")
+ }
+
+ first.Release()
+ reused, err := AcquireInstancePaths()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reused.Release()
+ if reused.Name() != "默认实例" || reused.Paths().DataDir != baseDir {
+ t.Fatalf("reused instance = %q, %q", reused.Name(), reused.Paths().DataDir)
+ }
+}
diff --git a/internal/constants/constants.go b/internal/constants/constants.go
index 68c1375..01dfd41 100644
--- a/internal/constants/constants.go
+++ b/internal/constants/constants.go
@@ -3,7 +3,7 @@ package constants
import "time"
// Version 应用版本号,与 wails.json version 同步。
-const Version = "0.1.7"
+const Version = "0.1.9"
// BuildTime 构建时间,由编译时 ldflags 注入。
var BuildTime = "unknown"
diff --git a/wails.json b/wails.json
index 80486c4..4a4bf42 100644
--- a/wails.json
+++ b/wails.json
@@ -2,7 +2,7 @@
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "douyu_go",
"outputfilename": "douyu_go",
- "version": "0.1.7",
+ "version": "0.1.9",
"frontend:dir": "frontend",
"frontend:install": "npm install",
"frontend:build": "npm run build",
@@ -15,6 +15,6 @@
"email": "yml@example.com"
},
"info": {
- "productVersion": "0.1.7"
+ "productVersion": "0.1.9"
}
}