package services import ( "context" "errors" "fmt" "os" "runtime" "sync" "time" "github.com/creativeprojects/go-selfupdate" "github.com/wailsapp/wails/v3/pkg/services/dock" "github.com/wailsapp/wails/v3/pkg/services/log" "github.com/wailsapp/wails/v3/pkg/services/notifications" "voidraft/internal/models" ) // SelfUpdateResult 自我更新结果 type SelfUpdateResult struct { HasUpdate bool `json:"hasUpdate"` // 是否有更新 CurrentVersion string `json:"currentVersion"` // 当前版本 LatestVersion string `json:"latestVersion"` // 最新版本 UpdateApplied bool `json:"updateApplied"` // 是否已应用更新 AssetURL string `json:"assetURL"` // 下载链接 ReleaseNotes string `json:"releaseNotes"` // 发布说明 Error string `json:"error"` // 错误信息 Source string `json:"source"` // 更新源(github/gitea) } // SelfUpdateService 自我更新服务 type SelfUpdateService struct { logger *log.LogService configService *ConfigService badgeService *dock.DockService notificationService *notifications.NotificationService mu sync.Mutex // 保护更新状态 isUpdating bool } // NewSelfUpdateService 创建自我更新服务实例 func NewSelfUpdateService(configService *ConfigService, badgeService *dock.DockService, notificationService *notifications.NotificationService, logger *log.LogService) *SelfUpdateService { return &SelfUpdateService{ logger: logger, configService: configService, badgeService: badgeService, notificationService: notificationService, isUpdating: false, } } // getConfig 获取最新配置 func (s *SelfUpdateService) getConfig() (*models.AppConfig, error) { config, err := s.configService.GetConfig() if err != nil { return nil, fmt.Errorf("get config failed: %w", err) } return config, nil } // CheckForUpdates 检查更新 func (s *SelfUpdateService) CheckForUpdates(ctx context.Context) (*SelfUpdateResult, error) { config, err := s.getConfig() if err != nil { return nil, err } result := &SelfUpdateResult{ CurrentVersion: config.Updates.Version, HasUpdate: false, UpdateApplied: false, } // 尝试主要更新源 primaryResult, err := s.checkSourceForUpdates(ctx, config.Updates.PrimarySource, config) if err == nil && primaryResult != nil { s.handleUpdateBadge(primaryResult) return primaryResult, nil } // 尝试备用更新源 backupResult, backupErr := s.checkSourceForUpdates(ctx, config.Updates.BackupSource, config) if backupErr != nil { result.Error = fmt.Sprintf("both sources failed: %v; %v", err, backupErr) s.handleUpdateBadge(result) return result, errors.New(result.Error) } s.handleUpdateBadge(backupResult) return backupResult, nil } // checkSourceForUpdates 根据更新源类型检查更新 func (s *SelfUpdateService) checkSourceForUpdates(ctx context.Context, sourceType models.UpdateSourceType, config *models.AppConfig) (*SelfUpdateResult, error) { timeout := config.Updates.UpdateTimeout if timeout <= 0 { timeout = 30 } timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) defer cancel() result := &SelfUpdateResult{ CurrentVersion: config.Updates.Version, HasUpdate: false, UpdateApplied: false, Source: string(sourceType), } var release *selfupdate.Release var found bool var err error switch sourceType { case models.UpdateSourceGithub: release, found, err = s.checkGithubUpdates(timeoutCtx, config) case models.UpdateSourceGitea: release, found, err = s.checkGiteaUpdates(timeoutCtx, config) default: return nil, fmt.Errorf("unsupported source: %s", sourceType) } if err != nil { return result, fmt.Errorf("check failed: %w", err) } if !found { return result, fmt.Errorf("no release for %s/%s", runtime.GOOS, runtime.GOARCH) } result.LatestVersion = release.Version() result.AssetURL = release.AssetURL result.ReleaseNotes = release.ReleaseNotes result.HasUpdate = release.GreaterThan(config.Updates.Version) return result, nil } // createGithubUpdater 创建GitHub更新器 func (s *SelfUpdateService) createGithubUpdater() (*selfupdate.Updater, error) { return selfupdate.NewUpdater(selfupdate.Config{}) } // createGiteaUpdater 创建Gitea更新器 func (s *SelfUpdateService) createGiteaUpdater(config *models.AppConfig) (*selfupdate.Updater, error) { source, err := selfupdate.NewGiteaSource(selfupdate.GiteaConfig{ BaseURL: config.Updates.Gitea.BaseURL, }) if err != nil { return nil, fmt.Errorf("create gitea source failed: %w", err) } return selfupdate.NewUpdater(selfupdate.Config{Source: source}) } // checkGithubUpdates 检查GitHub更新 func (s *SelfUpdateService) checkGithubUpdates(ctx context.Context, config *models.AppConfig) (*selfupdate.Release, bool, error) { updater, err := s.createGithubUpdater() if err != nil { return nil, false, err } repo := selfupdate.NewRepositorySlug(config.Updates.Github.Owner, config.Updates.Github.Repo) return updater.DetectLatest(ctx, repo) } // checkGiteaUpdates 检查Gitea更新 func (s *SelfUpdateService) checkGiteaUpdates(ctx context.Context, config *models.AppConfig) (*selfupdate.Release, bool, error) { updater, err := s.createGiteaUpdater(config) if err != nil { return nil, false, err } repo := selfupdate.NewRepositorySlug(config.Updates.Gitea.Owner, config.Updates.Gitea.Repo) return updater.DetectLatest(ctx, repo) } // ApplyUpdate 应用更新 func (s *SelfUpdateService) ApplyUpdate(ctx context.Context) (*SelfUpdateResult, error) { s.mu.Lock() if s.isUpdating { s.mu.Unlock() return nil, errors.New("update in progress") } s.isUpdating = true s.mu.Unlock() defer func() { s.mu.Lock() s.isUpdating = false s.mu.Unlock() }() config, err := s.getConfig() if err != nil { return nil, err } exe, err := selfupdate.ExecutablePath() if err != nil { return nil, fmt.Errorf("locate executable failed: %w", err) } // 尝试主要源 result, err := s.performUpdate(ctx, config.Updates.PrimarySource, exe, config) if err == nil { return result, nil } // 尝试备用源 result, err = s.performUpdate(ctx, config.Updates.BackupSource, exe, config) if err != nil { return nil, fmt.Errorf("update failed from both sources: %w", err) } return result, nil } // performUpdate 执行更新操作(包括检测、备份、下载、应用) func (s *SelfUpdateService) performUpdate(ctx context.Context, sourceType models.UpdateSourceType, exe string, config *models.AppConfig) (*SelfUpdateResult, error) { timeout := config.Updates.UpdateTimeout if timeout <= 0 { timeout = 30 } checkCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) defer cancel() // 获取更新器和版本信息 updater, release, found, err := s.getUpdateFromSource(checkCtx, sourceType, config) if err != nil || !found { return nil, fmt.Errorf("detect release failed: %w", err) } result := &SelfUpdateResult{ CurrentVersion: config.Updates.Version, LatestVersion: release.Version(), AssetURL: release.AssetURL, ReleaseNotes: release.ReleaseNotes, Source: string(sourceType), HasUpdate: release.GreaterThan(config.Updates.Version), } // 无更新 if !result.HasUpdate { return result, nil } // 创建备份 var backupPath string if config.Updates.BackupBeforeUpdate { backupPath, err = s.createBackup(exe) if err != nil { return nil, fmt.Errorf("backup failed: %w", err) } defer func() { if backupPath != "" { s.cleanupBackup(backupPath) } }() } // 下载并应用更新 if err := updater.UpdateTo(ctx, release, exe); err != nil { return nil, fmt.Errorf("apply update failed: %w", err) } result.UpdateApplied = true s.handleUpdateSuccess(result) return result, nil } // getUpdateFromSource 从指定源获取更新信息 func (s *SelfUpdateService) getUpdateFromSource(ctx context.Context, sourceType models.UpdateSourceType, config *models.AppConfig) (*selfupdate.Updater, *selfupdate.Release, bool, error) { var updater *selfupdate.Updater var release *selfupdate.Release var found bool var err error switch sourceType { case models.UpdateSourceGithub: updater, err = s.createGithubUpdater() if err != nil { return nil, nil, false, err } release, found, err = s.checkGithubUpdates(ctx, config) case models.UpdateSourceGitea: updater, err = s.createGiteaUpdater(config) if err != nil { return nil, nil, false, err } release, found, err = s.checkGiteaUpdates(ctx, config) default: return nil, nil, false, fmt.Errorf("unsupported source: %s", sourceType) } return updater, release, found, err } // handleUpdateSuccess 处理更新成功后的操作 func (s *SelfUpdateService) handleUpdateSuccess(result *SelfUpdateResult) { // 更新配置版本 if err := s.configService.Set("updates.version", result.LatestVersion); err != nil { s.logger.Error("update config version failed", "error", err) } if err := s.configService.Set("metadata.version", result.LatestVersion); err != nil { s.logger.Error("update config version failed", "error", err) } // 执行配置迁移 if err := s.configService.MigrateConfig(); err != nil { s.logger.Error("migrate config failed", "error", err) } // 移除badge if s.badgeService != nil { s.badgeService.RemoveBadge() } } // createBackup 创建可执行文件备份 func (s *SelfUpdateService) createBackup(executablePath string) (string, error) { backupPath := executablePath + ".backup" data, err := os.ReadFile(executablePath) if err != nil { return "", fmt.Errorf("read executable failed: %w", err) } if err := os.WriteFile(backupPath, data, 0755); err != nil { return "", fmt.Errorf("write backup failed: %w", err) } return backupPath, nil } // cleanupBackup 清理备份文件 func (s *SelfUpdateService) cleanupBackup(backupPath string) error { if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) { s.logger.Error("cleanup backup failed", "error", err) } return nil } // RestartApplication 重启应用程序 func (s *SelfUpdateService) RestartApplication() error { return s.restartApplication() } // handleUpdateBadge 处理更新徽章和通知 func (s *SelfUpdateService) handleUpdateBadge(result *SelfUpdateResult) { if result == nil || !result.HasUpdate { if s.badgeService != nil { s.badgeService.RemoveBadge() } return } // 显示徽章 if s.badgeService != nil { if err := s.badgeService.SetBadge("●"); err != nil { s.logger.Error("set badge failed", "error", err) } } // 发送通知 s.sendUpdateNotification(result) } // sendUpdateNotification 发送更新通知 func (s *SelfUpdateService) sendUpdateNotification(result *SelfUpdateResult) { if s.notificationService == nil { return } // 检查授权 authorized, err := s.notificationService.CheckNotificationAuthorization() if err != nil || !authorized { authorized, err = s.notificationService.RequestNotificationAuthorization() if err != nil || !authorized { return } } // 发送通知 s.notificationService.SendNotification(notifications.NotificationOptions{ ID: "update_available", Title: "Voidraft Update Available", Subtitle: "New version available", Body: fmt.Sprintf("Version %s available (current: %s)", result.LatestVersion, result.CurrentVersion), }) }