diff --git a/cmd/updater/main.go b/cmd/updater/main.go index b5f74b6..444de97 100644 --- a/cmd/updater/main.go +++ b/cmd/updater/main.go @@ -28,6 +28,6 @@ func main() { femaUpdater := updater.NewUpdater(cfg, binaryData) // Create and show updater window - updaterWindow := ui.NewUpdaterWindow(myApp, cfg, femaUpdater.Update) + updaterWindow := ui.NewUpdaterWindow(myApp, cfg, femaUpdater) updaterWindow.ShowAndRun() } diff --git a/cmd/updater/updater_config.json b/cmd/updater/updater_config.json index 3a412a6..30422a6 100644 --- a/cmd/updater/updater_config.json +++ b/cmd/updater/updater_config.json @@ -1,6 +1,7 @@ { - "ip": "192.168.111.111", - "port": "22", + "ip": "62.217.183.220", + "port": "10000", "login": "root", - "password": "orangepi" -} \ No newline at end of file + "password": "orangepi", + "downloadUrl": "https://s3.ru1.storage.beget.cloud/e4b29bca179c-sparkguard/build" +} diff --git a/internal/ui/updater_window.go b/internal/ui/updater_window.go index f15efc1..2c6cb6a 100644 --- a/internal/ui/updater_window.go +++ b/internal/ui/updater_window.go @@ -1,45 +1,124 @@ package ui import ( + "fmt" + "time" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/widget" + "gitea.unprism.ru/KRBL/FemaInstaller/internal/updater" "gitea.unprism.ru/KRBL/FemaInstaller/pkg/config" ) // UpdaterWindow represents the main window for the updater application type UpdaterWindow struct { - Window fyne.Window - ConfigDisplay *widget.Label - UpdateButton *widget.Button - StatusLabel *widget.Label + Window fyne.Window + ConfigDisplay *widget.Label + UpdateButton *widget.Button + StatusLabel *widget.Label + ProgressBar *widget.ProgressBar + StageLabel *widget.Label + TimeRemainingLabel *widget.Label + UpdateMethodRadio *widget.RadioGroup + Updater *updater.Updater } // NewUpdaterWindow creates a new window for the updater application -func NewUpdaterWindow(app fyne.App, config *config.UpdaterConfig, updateHandler func() error) *UpdaterWindow { +func NewUpdaterWindow(app fyne.App, config *config.UpdaterConfig, femaUpdater *updater.Updater) *UpdaterWindow { window := app.NewWindow("Обновление ПО Фема") + // Create update method radio + updateMethodRadio := widget.NewRadioGroup( + []string{"Использовать встроенную версию", "Загрузить с сервера по URL"}, + func(selected string) { + if selected == "Использовать встроенную версию" { + femaUpdater.SetUpdateMethod(updater.UpdateMethodEmbedded) + } else { + femaUpdater.SetUpdateMethod(updater.UpdateMethodDirectDownload) + } + }, + ) + // Default to embedded method + updateMethodRadio.SetSelected("Использовать встроенную версию") + // Create updater window updaterWindow := &UpdaterWindow{ - Window: window, - ConfigDisplay: widget.NewLabel(config.String()), - StatusLabel: widget.NewLabel(""), + Window: window, + ConfigDisplay: widget.NewLabel(config.String()), + StatusLabel: widget.NewLabel(""), + ProgressBar: widget.NewProgressBar(), + StageLabel: widget.NewLabel(""), + TimeRemainingLabel: widget.NewLabel(""), + UpdateMethodRadio: updateMethodRadio, + Updater: femaUpdater, } + // Hide progress elements initially + updaterWindow.ProgressBar.Hide() + updaterWindow.StageLabel.Hide() + updaterWindow.TimeRemainingLabel.Hide() + // Create update button updaterWindow.UpdateButton = widget.NewButton("Обновить ПО", func() { updaterWindow.StatusLabel.SetText("Начало обновления...") updaterWindow.UpdateButton.Disable() + updaterWindow.UpdateMethodRadio.Disable() + + // Show progress elements + updaterWindow.ProgressBar.Show() + updaterWindow.StageLabel.Show() + updaterWindow.TimeRemainingLabel.Show() + + // Reset progress + updaterWindow.ProgressBar.SetValue(0) + updaterWindow.StageLabel.SetText("Подготовка...") + updaterWindow.TimeRemainingLabel.SetText("") go func() { - err := updateHandler() + // Create progress callback + progressCallback := func(progress updater.ProgressInfo) { + // Update UI from the main thread + window.Canvas().Refresh(updaterWindow.ProgressBar) + + updaterWindow.ProgressBar.SetValue(progress.Percentage / 100) + updaterWindow.StageLabel.SetText(progress.Stage) + + // Format time remaining + if progress.EstimatedTimeRemaining > 0 { + minutes := int(progress.EstimatedTimeRemaining.Minutes()) + seconds := int(progress.EstimatedTimeRemaining.Seconds()) % 60 + + if minutes > 0 { + updaterWindow.TimeRemainingLabel.SetText( + fmt.Sprintf("Осталось примерно: %d мин %d сек", minutes, seconds)) + } else { + updaterWindow.TimeRemainingLabel.SetText( + fmt.Sprintf("Осталось примерно: %d сек", seconds)) + } + } else { + updaterWindow.TimeRemainingLabel.SetText("") + } + } + + err := femaUpdater.Update(progressCallback) if err != nil { updaterWindow.StatusLabel.SetText("Ошибка обновления: " + err.Error()) } else { updaterWindow.StatusLabel.SetText("Обновление успешно завершено!") } + + // Wait a moment to show 100% completion + time.Sleep(500 * time.Millisecond) + + // Hide progress elements after completion + updaterWindow.ProgressBar.Hide() + updaterWindow.StageLabel.Hide() + updaterWindow.TimeRemainingLabel.Hide() + updaterWindow.UpdateButton.Enable() + updaterWindow.UpdateMethodRadio.Enable() }() }) @@ -52,6 +131,17 @@ func NewUpdaterWindow(app fyne.App, config *config.UpdaterConfig, updateHandler configTitle := widget.NewLabel("Текущая конфигурация:") configTitle.TextStyle = fyne.TextStyle{Bold: true} + // Create progress section + progressSection := container.NewVBox( + updaterWindow.StageLabel, + updaterWindow.ProgressBar, + updaterWindow.TimeRemainingLabel, + ) + + // Create update method section title + updateMethodTitle := widget.NewLabel("Метод обновления:") + updateMethodTitle.TextStyle = fyne.TextStyle{Bold: true} + // Create layout content := container.NewVBox( title, @@ -59,13 +149,17 @@ func NewUpdaterWindow(app fyne.App, config *config.UpdaterConfig, updateHandler configTitle, updaterWindow.ConfigDisplay, widget.NewSeparator(), + updateMethodTitle, + updaterWindow.UpdateMethodRadio, + widget.NewSeparator(), updaterWindow.UpdateButton, updaterWindow.StatusLabel, + progressSection, ) // Set window content and size window.SetContent(content) - window.Resize(fyne.NewSize(400, 300)) + window.Resize(fyne.NewSize(500, 400)) return updaterWindow } diff --git a/internal/updater/updater.go b/internal/updater/updater.go index c23ef59..74cbc8e 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -4,34 +4,68 @@ import ( "fmt" "os" "path/filepath" + "time" "gitea.unprism.ru/KRBL/FemaInstaller/internal/ssh" "gitea.unprism.ru/KRBL/FemaInstaller/pkg/config" "gitea.unprism.ru/KRBL/FemaInstaller/pkg/fileutils" + gossh "golang.org/x/crypto/ssh" +) + +// ProgressInfo contains information about the update progress +type ProgressInfo struct { + Stage string + Percentage float64 + EstimatedTimeRemaining time.Duration +} + +// ProgressCallback is a function that reports update progress +type ProgressCallback func(progress ProgressInfo) + +// UpdateMethod defines the method used for updating +type UpdateMethod int + +const ( + // UpdateMethodEmbedded uses the binary embedded in the program + UpdateMethodEmbedded UpdateMethod = iota + // UpdateMethodDirectDownload uses wget to download directly on the device + UpdateMethodDirectDownload ) // Updater handles the software update process type Updater struct { - Config *config.UpdaterConfig - BinaryData []byte + Config *config.UpdaterConfig + BinaryData []byte + UpdateMethod UpdateMethod } // NewUpdater creates a new updater with the provided configuration and binary data func NewUpdater(config *config.UpdaterConfig, binaryData []byte) *Updater { return &Updater{ - Config: config, - BinaryData: binaryData, + Config: config, + BinaryData: binaryData, + UpdateMethod: UpdateMethodEmbedded, // Default to embedded method } } +// SetUpdateMethod sets the update method to use +func (u *Updater) SetUpdateMethod(method UpdateMethod) { + u.UpdateMethod = method +} + // Update performs the software update process -func (u *Updater) Update() error { - // Save binary to temporary file - tempFile := "update_binary" - if err := os.WriteFile(tempFile, u.BinaryData, 0644); err != nil { - return fmt.Errorf("не удалось сохранить временный файл: %w", err) +func (u *Updater) Update(progressCallback ProgressCallback) error { + // If no callback is provided, use a no-op callback + if progressCallback == nil { + progressCallback = func(progress ProgressInfo) {} } - defer os.Remove(tempFile) // Clean up temporary file + + // Report initial progress + progressCallback(ProgressInfo{ + Stage: "Подготовка", + Percentage: 0, + EstimatedTimeRemaining: 0, + }) // Create SSH client configuration clientConfig := ssh.NewClientConfig( @@ -42,12 +76,44 @@ func (u *Updater) Update() error { ) // Connect to SSH server + progressCallback(ProgressInfo{ + Stage: "Подключение к серверу", + Percentage: 10, + EstimatedTimeRemaining: 0, + }) + sshClient, err := ssh.CreateSSHClient(clientConfig) if err != nil { return fmt.Errorf("ошибка подключения SSH: %w", err) } defer sshClient.Close() + // Choose update method + switch u.UpdateMethod { + case UpdateMethodEmbedded: + return u.updateWithEmbeddedBinary(sshClient, clientConfig, progressCallback) + case UpdateMethodDirectDownload: + return u.updateWithDirectDownload(sshClient, progressCallback) + default: + return fmt.Errorf("неизвестный метод обновления") + } +} + +// updateWithEmbeddedBinary updates using the binary embedded in the program +func (u *Updater) updateWithEmbeddedBinary(sshClient *gossh.Client, clientConfig *ssh.ClientConfig, progressCallback ProgressCallback) error { + // Save binary to temporary file + tempFile := "update_binary" + if err := os.WriteFile(tempFile, u.BinaryData, 0644); err != nil { + return fmt.Errorf("не удалось сохранить временный файл: %w", err) + } + defer os.Remove(tempFile) // Clean up temporary file + + progressCallback(ProgressInfo{ + Stage: "Создание SFTP-соединения", + Percentage: 20, + EstimatedTimeRemaining: 0, + }) + // Create SFTP client sftpClient, err := ssh.CreateSFTPClient(clientConfig) if err != nil { @@ -55,12 +121,70 @@ func (u *Updater) Update() error { } defer sftpClient.Close() - // Upload binary + progressCallback(ProgressInfo{ + Stage: "Загрузка файла", + Percentage: 30, + EstimatedTimeRemaining: 0, + }) + + // Upload binary with progress reporting remotePath := "/root/fema/build.new" - if err = fileutils.UploadFile(sftpClient, tempFile, remotePath); err != nil { + + // Create a file upload progress callback + uploadProgressCallback := func(percentage float64, estimatedTimeRemaining time.Duration) { + // Map the upload progress (0-100%) to the overall progress (30-80%) + overallPercentage := 30 + (percentage * 0.5) + + progressCallback(ProgressInfo{ + Stage: "Загрузка файла", + Percentage: overallPercentage, + EstimatedTimeRemaining: estimatedTimeRemaining, + }) + } + + if err = fileutils.UploadFileWithProgress(sftpClient, tempFile, remotePath, uploadProgressCallback); err != nil { return fmt.Errorf("ошибка загрузки файла: %w", err) } + return u.finalizeUpdate(sshClient, progressCallback) +} + +// updateWithDirectDownload updates by downloading directly on the device using wget +func (u *Updater) updateWithDirectDownload(sshClient *gossh.Client, progressCallback ProgressCallback) error { + if u.Config.DownloadURL == "" { + return fmt.Errorf("URL загрузки не указан в конфигурации") + } + + progressCallback(ProgressInfo{ + Stage: "Загрузка файла на устройство", + Percentage: 30, + EstimatedTimeRemaining: 0, + }) + + // Download file directly on the device using wget + downloadCmd := fmt.Sprintf("wget -O /root/fema/build.new %s", u.Config.DownloadURL) + + if err := ssh.ExecuteCommand(sshClient, downloadCmd); err != nil { + return fmt.Errorf("ошибка загрузки файла на устройство: %w", err) + } + + progressCallback(ProgressInfo{ + Stage: "Загрузка файла на устройство", + Percentage: 80, + EstimatedTimeRemaining: 0, + }) + + return u.finalizeUpdate(sshClient, progressCallback) +} + +// finalizeUpdate applies the update by moving files and restarting the service +func (u *Updater) finalizeUpdate(sshClient *gossh.Client, progressCallback ProgressCallback) error { + progressCallback(ProgressInfo{ + Stage: "Применение обновления", + Percentage: 80, + EstimatedTimeRemaining: 0, + }) + // Execute update commands commands := []string{ "mv -f /root/fema/build.new /root/fema/build", @@ -68,12 +192,28 @@ func (u *Updater) Update() error { "systemctl restart fema.service", } - for _, cmd := range commands { + for i, cmd := range commands { + // Calculate progress for each command (80-100%) + cmdProgress := 80 + float64(i+1)*20/float64(len(commands)) + + progressCallback(ProgressInfo{ + Stage: "Применение обновления", + Percentage: cmdProgress, + EstimatedTimeRemaining: 0, + }) + if err := ssh.ExecuteCommand(sshClient, cmd); err != nil { return fmt.Errorf("ошибка выполнения команды '%s': %w", cmd, err) } } + // Report completion + progressCallback(ProgressInfo{ + Stage: "Завершено", + Percentage: 100, + EstimatedTimeRemaining: 0, + }) + return nil } diff --git a/pkg/config/updater_config.go b/pkg/config/updater_config.go index cfea5be..521982f 100644 --- a/pkg/config/updater_config.go +++ b/pkg/config/updater_config.go @@ -8,19 +8,21 @@ import ( // UpdaterConfig holds the configuration for the updater application type UpdaterConfig struct { - IP string `json:"ip"` - Port string `json:"port"` - Login string `json:"login"` - Password string `json:"password"` + IP string `json:"ip"` + Port string `json:"port"` + Login string `json:"login"` + Password string `json:"password"` + DownloadURL string `json:"downloadUrl"` } // NewUpdaterConfig creates a new updater configuration with default values func NewUpdaterConfig() *UpdaterConfig { return &UpdaterConfig{ - IP: "127.0.0.1", - Port: "22", - Login: "root", - Password: "", + IP: "127.0.0.1", + Port: "22", + Login: "root", + Password: "", + DownloadURL: "http://example.com/fema/build", } } @@ -69,5 +71,5 @@ func SaveUpdaterConfig(config *UpdaterConfig, filePath string) error { // String returns a string representation of the updater configuration func (c *UpdaterConfig) String() string { - return fmt.Sprintf("IP: %s\nПорт: %s\nЛогин: %s\nПароль: %s", c.IP, c.Port, c.Login, c.Password) -} \ No newline at end of file + return fmt.Sprintf("IP: %s\nПорт: %s\nЛогин: %s\nПароль: %s\nURL загрузки: %s", c.IP, c.Port, c.Login, c.Password, c.DownloadURL) +} diff --git a/pkg/fileutils/fileutils.go b/pkg/fileutils/fileutils.go index 8602deb..4bcfc76 100644 --- a/pkg/fileutils/fileutils.go +++ b/pkg/fileutils/fileutils.go @@ -4,12 +4,21 @@ import ( "fmt" "io" "os" + "time" "github.com/pkg/sftp" ) +// ProgressCallback is a function that reports upload progress +type ProgressCallback func(percentage float64, estimatedTimeRemaining time.Duration) + // UploadFile uploads a local file to a remote server using SFTP func UploadFile(client *sftp.Client, localPath, remotePath string) error { + return UploadFileWithProgress(client, localPath, remotePath, nil) +} + +// UploadFileWithProgress uploads a local file to a remote server using SFTP and reports progress +func UploadFileWithProgress(client *sftp.Client, localPath, remotePath string, progressCallback ProgressCallback) error { // Open the local file localFile, err := os.Open(localPath) if err != nil { @@ -17,6 +26,13 @@ func UploadFile(client *sftp.Client, localPath, remotePath string) error { } defer localFile.Close() + // Get file size + fileInfo, err := localFile.Stat() + if err != nil { + return fmt.Errorf("failed to get file info: %w", err) + } + fileSize := fileInfo.Size() + // Create the remote file remoteFile, err := client.Create(remotePath) if err != nil { @@ -24,10 +40,54 @@ func UploadFile(client *sftp.Client, localPath, remotePath string) error { } defer remoteFile.Close() - // Copy from the local file to the remote file - if _, err = io.Copy(remoteFile, localFile); err != nil { - return fmt.Errorf("failed to upload file: %w", err) + // If no progress callback is provided, just copy the file + if progressCallback == nil { + if _, err = io.Copy(remoteFile, localFile); err != nil { + return fmt.Errorf("failed to upload file: %w", err) + } + return nil + } + + // Copy with progress reporting + buffer := make([]byte, 32*1024) // 32KB buffer + var totalBytesWritten int64 + startTime := time.Now() + + for { + bytesRead, readErr := localFile.Read(buffer) + if bytesRead > 0 { + bytesWritten, writeErr := remoteFile.Write(buffer[:bytesRead]) + if writeErr != nil { + return fmt.Errorf("failed to write to remote file: %w", writeErr) + } + totalBytesWritten += int64(bytesWritten) + + // Calculate progress percentage + percentage := float64(totalBytesWritten) / float64(fileSize) * 100 + + // Calculate estimated time remaining + elapsed := time.Since(startTime) + var estimatedTimeRemaining time.Duration + if totalBytesWritten > 0 { + bytesPerSecond := float64(totalBytesWritten) / elapsed.Seconds() + if bytesPerSecond > 0 { + remainingBytes := fileSize - totalBytesWritten + estimatedSeconds := float64(remainingBytes) / bytesPerSecond + estimatedTimeRemaining = time.Duration(estimatedSeconds * float64(time.Second)) + } + } + + // Report progress + progressCallback(percentage, estimatedTimeRemaining) + } + + if readErr != nil { + if readErr == io.EOF { + break + } + return fmt.Errorf("failed to read from local file: %w", readErr) + } } return nil -} \ No newline at end of file +}