diff --git a/Taskfile.yml b/Taskfile.yml index 938975e..f589c23 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -12,13 +12,25 @@ vars: VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' tasks: + version: + summary: Generate version information + cmds: + - '{{if eq OS "windows"}}cmd /c ".\scripts\version.bat"{{else}}bash ./scripts/version.sh{{end}}' + sources: + - scripts/version.bat + - scripts/version.sh + generates: + - version.txt + build: summary: Builds the application + deps: [version] cmds: - task: "{{OS}}:build" package: summary: Packages a production build of the application + deps: [version] cmds: - task: "{{OS}}:package" diff --git a/build/darwin/Taskfile.yml b/build/darwin/Taskfile.yml index 331ee6f..acf7ea1 100644 --- a/build/darwin/Taskfile.yml +++ b/build/darwin/Taskfile.yml @@ -11,9 +11,12 @@ tasks: - task: common:build:frontend - task: common:generate:icons cmds: - - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}} + - go build {{.BUILD_FLAGS}} -ldflags="{{.LDFLAGS}} {{.VERSION_FLAGS}}" -o {{.OUTPUT}} vars: - BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}' + BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false{{else}}-buildvcs=false -gcflags=all="-l"{{end}}' + LDFLAGS: '{{if eq .PRODUCTION "true"}}-w -s{{else}}{{end}}' + VERSION_FLAGS: + sh: 'grep "VERSION=" version.txt | cut -d"=" -f2 | xargs -I {} echo "-X voidraft/internal/version.Version={}"' DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' env: diff --git a/build/linux/Taskfile.yml b/build/linux/Taskfile.yml index e37d032..105f72a 100644 --- a/build/linux/Taskfile.yml +++ b/build/linux/Taskfile.yml @@ -11,9 +11,12 @@ tasks: - task: common:build:frontend - task: common:generate:icons cmds: - - go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}} + - go build {{.BUILD_FLAGS}} -ldflags="{{.LDFLAGS}} {{.VERSION_FLAGS}}" -o {{.BIN_DIR}}/{{.APP_NAME}} vars: - BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}' + BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false{{else}}-buildvcs=false -gcflags=all="-l"{{end}}' + LDFLAGS: '{{if eq .PRODUCTION "true"}}-w -s{{else}}{{end}}' + VERSION_FLAGS: + sh: 'grep "VERSION=" version.txt | cut -d"=" -f2 | xargs -I {} echo "-X voidraft/internal/version.Version={}"' env: GOOS: linux CGO_ENABLED: 1 diff --git a/build/windows/Taskfile.yml b/build/windows/Taskfile.yml index 4ecb481..d694f4f 100644 --- a/build/windows/Taskfile.yml +++ b/build/windows/Taskfile.yml @@ -14,13 +14,16 @@ tasks: - task: common:generate:icons cmds: - task: generate:syso - - go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}.exe + - go build {{.BUILD_FLAGS}} -ldflags="{{.LDFLAGS}} {{.VERSION_FLAGS}}" -o {{.BIN_DIR}}/{{.APP_NAME}}.exe - cmd: powershell Remove-item *.syso platforms: [windows] - cmd: rm -f *.syso platforms: [linux, darwin] vars: - BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}' + BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false{{else}}-buildvcs=false -gcflags=all="-l"{{end}}' + LDFLAGS: '{{if eq .PRODUCTION "true"}}-w -s -H windowsgui{{else}}{{end}}' + VERSION_FLAGS: + sh: 'powershell -Command "(Get-Content version.txt) -replace ''VERSION='', ''-X voidraft/internal/version.Version=''"' env: GOOS: windows CGO_ENABLED: 1 diff --git a/frontend/bindings/voidraft/internal/services/configservice.ts b/frontend/bindings/voidraft/internal/services/configservice.ts index f61248e..b9bb3cb 100644 --- a/frontend/bindings/voidraft/internal/services/configservice.ts +++ b/frontend/bindings/voidraft/internal/services/configservice.ts @@ -34,6 +34,22 @@ export function GetConfig(): Promise & { cancel(): vo return $typingPromise; } +/** + * GetConfigDir 获取配置目录 + */ +export function GetConfigDir(): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(2275626561) as any; + return $resultPromise; +} + +/** + * GetSettingsPath 获取设置文件路径 + */ +export function GetSettingsPath(): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(2175583370) as any; + return $resultPromise; +} + /** * ResetConfig 强制重置所有配置为默认值 */ diff --git a/go.mod b/go.mod index 28965ec..4e04b9e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module voidraft go 1.24.4 require ( - github.com/Masterminds/semver/v3 v3.4.0 github.com/creativeprojects/go-selfupdate v1.5.0 github.com/go-git/go-git/v5 v5.16.2 github.com/knadh/koanf/parsers/json v1.0.0 @@ -11,6 +10,7 @@ require ( github.com/knadh/koanf/providers/structs v1.0.0 github.com/knadh/koanf/v2 v2.2.2 github.com/robertkrimen/otto v0.5.1 + github.com/stretchr/testify v1.10.0 github.com/wailsapp/wails/v3 v3.0.0-alpha.25 golang.org/x/net v0.43.0 golang.org/x/sys v0.35.0 @@ -22,12 +22,14 @@ require ( code.gitea.io/sdk/gitea v0.21.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/42wim/httpsig v1.2.3 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.8.4 // indirect @@ -62,6 +64,7 @@ require ( github.com/pjbgf/sha1cd v0.4.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.51.0 // indirect diff --git a/internal/models/config.go b/internal/models/config.go index f85c17f..073de20 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "time" + "voidraft/internal/version" ) // TabType 定义了制表符类型 @@ -178,7 +179,7 @@ func NewDefaultAppConfig() *AppConfig { SystemTheme: SystemThemeAuto, }, Updates: UpdatesConfig{ - Version: "1.3.0", + Version: version.Version, AutoUpdate: true, PrimarySource: UpdateSourceGitea, BackupSource: UpdateSourceGithub, @@ -207,7 +208,7 @@ func NewDefaultAppConfig() *AppConfig { }, Metadata: ConfigMetadata{ LastUpdated: time.Now().Format(time.RFC3339), - Version: "1.2.0", + Version: version.Version, }, } } diff --git a/internal/services/config_migration_service.go b/internal/services/config_migration_service.go index 13ec711..5181a2a 100644 --- a/internal/services/config_migration_service.go +++ b/internal/services/config_migration_service.go @@ -1,318 +1,195 @@ package services import ( - "encoding/json" "fmt" - "os" - "path/filepath" - "reflect" - "sort" - "time" - "voidraft/internal/models" - - "github.com/Masterminds/semver/v3" jsonparser "github.com/knadh/koanf/parsers/json" "github.com/knadh/koanf/providers/structs" "github.com/knadh/koanf/v2" "github.com/wailsapp/wails/v3/pkg/services/log" + "os" + "path/filepath" + "time" ) const ( - // CurrentAppConfigVersion 当前应用配置版本 - CurrentAppConfigVersion = "1.3.0" - // BackupFilePattern 备份文件名模式 + // BackupFilePattern backup file name pattern BackupFilePattern = "%s.backup.%s.json" - - // 资源限制常量 + // MaxConfigFileSize maximum config file size MaxConfigFileSize = 10 * 1024 * 1024 // 10MB - MaxRecursionDepth = 50 // 最大递归深度 ) -// Migratable 可迁移的配置接口 -type Migratable interface { - GetVersion() string // 获取当前版本 - SetVersion(string) // 设置版本 - SetLastUpdated(string) // 设置最后更新时间 - GetDefaultConfig() any // 获取默认配置 +// ConfigMigrator elegant configuration migrator with automatic field detection +type ConfigMigrator struct { + logger *log.LogService + configDir string + configName string + configPath string } -// ConfigMigrationService 配置迁移服务 -type ConfigMigrationService[T Migratable] struct { - logger *log.LogService - configDir string - configName string - targetVersion string - configPath string -} - -// MigrationResult 迁移结果 +// MigrationResult migration operation result type MigrationResult struct { - Migrated, ConfigUpdated bool - FromVersion, ToVersion string - BackupPath string + Migrated bool `json:"migrated"` // Whether migration was performed + MissingFields []string `json:"missingFields"` // Fields that were missing + BackupPath string `json:"backupPath"` // Path to backup file + Description string `json:"description"` // Description of migration } -// NewConfigMigrationService 创建配置迁移服务 -func NewConfigMigrationService[T Migratable]( +// NewConfigMigrator creates a new configuration migrator +func NewConfigMigrator( logger *log.LogService, configDir string, - configName, targetVersion, configPath string, -) *ConfigMigrationService[T] { - return &ConfigMigrationService[T]{ - logger: orDefault(logger, log.New()), - configDir: configDir, - configName: configName, - targetVersion: targetVersion, - configPath: configPath, + configName, configPath string, +) *ConfigMigrator { + if logger == nil { + logger = log.New() + } + return &ConfigMigrator{ + logger: logger, + configDir: configDir, + configName: configName, + configPath: configPath, } } -// MigrateConfig 迁移配置文件 -func (cms *ConfigMigrationService[T]) MigrateConfig(existingConfig *koanf.Koanf) (*MigrationResult, error) { - currentVersion := orDefault(existingConfig.String("metadata.version"), "0.0.0") - result := &MigrationResult{ - FromVersion: currentVersion, - ToVersion: cms.targetVersion, +// AutoMigrate automatically detects and migrates missing configuration fields +func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *koanf.Koanf) (*MigrationResult, error) { + // Load default config into temporary koanf instance + defaultKoanf := koanf.New(".") + if err := defaultKoanf.Load(structs.Provider(defaultConfig, "json"), nil); err != nil { + return nil, fmt.Errorf("failed to load default config: %w", err) } - if needsMigration, err := cms.needsMigration(currentVersion); err != nil { - return result, fmt.Errorf("version comparison failed: %w", err) - } else if !needsMigration { + // Detect missing fields + missingFields := cm.detectMissingFields(currentConfig.All(), defaultKoanf.All()) + + // Create result object + result := &MigrationResult{ + MissingFields: missingFields, + Migrated: len(missingFields) > 0, + Description: fmt.Sprintf("Detected %d missing configuration fields", len(missingFields)), + } + + // If no missing fields, return early + if !result.Migrated { + cm.logger.Info("No missing configuration fields detected") return result, nil } - // 资源检查和备份 - if err := cms.checkResourceLimits(); err != nil { - return result, fmt.Errorf("resource limit check failed: %w", err) + // Only create backup if we actually need to migrate (has missing fields) + if len(missingFields) > 0 { + if backupPath, err := cm.createBackup(); err != nil { + cm.logger.Error("Failed to create backup", "error", err) + } else { + result.BackupPath = backupPath + } } - if backupPath, err := cms.createBackupOptimized(); err != nil { - return result, fmt.Errorf("backup creation failed: %w", err) - } else { - result.BackupPath = backupPath + // Merge missing fields from default config + if err := cm.mergeDefaultFields(currentConfig, defaultKoanf, missingFields); err != nil { + return result, fmt.Errorf("failed to merge default fields: %w", err) } - // 自动恢复检查 - cms.tryQuickRecovery(existingConfig) - - // 执行迁移 - if configUpdated, err := cms.performOptimizedMigration(existingConfig); err != nil { - return result, fmt.Errorf("migration failed: %w", err) - } else { - result.Migrated = true - result.ConfigUpdated = configUpdated + // Save updated config + if err := cm.saveConfig(currentConfig); err != nil { + return result, fmt.Errorf("failed to save updated config: %w", err) } + cm.logger.Info("Configuration migration completed successfully", "migratedFields", len(missingFields)) return result, nil } -// needsMigration 检查是否需要迁移 -func (cms *ConfigMigrationService[T]) needsMigration(current string) (bool, error) { - currentVer, err := semver.NewVersion(current) - if err != nil { - return true, nil - } - targetVer, err := semver.NewVersion(cms.targetVersion) - if err != nil { - return false, fmt.Errorf("invalid target version: %s", cms.targetVersion) - } - return currentVer.LessThan(targetVer), nil +// detectMissingFields detects missing configuration fields +func (cm *ConfigMigrator) detectMissingFields(current, defaultConfig map[string]interface{}) []string { + var missingFields []string + cm.findMissingFieldsRecursive("", defaultConfig, current, &missingFields) + return missingFields } -// checkResourceLimits 检查资源限制 -func (cms *ConfigMigrationService[T]) checkResourceLimits() error { - if info, err := os.Stat(cms.configPath); err == nil && info.Size() > MaxConfigFileSize { - return fmt.Errorf("config file size (%d bytes) exceeds limit (%d bytes)", info.Size(), MaxConfigFileSize) +// findMissingFieldsRecursive recursively finds missing fields +func (cm *ConfigMigrator) findMissingFieldsRecursive(prefix string, defaultMap, currentMap map[string]interface{}, missing *[]string) { + for key, defaultVal := range defaultMap { + fullKey := key + if prefix != "" { + fullKey = prefix + "." + key + } + + currentVal, exists := currentMap[key] + if !exists { + // Field is completely missing + *missing = append(*missing, fullKey) + } else { + // Check nested structures + if defaultNestedMap, ok := defaultVal.(map[string]interface{}); ok { + if currentNestedMap, ok := currentVal.(map[string]interface{}); ok { + cm.findMissingFieldsRecursive(fullKey, defaultNestedMap, currentNestedMap, missing) + } else { + // Current value is not a map but default is, structure mismatch + *missing = append(*missing, fullKey) + } + } + } } +} + +// mergeDefaultFields merges default values for missing fields into current config +func (cm *ConfigMigrator) mergeDefaultFields(current, defaultConfig *koanf.Koanf, missingFields []string) error { + for _, field := range missingFields { + defaultValue := defaultConfig.Get(field) + if defaultValue != nil { + current.Set(field, defaultValue) + cm.logger.Debug("Merged missing field", "field", field, "value", defaultValue) + } + } + + // Update last modified timestamp + current.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339)) + return nil } -// createBackupOptimized 优化的备份创建 -func (cms *ConfigMigrationService[T]) createBackupOptimized() (string, error) { - if _, err := os.Stat(cms.configPath); os.IsNotExist(err) { +// createBackup creates a backup of the configuration file +func (cm *ConfigMigrator) createBackup() (string, error) { + if _, err := os.Stat(cm.configPath); os.IsNotExist(err) { return "", nil } - configDir := cms.configDir timestamp := time.Now().Format("20060102150405") - newBackupPath := filepath.Join(configDir, fmt.Sprintf(BackupFilePattern, cms.configName, timestamp)) + backupPath := filepath.Join(cm.configDir, fmt.Sprintf(BackupFilePattern, cm.configName, timestamp)) - // 单次扫描:删除旧备份并创建新备份 - pattern := filepath.Join(configDir, fmt.Sprintf("%s.backup.*.json", cms.configName)) - if matches, err := filepath.Glob(pattern); err == nil { - for _, oldBackup := range matches { - if oldBackup != newBackupPath { - os.Remove(oldBackup) // 忽略删除错误,继续处理 - } - } - } - - return newBackupPath, copyFile(cms.configPath, newBackupPath) -} - -// tryQuickRecovery 快速恢复检查 -func (cms *ConfigMigrationService[T]) tryQuickRecovery(existingConfig *koanf.Koanf) { - var testConfig T - if existingConfig.Unmarshal("", &testConfig) != nil { - cms.logger.Info("Config appears corrupted, attempting quick recovery") - if backupPath := cms.findLatestBackupQuick(); backupPath != "" { - if data, err := os.ReadFile(backupPath); err == nil { - existingConfig.Delete("") - existingConfig.Load(&BytesProvider{data}, jsonparser.Parser()) - cms.logger.Info("Quick recovery successful") - } - } - } -} - -// findLatestBackupQuick 快速查找最新备份(优化排序) -func (cms *ConfigMigrationService[T]) findLatestBackupQuick() string { - pattern := filepath.Join(cms.configDir, fmt.Sprintf("%s.backup.*.json", cms.configName)) - matches, err := filepath.Glob(pattern) - if err != nil || len(matches) == 0 { - return "" - } - sort.Strings(matches) // 字典序排序,时间戳格式确保正确性 - return matches[len(matches)-1] -} - -// performOptimizedMigration 优化的迁移执行 -func (cms *ConfigMigrationService[T]) performOptimizedMigration(existingConfig *koanf.Koanf) (bool, error) { - // 直接从koanf实例获取配置,避免额外序列化 - var currentConfig T - if err := existingConfig.Unmarshal("", ¤tConfig); err != nil { - return false, fmt.Errorf("unmarshal existing config failed: %w", err) - } - - defaultConfig, ok := currentConfig.GetDefaultConfig().(T) - if !ok { - return false, fmt.Errorf("default config type mismatch") - } - - return cms.mergeInPlace(existingConfig, currentConfig, defaultConfig) -} - -// mergeInPlace 就地合并配置 -func (cms *ConfigMigrationService[T]) mergeInPlace(existingConfig *koanf.Koanf, currentConfig, defaultConfig T) (bool, error) { - // 创建临时合并实例 - mergeKoanf := koanf.New(".") - - // 使用快速加载链 - if err := chainLoad(mergeKoanf, - func() error { return mergeKoanf.Load(structs.Provider(defaultConfig, "json"), nil) }, - func() error { - return mergeKoanf.Load(structs.Provider(currentConfig, "json"), nil, - koanf.WithMergeFunc(cms.fastMerge)) - }, - ); err != nil { - return false, fmt.Errorf("config merge failed: %w", err) - } - - // 更新元数据 - mergeKoanf.Set("metadata.version", cms.targetVersion) - mergeKoanf.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339)) - - // 一次性序列化和原子写入 - configBytes, err := mergeKoanf.Marshal(jsonparser.Parser()) + data, err := os.ReadFile(cm.configPath) if err != nil { - return false, fmt.Errorf("marshal config failed: %w", err) + return "", fmt.Errorf("failed to read config file: %w", err) + } + + if err := os.WriteFile(backupPath, data, 0644); err != nil { + return "", fmt.Errorf("failed to create backup: %w", err) + } + + cm.logger.Info("Configuration backup created", "path", backupPath) + return backupPath, nil +} + +// saveConfig saves configuration to file +func (cm *ConfigMigrator) saveConfig(config *koanf.Koanf) error { + configBytes, err := config.Marshal(jsonparser.Parser()) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) } if len(configBytes) > MaxConfigFileSize { - return false, fmt.Errorf("merged config size exceeds limit") + return fmt.Errorf("config size (%d bytes) exceeds limit (%d bytes)", len(configBytes), MaxConfigFileSize) } - // 原子写入 - return true, cms.atomicWrite(existingConfig, configBytes) -} - -// atomicWrite 原子写入操作 -func (cms *ConfigMigrationService[T]) atomicWrite(existingConfig *koanf.Koanf, configBytes []byte) error { - tempPath := cms.configPath + ".tmp" - + // Atomic write + tempPath := cm.configPath + ".tmp" if err := os.WriteFile(tempPath, configBytes, 0644); err != nil { - return fmt.Errorf("write temp config failed: %w", err) + return fmt.Errorf("failed to write temp config: %w", err) } - if err := os.Rename(tempPath, cms.configPath); err != nil { + if err := os.Rename(tempPath, cm.configPath); err != nil { os.Remove(tempPath) - return fmt.Errorf("atomic rename failed: %w", err) + return fmt.Errorf("failed to rename temp config: %w", err) } - // 重新加载到原实例 - existingConfig.Delete("") - return existingConfig.Load(&BytesProvider{configBytes}, jsonparser.Parser()) -} - -// fastMerge 快速合并函数 -func (cms *ConfigMigrationService[T]) fastMerge(src, dest map[string]interface{}) error { - return cms.fastMergeRecursive(src, dest, 0) -} - -// fastMergeRecursive 快速递归合并 -func (cms *ConfigMigrationService[T]) fastMergeRecursive(src, dest map[string]interface{}, depth int) error { - if depth > MaxRecursionDepth { - return fmt.Errorf("recursion depth exceeded") - } - - for key, srcVal := range src { - if destVal, exists := dest[key]; exists { - // 优先检查map类型(最常见情况) - if srcMap, srcOK := srcVal.(map[string]interface{}); srcOK { - if destMap, destOK := destVal.(map[string]interface{}); destOK { - if err := cms.fastMergeRecursive(srcMap, destMap, depth+1); err != nil { - return err - } - continue - } - } - // 快速空值检查(避免反射) - if srcVal == nil || srcVal == "" || srcVal == 0 { - continue - } - } - dest[key] = srcVal - } return nil } - -// BytesProvider 轻量字节提供器 -type BytesProvider struct{ data []byte } - -func (bp *BytesProvider) ReadBytes() ([]byte, error) { return bp.data, nil } -func (bp *BytesProvider) Read() (map[string]interface{}, error) { - var result map[string]interface{} - return result, json.Unmarshal(bp.data, &result) -} - -// 工具函数 -func orDefault[T any](value, defaultValue T) T { - var zero T - if reflect.DeepEqual(value, zero) { - return defaultValue - } - return value -} - -func copyFile(src, dst string) error { - data, err := os.ReadFile(src) - if err != nil { - return err - } - return os.WriteFile(dst, data, 0644) -} - -func chainLoad(k *koanf.Koanf, loaders ...func() error) error { - for _, loader := range loaders { - if err := loader(); err != nil { - return err - } - } - return nil -} - -// 工厂函数 -func NewAppConfigMigrationService(logger *log.LogService, configDir, settingsPath string) *ConfigMigrationService[*models.AppConfig] { - return NewConfigMigrationService[*models.AppConfig]( - logger, configDir, "settings", CurrentAppConfigVersion, settingsPath) -} diff --git a/internal/services/config_migrator_test.go b/internal/services/config_migrator_test.go new file mode 100644 index 0000000..364e1d5 --- /dev/null +++ b/internal/services/config_migrator_test.go @@ -0,0 +1,167 @@ +package services + +import ( + "encoding/json" + jsonparser "github.com/knadh/koanf/parsers/json" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" + "github.com/stretchr/testify/assert" + "github.com/wailsapp/wails/v3/pkg/services/log" + "os" + "path/filepath" + "testing" +) + +// TestConfig represents a simplified config structure for testing +type TestConfig struct { + App struct { + Name string `json:"name"` + Version string `json:"version"` + Theme string `json:"theme"` + } `json:"app"` + User struct { + Name string `json:"name"` + Email string `json:"email"` + Settings struct { + AutoSave bool `json:"autoSave"` + Language string `json:"language"` + NewSetting bool `json:"newSetting"` // This field will be missing in old config + NewSetting2 string `json:"newSetting2"` // This field will be missing in old config + } `json:"settings"` + } `json:"user"` + NewSection struct { + Enabled bool `json:"enabled"` + Value string `json:"value"` + } `json:"newSection"` // This entire section will be missing in old config +} + +// createTestConfig creates a test configuration file +func createTestConfig(t *testing.T, tempDir string) string { + // Old config without some fields + oldConfig := map[string]interface{}{ + "app": map[string]interface{}{ + "name": "TestApp", + "version": "1.0.0", + "theme": "dark", + }, + "user": map[string]interface{}{ + "name": "Test User", + "email": "test@example.com", + "settings": map[string]interface{}{ + "autoSave": true, + "language": "en", + // Missing newSetting and newSetting2 + }, + }, + // Missing newSection + } + + // Create config file + configPath := filepath.Join(tempDir, "config.json") + jsonData, err := json.MarshalIndent(oldConfig, "", " ") + if err != nil { + t.Fatalf("Failed to marshal test config: %v", err) + } + + err = os.WriteFile(configPath, jsonData, 0644) + if err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + return configPath +} + +// TestConfigMigrator_AutoMigrate tests the ConfigMigrator's AutoMigrate functionality +func TestConfigMigrator_AutoMigrate(t *testing.T) { + // Create temp directory for test + tempDir, err := os.MkdirTemp("", "config_migrator_test") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Create test config file + configPath := createTestConfig(t, tempDir) + + // Create logger + logger := log.New() + + // Create config migrator + migrator := NewConfigMigrator(logger, tempDir, "config", configPath) + + // Create koanf instance and load the config + k := koanf.New(".") + fileProvider := file.Provider(configPath) + jsonParser := jsonparser.Parser() + if err := k.Load(fileProvider, jsonParser); err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Create default config with all fields + defaultConfig := TestConfig{} + defaultConfig.App.Name = "TestApp" + defaultConfig.App.Version = "1.0.0" + defaultConfig.App.Theme = "dark" + defaultConfig.User.Name = "Test User" + defaultConfig.User.Email = "test@example.com" + defaultConfig.User.Settings.AutoSave = true + defaultConfig.User.Settings.Language = "en" + defaultConfig.User.Settings.NewSetting = true // New field + defaultConfig.User.Settings.NewSetting2 = "value" // New field + defaultConfig.NewSection.Enabled = true // New section + defaultConfig.NewSection.Value = "new section" // New section + + // Run auto migration + result, err := migrator.AutoMigrate(defaultConfig, k) + if err != nil { + t.Fatalf("Failed to auto migrate: %v", err) + } + + // Assertions + assert.True(t, result.Migrated, "Migration should have been performed") + + // 打印检测到的缺失字段,便于分析 + t.Logf("Detected fields: %v", result.MissingFields) + + // 验证检测到了正确数量的字段 - 实际检测到4个 + assert.Equal(t, 4, len(result.MissingFields), "Should have detected 4 missing fields") + + // 期望检测到的缺失字段 + expectedFields := map[string]bool{ + "user.settings.newSetting": true, + "user.settings.newSetting2": true, + "newSection.enabled": true, + "newSection.value": true, + } + + // 验证所有预期的字段都被检测到了 + for _, field := range result.MissingFields { + _, expected := expectedFields[field] + assert.True(t, expected, "Field %s was detected but not expected", field) + } + + // 验证所有检测到的字段都在预期之内 + for expectedField := range expectedFields { + found := false + for _, field := range result.MissingFields { + if field == expectedField { + found = true + break + } + } + assert.True(t, found, "Expected field %s was not detected", expectedField) + } + + // Verify that the fields were actually added to the config + assert.True(t, k.Bool("user.settings.newSetting"), "newSetting should be added with correct value") + assert.Equal(t, "value", k.String("user.settings.newSetting2"), "newSetting2 should be added with correct value") + assert.True(t, k.Bool("newSection.enabled"), "newSection.enabled should be added with correct value") + assert.Equal(t, "new section", k.String("newSection.value"), "newSection.value should be added with correct value") + + // Check that backup was created + backupFiles, err := filepath.Glob(filepath.Join(tempDir, "*.backup.*")) + if err != nil { + t.Fatalf("Failed to list backup files: %v", err) + } + assert.Equal(t, 1, len(backupFiles), "One backup file should have been created") +} diff --git a/internal/services/config_notification_service.go b/internal/services/config_notification_service.go index a37d50a..8002d57 100644 --- a/internal/services/config_notification_service.go +++ b/internal/services/config_notification_service.go @@ -288,25 +288,6 @@ func deepCopyValue(src, dst reflect.Value) { } } -// deepCopyConfig 保留原有的JSON深拷贝方法作为备用 -func deepCopyConfig(src *models.AppConfig) *models.AppConfig { - if src == nil { - return nil - } - - jsonBytes, err := json.Marshal(src) - if err != nil { - return src - } - - var dst models.AppConfig - if err := json.Unmarshal(jsonBytes, &dst); err != nil { - return src - } - - return &dst -} - // debounceNotify 防抖通知 func (cns *ConfigNotificationService) debounceNotify(listener *ConfigListener, oldConfig, newConfig *models.AppConfig) { listener.mu.Lock() diff --git a/internal/services/config_service.go b/internal/services/config_service.go index de6fe07..42c3596 100644 --- a/internal/services/config_service.go +++ b/internal/services/config_service.go @@ -27,8 +27,9 @@ type ConfigService struct { // 配置通知服务 notificationService *ConfigNotificationService - // 配置迁移服务 - migrationService *ConfigMigrationService[*models.AppConfig] + + // 配置迁移器 + configMigrator *ConfigMigrator } // ConfigError 配置错误 @@ -67,16 +68,18 @@ func NewConfigService(logger *log.LogService) *ConfigService { settingsPath := filepath.Join(configDir, "settings.json") cs := &ConfigService{ - logger: logger, - configDir: configDir, - settingsPath: settingsPath, - koanf: koanf.New("."), - migrationService: NewAppConfigMigrationService(logger, configDir, settingsPath), + logger: logger, + configDir: configDir, + settingsPath: settingsPath, + koanf: koanf.New("."), } // 初始化配置通知服务 cs.notificationService = NewConfigNotificationService(cs.koanf, logger) + // 初始化配置迁移器 + cs.configMigrator = NewConfigMigrator(logger, configDir, "settings", settingsPath) + cs.initConfig() // 启动配置文件监听 @@ -106,23 +109,36 @@ func (cs *ConfigService) initConfig() error { return cs.createDefaultConfig() } - // 配置文件存在,先加载现有配置 + // 检查并自动迁移配置 + cs.checkConfigMigration() + + // 配置文件存在,直接加载现有配置 cs.fileProvider = file.Provider(cs.settingsPath) if err := cs.koanf.Load(cs.fileProvider, jsonparser.Parser()); err != nil { return &ConfigError{Operation: "load_config_file", Err: err} } - // 检查并执行配置迁移 - if cs.migrationService != nil { - result, err := cs.migrationService.MigrateConfig(cs.koanf) - if err != nil { - return &ConfigError{Operation: "migrate_config", Err: err} - } + return nil +} - if result.Migrated && result.ConfigUpdated { - // 迁移完成且配置已更新,重新创建文件提供器以监听新文件 - cs.fileProvider = file.Provider(cs.settingsPath) - } +// checkConfigMigration 检查配置迁移 +func (cs *ConfigService) checkConfigMigration() error { + if cs.configMigrator == nil { + return nil + } + + defaultConfig := models.NewDefaultAppConfig() + result, err := cs.configMigrator.AutoMigrate(defaultConfig, cs.koanf) + + if err != nil { + cs.logger.Error("Failed to check config migration", "error", err) + return err + } + + if result != nil && result.Migrated { + cs.logger.Info("Config migration performed", + "fields", result.MissingFields, + "backup", result.BackupPath) } return nil @@ -326,3 +342,13 @@ func (cs *ConfigService) ServiceShutdown() error { } return nil } + +// GetConfigDir 获取配置目录 +func (cs *ConfigService) GetConfigDir() string { + return cs.configDir +} + +// GetSettingsPath 获取设置文件路径 +func (cs *ConfigService) GetSettingsPath() string { + return cs.settingsPath +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..a6e7163 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,4 @@ +package version + +// Version 版本注入 Ldflags +var Version = "0.0.0" diff --git a/scripts/version.bat b/scripts/version.bat new file mode 100644 index 0000000..407363d --- /dev/null +++ b/scripts/version.bat @@ -0,0 +1,73 @@ +@echo off +setlocal enabledelayedexpansion + +REM Simplified version management script - Windows version +REM Auto-increment patch version from git tags or use custom version + +REM Configuration section - Set custom version here if needed +set "CUSTOM_VERSION=" +REM Example: set "CUSTOM_VERSION=2.0.0" + +set "VERSION_FILE=version.txt" + +REM Check if custom version is set +if not "%CUSTOM_VERSION%"=="" ( + echo [INFO] Using custom version: %CUSTOM_VERSION% + set "VERSION=%CUSTOM_VERSION%" + goto :save_version +) + +REM Check if git is available +git --version >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Git is not installed or not in PATH + exit /b 1 +) + +REM Check if in git repository +git rev-parse --git-dir >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Not in a git repository + exit /b 1 +) + +REM Get latest git tag +git describe --abbrev=0 --tags > temp_tag.txt 2>nul +if errorlevel 1 ( + echo [ERROR] No git tags found in repository + if exist temp_tag.txt del temp_tag.txt + exit /b 1 +) + +set /p LATEST_TAG= %VERSION_FILE% + +echo [INFO] Version information saved to %VERSION_FILE% \ No newline at end of file diff --git a/scripts/version.sh b/scripts/version.sh new file mode 100644 index 0000000..2cb712f --- /dev/null +++ b/scripts/version.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# 配置区域 - 如需自定义版本,请在此处设置 +CUSTOM_VERSION="" +# 示例: CUSTOM_VERSION="2.0.0" + +VERSION_FILE="version.txt" + +# 检查是否设置了自定义版本 +if [ -n "$CUSTOM_VERSION" ]; then + echo "[INFO] Using custom version: $CUSTOM_VERSION" + VERSION="$CUSTOM_VERSION" +else + # 检查git是否可用 + if ! command -v git &> /dev/null; then + echo "[ERROR] Git is not installed or not in PATH" + exit 1 + elif ! git rev-parse --git-dir &> /dev/null; then + echo "[ERROR] Not in a git repository" + exit 1 + else + # 获取最新的git标签 + LATEST_TAG=$(git describe --abbrev=0 --tags 2>/dev/null) + + if [ -z "$LATEST_TAG" ]; then + echo "[ERROR] No git tags found in repository" + exit 1 + else + echo "[INFO] Latest git tag: $LATEST_TAG" + + # 移除v前缀 + CLEAN_VERSION=${LATEST_TAG#v} + + # 分割版本号并递增patch版本 + IFS='.' read -r MAJOR MINOR PATCH <<< "$CLEAN_VERSION" + PATCH=$((PATCH + 1)) + + VERSION="$MAJOR.$MINOR.$PATCH" + echo "[INFO] Auto-incremented patch version: $VERSION" + fi + fi +fi + +# 输出版本信息 +echo "VERSION=$VERSION" + +# 保存到文件供其他脚本使用 +echo "VERSION=$VERSION" > "$VERSION_FILE" + +echo "[INFO] Version information saved to $VERSION_FILE" \ No newline at end of file diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..877106a --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +VERSION=1.3.5