From 923c47020d984a1ec224cb8b33fb12ccf721e67d Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:57:32 +0200 Subject: [PATCH] fix(launcher): robust binary download/upgrade (resume, rate-limit, UX) (#10575) * fix(launcher): resume flaky downloads, drop redundant percent, fit dialogs The binary upgrade/download flow had three rough edges: - The status label printed "Downloading... N%" right next to a progress bar already showing the percent. Replace it with a human-readable byte readout ("Downloading... 12.3 MB / 45.6 MB"). - A failed download (GitHub releases are flaky) had no recourse and always restarted from byte 0. Stream to ".part" and resume via a "Range: bytes=N-" request (handling 206/200/416), renaming to the final path only after checksum verification; on checksum failure the file is discarded so the next attempt starts clean. Add a Retry button that appears on failure and resumes from the partial file. - Progress/install dialogs were hardcoded to oversized dimensions, leaving a blank gap below "View Release Notes". Size each window to its content with a sane minimum width. Also unify the three near-identical download-progress popups into one Launcher.showDownloadProgressWindow helper (and delete a dead unused copy in ui.go) so the behaviour stays consistent across every entry point. The progress callback now reports (downloaded, total) byte counts instead of a single fraction. Resume/retry behaviour is covered by httptest-backed unit tests in release_manager_test.go. Signed-off-by: Ettore Di Giacinto * fix(launcher): resolve latest version via redirect to dodge GitHub API 403 On a fresh Linux start with no LocalAI installed, the download failed with "failed to fetch latest release: status 403". The cause is the unauthenticated api.github.com rate limit (60 requests/hour, per IP): on shared/NAT/CGNAT/cloud addresses it is exhausted almost immediately and every request 403s. Resolve the latest version by following the github.com "releases/latest" redirect instead, reading the tag from the final ".../releases/tag/" URL. That endpoint is not subject to the API rate limit. Only the version is ever consumed by callers, so the tag is sufficient. The JSON API is kept as a fallback, now honoring GITHUB_TOKEN and reporting rate-limit 403/429 clearly instead of an opaque status code. Covered by an httptest-backed unit test that asserts the redirect path is used. Signed-off-by: Ettore Di Giacinto --------- Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- cmd/launcher/internal/launcher.go | 136 +++++++---- cmd/launcher/internal/release_manager.go | 185 +++++++++++--- cmd/launcher/internal/release_manager_test.go | 225 ++++++++++++++++++ cmd/launcher/internal/systray_manager.go | 93 ++------ cmd/launcher/internal/ui.go | 93 +------- 5 files changed, 498 insertions(+), 234 deletions(-) diff --git a/cmd/launcher/internal/launcher.go b/cmd/launcher/internal/launcher.go index 291c57b45..b7776acb5 100644 --- a/cmd/launcher/internal/launcher.go +++ b/cmd/launcher/internal/launcher.go @@ -429,7 +429,7 @@ func (l *Launcher) CheckForUpdates() (bool, string, error) { } // DownloadUpdate downloads the latest version -func (l *Launcher) DownloadUpdate(version string, progressCallback func(float64)) error { +func (l *Launcher) DownloadUpdate(version string, progressCallback func(downloaded, total int64)) error { return l.releaseManager.DownloadRelease(version, progressCallback) } @@ -486,7 +486,6 @@ func (l *Launcher) showDownloadLocalAIDialog() { fyne.DoAndWait(func() { // Create a standalone window for the download dialog dialogWindow := l.app.NewWindow("LocalAI Installation Required") - dialogWindow.Resize(fyne.NewSize(500, 350)) dialogWindow.CenterOnScreen() dialogWindow.SetCloseIntercept(func() { dialogWindow.Close() @@ -548,6 +547,7 @@ func (l *Launcher) showDownloadLocalAIDialog() { ) dialogWindow.SetContent(content) + resizeToContent(dialogWindow, content) dialogWindow.Show() }) } @@ -621,88 +621,134 @@ func (l *Launcher) showDownloadError(title, message string) { } // showDownloadProgress shows a standalone progress window for downloading LocalAI +// after a fresh install (no LocalAI binary present yet). func (l *Launcher) showDownloadProgress(version, title string) { + l.showDownloadProgressWindow(version, title, func(win fyne.Window) { + dialog.ShowConfirm("Installation Complete", + "LocalAI has been downloaded and installed successfully. You can now start LocalAI from the launcher.", + func(bool) { + win.Close() + l.updateStatus("LocalAI installed successfully") + if l.systray != nil { + l.systray.recreateMenu() + } + }, win) + }) +} + +// showDownloadProgressWindow renders the download progress popup shared by every +// "download/upgrade LocalAI" entry point. It owns the progress bar, the +// human-readable byte readout, resume-aware retry, and content-fit window +// sizing so the behaviour stays identical everywhere. onSuccess runs (on the UI +// goroutine) once the download verifies, and is responsible for the success +// dialog and any follow-up; the window is passed in so it can be parented/closed. +func (l *Launcher) showDownloadProgressWindow(version, title string, onSuccess func(win fyne.Window)) { fyne.DoAndWait(func() { - // Create progress window progressWindow := l.app.NewWindow("Downloading LocalAI") - progressWindow.Resize(fyne.NewSize(400, 250)) progressWindow.CenterOnScreen() progressWindow.SetCloseIntercept(func() { progressWindow.Close() }) - // Progress bar progressBar := widget.NewProgressBar() progressBar.SetValue(0) // Status label. Truncate with an ellipsis so a long "Download failed: // " message can't stretch the window (and progress bar) to fit the - // whole error on one line; the full error is shown in the dialog below. + // whole error on one line. statusLabel := widget.NewLabel("Preparing download...") statusLabel.Truncation = fyne.TextTruncateEllipsis - // Release notes button releaseNotesButton := widget.NewButton("View Release Notes", func() { releaseNotesURL, err := l.githubReleaseNotesURL(version) if err != nil { log.Printf("Failed to parse URL: %v", err) return } - l.app.OpenURL(releaseNotesURL) }) - // Progress container - progressContainer := container.NewVBox( + // Retry button: hidden until a download fails. GitHub downloads are + // flaky, and the underlying download resumes from the partial file, so + // a retry continues where it left off rather than starting over. + retryButton := widget.NewButton("Retry", nil) + retryButton.Importance = widget.HighImportance + retryButton.Hide() + + buttonRow := container.NewHBox(releaseNotesButton, retryButton) + content := container.NewVBox( widget.NewLabel(title), progressBar, statusLabel, widget.NewSeparator(), - releaseNotesButton, + buttonRow, ) + progressWindow.SetContent(content) + resizeToContent(progressWindow, content) - progressWindow.SetContent(progressContainer) - progressWindow.Show() + var startDownload func() + startDownload = func() { + retryButton.Hide() + progressBar.SetValue(0) + statusLabel.SetText("Preparing download...") + resizeToContent(progressWindow, content) - // Start download in background - go func() { - err := l.DownloadUpdate(version, func(progress float64) { - // Update progress bar - fyne.Do(func() { - progressBar.SetValue(progress) - percentage := int(progress * 100) - statusLabel.SetText(fmt.Sprintf("Downloading... %d%%", percentage)) + go func() { + err := l.DownloadUpdate(version, func(downloaded, total int64) { + fyne.Do(func() { + if total > 0 { + progressBar.SetValue(float64(downloaded) / float64(total)) + statusLabel.SetText(fmt.Sprintf("Downloading… %s / %s", formatBytes(downloaded), formatBytes(total))) + } else { + statusLabel.SetText(fmt.Sprintf("Downloading… %s", formatBytes(downloaded))) + } + }) }) - }) - // Handle completion - fyne.Do(func() { - if err != nil { - statusLabel.SetText(fmt.Sprintf("Download failed: %v", err)) - // Show error dialog - dialog.ShowError(err, progressWindow) - } else { - statusLabel.SetText("Download completed successfully!") + fyne.Do(func() { + if err != nil { + statusLabel.SetText(fmt.Sprintf("Download failed: %v", err)) + retryButton.Show() + resizeToContent(progressWindow, content) + return + } progressBar.SetValue(1.0) + statusLabel.SetText("Download complete") + onSuccess(progressWindow) + }) + }() + } + retryButton.OnTapped = startDownload - // Show success dialog - dialog.ShowConfirm("Installation Complete", - "LocalAI has been downloaded and installed successfully. You can now start LocalAI from the launcher.", - func(close bool) { - progressWindow.Close() - // Update status and refresh systray menu - l.updateStatus("LocalAI installed successfully") - - if l.systray != nil { - l.systray.recreateMenu() - } - }, progressWindow) - } - }) - }() + progressWindow.Show() + startDownload() }) } +// resizeToContent sizes a window to fit its content (with a sane minimum width) +// so the dialog doesn't show a large blank gap below the last widget. +func resizeToContent(w fyne.Window, content fyne.CanvasObject) { + size := content.MinSize() + if size.Width < 400 { + size.Width = 400 + } + w.Resize(size) +} + +// formatBytes renders a byte count as a human-readable size (e.g. "12.3 MB"). +func formatBytes(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) +} + // monitorLogs monitors the output of LocalAI and adds it to the log buffer func (l *Launcher) monitorLogs(reader io.Reader, prefix string) { scanner := bufio.NewScanner(reader) diff --git a/cmd/launcher/internal/release_manager.go b/cmd/launcher/internal/release_manager.go index 4634d9cfa..362af0ddc 100644 --- a/cmd/launcher/internal/release_manager.go +++ b/cmd/launcher/internal/release_manager.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "os/exec" + "path" "path/filepath" "runtime" "strings" @@ -50,6 +51,12 @@ type ReleaseManager struct { ChecksumsPath string // MetadataPath is where version metadata is stored MetadataPath string + // BaseDownloadURL is the base URL release assets are downloaded from + // (defaults to https://github.com; overridable for testing) + BaseDownloadURL string + // RetryBackoff is the base wait between download attempts; the Nth retry + // waits N*RetryBackoff (defaults to 1s; lowered in tests) + RetryBackoff time.Duration // HTTPClient is the HTTP client used for downloads HTTPClient *http.Client } @@ -62,28 +69,94 @@ func NewReleaseManager() *ReleaseManager { metadataPath := filepath.Join(homeDir, ".localai", "metadata") return &ReleaseManager{ - GitHubOwner: "mudler", - GitHubRepo: "LocalAI", - BinaryPath: binaryPath, - CurrentVersion: internal.PrintableVersion(), - ChecksumsPath: checksumsPath, - MetadataPath: metadataPath, - HTTPClient: httpclient.NewWithTimeout(30*time.Second, httpclient.WithFollowRedirects()), + GitHubOwner: "mudler", + GitHubRepo: "LocalAI", + BinaryPath: binaryPath, + CurrentVersion: internal.PrintableVersion(), + ChecksumsPath: checksumsPath, + MetadataPath: metadataPath, + BaseDownloadURL: "https://github.com", + RetryBackoff: 1 * time.Second, + HTTPClient: httpclient.NewWithTimeout(30*time.Second, httpclient.WithFollowRedirects()), } } -// GetLatestRelease fetches the latest release information from GitHub +// GetLatestRelease resolves the latest LocalAI release. +// +// It first follows the github.com "releases/latest" redirect, which reveals the +// latest tag in the final URL and—crucially—is NOT subject to the +// 60-requests/hour unauthenticated rate limit of api.github.com. That limit is +// per-IP, so on shared/NAT/CGNAT/cloud addresses the API returns 403 almost +// immediately (e.g. on a fresh install with no LocalAI present yet). The +// redirect avoids that entirely. The richer JSON API is kept only as a fallback. +// +// Only the version is consumed by callers, so the redirect's tag is sufficient. func (rm *ReleaseManager) GetLatestRelease() (*Release, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", rm.GitHubOwner, rm.GitHubRepo) + version, redirectErr := rm.latestVersionFromRedirect() + if redirectErr == nil { + return &Release{Version: version}, nil + } + log.Printf("Could not resolve latest version via release redirect (%v); falling back to GitHub API", redirectErr) + + release, apiErr := rm.latestReleaseFromAPI() + if apiErr != nil { + // Surface both failures so a rate-limited API doesn't mask the (usually + // more relevant) redirect error. + return nil, fmt.Errorf("failed to fetch latest release: %v (redirect: %v)", apiErr, redirectErr) + } + return release, nil +} + +// latestVersionFromRedirect returns the latest tag by following the github.com +// "releases/latest" redirect to ".../releases/tag/". +func (rm *ReleaseManager) latestVersionFromRedirect() (string, error) { + url := fmt.Sprintf("%s/%s/%s/releases/latest", rm.BaseDownloadURL, rm.GitHubOwner, rm.GitHubRepo) resp, err := rm.HTTPClient.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status %s", resp.Status) + } + + // After the redirect is followed, the final request URL is the tag page. + version := path.Base(resp.Request.URL.Path) + if version == "" || version == "." || version == "latest" { + return "", fmt.Errorf("could not determine version from %s", resp.Request.URL.String()) + } + return version, nil +} + +// latestReleaseFromAPI fetches the latest release JSON from api.github.com. This +// is the fallback path; it is rate-limited unless GITHUB_TOKEN is set. +func (rm *ReleaseManager) latestReleaseFromAPI() (*Release, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", rm.GitHubOwner, rm.GitHubRepo) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + // An optional token lifts the unauthenticated 60/hour limit to 5000/hour. + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := rm.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch latest release: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch latest release: status %d", resp.StatusCode) + if (resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests) && + resp.Header.Get("X-RateLimit-Remaining") == "0" { + return nil, fmt.Errorf("GitHub API rate limit exceeded (status %d); retry later or set GITHUB_TOKEN to raise the limit", resp.StatusCode) + } + return nil, fmt.Errorf("status %d", resp.StatusCode) } // Parse the JSON response properly @@ -106,7 +179,7 @@ func (rm *ReleaseManager) GetLatestRelease() (*Release, error) { } // DownloadRelease downloads a specific version of LocalAI -func (rm *ReleaseManager) DownloadRelease(version string, progressCallback func(float64)) error { +func (rm *ReleaseManager) DownloadRelease(version string, progressCallback func(downloaded, total int64)) error { // Ensure the binary directory exists if err := os.MkdirAll(rm.BinaryPath, 0755); err != nil { return fmt.Errorf("failed to create binary directory: %w", err) @@ -117,16 +190,16 @@ func (rm *ReleaseManager) DownloadRelease(version string, progressCallback func( localPath := filepath.Join(rm.BinaryPath, "local-ai") // Download the binary - downloadURL := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s", - rm.GitHubOwner, rm.GitHubRepo, version, binaryName) + downloadURL := fmt.Sprintf("%s/%s/%s/releases/download/%s/%s", + rm.BaseDownloadURL, rm.GitHubOwner, rm.GitHubRepo, version, binaryName) if err := rm.downloadFile(downloadURL, localPath, progressCallback); err != nil { return fmt.Errorf("failed to download binary: %w", err) } // Download and verify checksums - checksumURL := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/LocalAI-%s-checksums.txt", - rm.GitHubOwner, rm.GitHubRepo, version, version) + checksumURL := fmt.Sprintf("%s/%s/%s/releases/download/%s/LocalAI-%s-checksums.txt", + rm.BaseDownloadURL, rm.GitHubOwner, rm.GitHubRepo, version, version) checksumPath := filepath.Join(rm.BinaryPath, "checksums.txt") manualChecksumPath := filepath.Join(rm.ChecksumsPath, fmt.Sprintf("checksums-%s.txt", version)) @@ -154,6 +227,10 @@ func (rm *ReleaseManager) DownloadRelease(version string, progressCallback func( // Verify the checksum if we have a checksum file if _, err := os.Stat(checksumPath); err == nil { if err := rm.VerifyChecksum(localPath, checksumPath, binaryName); err != nil { + // Discard the corrupt binary (and any leftover partial) so the next + // retry starts from a clean slate rather than resuming corruption. + os.Remove(localPath) + os.Remove(localPath + ".part") return fmt.Errorf("checksum verification failed: %w", err) } log.Printf("Checksum verification successful") @@ -196,44 +273,88 @@ func (rm *ReleaseManager) GetBinaryName(version string) string { } // downloadFile downloads a file from a URL to a local path with optional progress callback -func (rm *ReleaseManager) downloadFile(url, filepath string, progressCallback func(float64)) error { +func (rm *ReleaseManager) downloadFile(url, filepath string, progressCallback func(downloaded, total int64)) error { return rm.downloadFileWithRetry(url, filepath, progressCallback, 3) } -// downloadFileWithRetry downloads a file from a URL with retry logic -func (rm *ReleaseManager) downloadFileWithRetry(url, filepath string, progressCallback func(float64), maxRetries int) error { +// downloadFileWithRetry downloads a file with retry and HTTP Range resume. +// +// The body is streamed to ".part" and only renamed to dest on success, so +// a dropped connection leaves a partial file that the next attempt continues via +// a "Range: bytes=N-" request instead of restarting from zero. This matters for +// GitHub release downloads, which are large and flaky. +func (rm *ReleaseManager) downloadFileWithRetry(url, dest string, progressCallback func(downloaded, total int64), maxRetries int) error { + partPath := dest + ".part" var lastErr error for attempt := 1; attempt <= maxRetries; attempt++ { if attempt > 1 { log.Printf("Retrying download (attempt %d/%d): %s", attempt, maxRetries, url) - time.Sleep(time.Duration(attempt) * time.Second) + time.Sleep(time.Duration(attempt) * rm.RetryBackoff) } - resp, err := rm.HTTPClient.Get(url) + // Resume from however much we already have on disk. + var offset int64 + if fi, err := os.Stat(partPath); err == nil { + offset = fi.Size() + } + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return err + } + if offset > 0 { + req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset)) + } + + resp, err := rm.HTTPClient.Do(req) if err != nil { lastErr = err continue } - if resp.StatusCode != http.StatusOK { + switch resp.StatusCode { + case http.StatusOK: + // Server ignored the Range (or we had nothing): start fresh. + offset = 0 + case http.StatusPartialContent: + // Resume: append to the existing partial file. + case http.StatusRequestedRangeNotSatisfiable: + // Stale or already-complete partial: discard and restart fresh. + resp.Body.Close() + os.Remove(partPath) + lastErr = fmt.Errorf("partial download no longer valid (status %s), restarting", resp.Status) + continue + default: resp.Body.Close() lastErr = fmt.Errorf("bad status: %s", resp.Status) continue } - out, err := os.Create(filepath) + var out *os.File + if offset > 0 { + out, err = os.OpenFile(partPath, os.O_WRONLY|os.O_APPEND, 0644) + } else { + out, err = os.Create(partPath) + } if err != nil { resp.Body.Close() return err } - // Create a progress reader if callback is provided + // On a 206 the Content-Length is the remaining bytes, so the full size + // is what we already have plus what's still to come. + total := resp.ContentLength + if offset > 0 && total > 0 { + total += offset + } + var reader io.Reader = resp.Body - if progressCallback != nil && resp.ContentLength > 0 { + if progressCallback != nil && total > 0 { reader = &progressReader{ Reader: resp.Body, - Total: resp.ContentLength, + Total: total, + Current: offset, Callback: progressCallback, } } @@ -243,11 +364,14 @@ func (rm *ReleaseManager) downloadFileWithRetry(url, filepath string, progressCa out.Close() if err != nil { + // Keep the partial file so the next attempt can resume from it. lastErr = err - os.Remove(filepath) continue } + if err := os.Rename(partPath, dest); err != nil { + return err + } return nil } @@ -322,20 +446,21 @@ func (rm *ReleaseManager) saveVersionMetadata(version string) error { return nil } -// progressReader wraps an io.Reader to provide download progress +// progressReader wraps an io.Reader to provide download progress as a +// (downloaded, total) byte count so callers can render both a progress bar and +// a human-readable size. type progressReader struct { io.Reader Total int64 Current int64 - Callback func(float64) + Callback func(downloaded, total int64) } func (pr *progressReader) Read(p []byte) (int, error) { n, err := pr.Reader.Read(p) pr.Current += int64(n) if pr.Callback != nil { - progress := float64(pr.Current) / float64(pr.Total) - pr.Callback(progress) + pr.Callback(pr.Current, pr.Total) } return n, err } diff --git a/cmd/launcher/internal/release_manager_test.go b/cmd/launcher/internal/release_manager_test.go index f6de6aa5a..0dd3be212 100644 --- a/cmd/launcher/internal/release_manager_test.go +++ b/cmd/launcher/internal/release_manager_test.go @@ -1,9 +1,17 @@ package launcher_test import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "runtime" + "strconv" + "strings" + "sync" "time" . "github.com/onsi/ginkgo/v2" @@ -178,4 +186,221 @@ var _ = Describe("ReleaseManager", func() { Expect(err.Error()).To(ContainSubstring("checksum not found")) }) }) + + Describe("DownloadRelease resume and retry", func() { + var ( + version string + binaryName string + content []byte + checksums string + finalPath string + partPath string + ) + + BeforeEach(func() { + version = "v9.9.9" + binaryName = rm.GetBinaryName(version) + + // Deterministic, non-trivial content so resume/append bugs surface. + content = make([]byte, 4096) + for i := range content { + content[i] = byte(i % 251) + } + sum := sha256.Sum256(content) + checksums = fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), binaryName) + + finalPath = filepath.Join(tempDir, "local-ai") + partPath = finalPath + ".part" + + // Isolate the persistent checksum/metadata dirs to the temp dir so + // the test never touches the real ~/.localai and existing checksum + // files don't short-circuit the download. + rm.ChecksumsPath = filepath.Join(tempDir, "checksums") + rm.MetadataPath = filepath.Join(tempDir, "metadata") + rm.GitHubOwner = "owner" + rm.GitHubRepo = "repo" + rm.RetryBackoff = time.Millisecond + + Expect(os.MkdirAll(tempDir, 0755)).To(Succeed()) + }) + + It("resumes from a partial .part file using a Range request", func() { + Expect(os.WriteFile(partPath, content[:1024], 0644)).To(Succeed()) + + var mu sync.Mutex + sawRange := false + binBytesServed := 0 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "checksums.txt") { + _, _ = w.Write([]byte(checksums)) + return + } + if rangeHdr := r.Header.Get("Range"); rangeHdr != "" { + var start int + _, _ = fmt.Sscanf(rangeHdr, "bytes=%d-", &start) + mu.Lock() + sawRange = true + mu.Unlock() + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(content)-1, len(content))) + w.WriteHeader(http.StatusPartialContent) + n, _ := w.Write(content[start:]) + mu.Lock() + binBytesServed += n + mu.Unlock() + return + } + w.WriteHeader(http.StatusOK) + n, _ := w.Write(content) + mu.Lock() + binBytesServed += n + mu.Unlock() + })) + defer srv.Close() + rm.BaseDownloadURL = srv.URL + + err := rm.DownloadRelease(version, nil) + Expect(err).ToNot(HaveOccurred()) + + got, err := os.ReadFile(finalPath) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(content)) + Expect(sawRange).To(BeTrue(), "expected the download to resume with a Range request") + Expect(binBytesServed).To(Equal(len(content)-1024), "expected only the remaining bytes to be served") + Expect(partPath).ToNot(BeAnExistingFile()) + }) + + It("starts fresh when the server ignores the Range header (200)", func() { + // A stale/garbage partial that must NOT be appended to. + Expect(os.WriteFile(partPath, []byte("garbage-garbage-garbage"), 0644)).To(Succeed()) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "checksums.txt") { + _, _ = w.Write([]byte(checksums)) + return + } + // Ignore any Range and always serve the full body. + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + })) + defer srv.Close() + rm.BaseDownloadURL = srv.URL + + err := rm.DownloadRelease(version, nil) + Expect(err).ToNot(HaveOccurred()) + + got, err := os.ReadFile(finalPath) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(content)) + }) + + It("restarts the download when the partial is stale (416)", func() { + // Oversized partial -> requested Range start is beyond the content. + Expect(os.WriteFile(partPath, make([]byte, len(content)+10), 0644)).To(Succeed()) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "checksums.txt") { + _, _ = w.Write([]byte(checksums)) + return + } + if rangeHdr := r.Header.Get("Range"); rangeHdr != "" { + var start int + _, _ = fmt.Sscanf(rangeHdr, "bytes=%d-", &start) + if start >= len(content) { + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return + } + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(content)-1, len(content))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[start:]) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + })) + defer srv.Close() + rm.BaseDownloadURL = srv.URL + + err := rm.DownloadRelease(version, nil) + Expect(err).ToNot(HaveOccurred()) + + got, err := os.ReadFile(finalPath) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(content)) + }) + + It("removes the downloaded file when checksum verification fails", func() { + bad := []byte("this is definitely not the expected binary content") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "checksums.txt") { + // Checksums are for `content`, but we serve `bad`. + _, _ = w.Write([]byte(checksums)) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(bad) + })) + defer srv.Close() + rm.BaseDownloadURL = srv.URL + + err := rm.DownloadRelease(version, nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("checksum")) + Expect(finalPath).ToNot(BeAnExistingFile()) + Expect(partPath).ToNot(BeAnExistingFile()) + }) + + It("reports progress as downloaded and total byte counts", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "checksums.txt") { + _, _ = w.Write([]byte(checksums)) + return + } + w.Header().Set("Content-Length", strconv.Itoa(len(content))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + })) + defer srv.Close() + rm.BaseDownloadURL = srv.URL + + var mu sync.Mutex + var lastDownloaded, lastTotal int64 + err := rm.DownloadRelease(version, func(downloaded, total int64) { + mu.Lock() + lastDownloaded = downloaded + lastTotal = total + mu.Unlock() + }) + Expect(err).ToNot(HaveOccurred()) + Expect(lastTotal).To(Equal(int64(len(content)))) + Expect(lastDownloaded).To(Equal(int64(len(content)))) + }) + }) + + Describe("GetLatestRelease", func() { + It("resolves the latest version from the releases/latest redirect", func() { + // The github.com redirect path must be preferred over the + // rate-limited api.github.com, so a working redirect yields the tag + // without ever needing the API. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/releases/latest"): + http.Redirect(w, r, "/owner/repo/releases/tag/v9.9.9", http.StatusFound) + case strings.HasSuffix(r.URL.Path, "/releases/tag/v9.9.9"): + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + rm.BaseDownloadURL = srv.URL + rm.GitHubOwner = "owner" + rm.GitHubRepo = "repo" + + release, err := rm.GetLatestRelease() + Expect(err).ToNot(HaveOccurred()) + Expect(release.Version).To(Equal("v9.9.9")) + }) + }) }) diff --git a/cmd/launcher/internal/systray_manager.go b/cmd/launcher/internal/systray_manager.go index 8188c923f..ba0fb2385 100644 --- a/cmd/launcher/internal/systray_manager.go +++ b/cmd/launcher/internal/systray_manager.go @@ -443,84 +443,23 @@ func (sm *SystrayManager) showStartupErrorDialog(err error) { }) } -// showDownloadProgress shows a progress window for downloading updates +// showDownloadProgress shows a progress window for downloading updates. The +// progress UI (byte readout, resume-aware retry, sizing) is shared with the +// other download entry points via the launcher; only the post-success behaviour +// (restart prompt + systray refresh) is specific to the update flow. func (sm *SystrayManager) showDownloadProgress(version string) { - // Create a new window for download progress - progressWindow := sm.app.NewWindow("Downloading LocalAI Update") - progressWindow.Resize(fyne.NewSize(400, 250)) - progressWindow.CenterOnScreen() + sm.launcher.showDownloadProgressWindow(version, fmt.Sprintf("Downloading LocalAI version %s", version), func(win fyne.Window) { + dialog.ShowConfirm("Update Downloaded", + "LocalAI has been updated successfully. Please restart the launcher to use the new version.", + func(restart bool) { + if restart { + sm.app.Quit() + } + win.Close() + }, win) - // Progress bar - progressBar := widget.NewProgressBar() - progressBar.SetValue(0) - - // Status label. Truncate with an ellipsis so a long "Download failed: - // " message can't stretch the window (and progress bar) to fit the - // whole error on one line; the full error is shown in the dialog below. - statusLabel := widget.NewLabel("Preparing download...") - statusLabel.Truncation = fyne.TextTruncateEllipsis - - // Release notes button - releaseNotesButton := widget.NewButton("View Release Notes", func() { - releaseNotesURL, err := sm.launcher.githubReleaseNotesURL(version) - if err != nil { - log.Printf("Failed to parse URL: %v", err) - return - } - - sm.app.OpenURL(releaseNotesURL) + sm.hasUpdateAvailable = false + sm.latestVersion = "" + sm.recreateMenu() }) - - // Progress container - progressContainer := container.NewVBox( - widget.NewLabel(fmt.Sprintf("Downloading LocalAI version %s", version)), - progressBar, - statusLabel, - widget.NewSeparator(), - releaseNotesButton, - ) - - progressWindow.SetContent(progressContainer) - progressWindow.Show() - - // Start download in background - go func() { - err := sm.launcher.DownloadUpdate(version, func(progress float64) { - // Update progress bar - fyne.Do(func() { - progressBar.SetValue(progress) - percentage := int(progress * 100) - statusLabel.SetText(fmt.Sprintf("Downloading... %d%%", percentage)) - }) - }) - - // Handle completion - fyne.Do(func() { - if err != nil { - statusLabel.SetText(fmt.Sprintf("Download failed: %v", err)) - // Show error dialog - dialog.ShowError(err, progressWindow) - } else { - statusLabel.SetText("Download completed successfully!") - progressBar.SetValue(1.0) - - // Show restart dialog - dialog.ShowConfirm("Update Downloaded", - "LocalAI has been updated successfully. Please restart the launcher to use the new version.", - func(restart bool) { - if restart { - sm.app.Quit() - } - progressWindow.Close() - }, progressWindow) - } - }) - - // Update systray menu - if err == nil { - sm.hasUpdateAvailable = false - sm.latestVersion = "" - sm.recreateMenu() - } - }() } diff --git a/cmd/launcher/internal/ui.go b/cmd/launcher/internal/ui.go index 422238eb7..a3f19a146 100644 --- a/cmd/launcher/internal/ui.go +++ b/cmd/launcher/internal/ui.go @@ -490,14 +490,19 @@ func (ui *LauncherUI) downloadUpdate() { ui.UpdateStatus("Downloading update " + version + "...") go func() { - err := ui.launcher.DownloadUpdate(version, func(progress float64) { - // Update progress bar + err := ui.launcher.DownloadUpdate(version, func(downloaded, total int64) { fyne.Do(func() { - ui.progressBar.SetValue(progress) + if total > 0 { + ui.progressBar.SetValue(float64(downloaded) / float64(total)) + } }) - // Update status with percentage - percentage := int(progress * 100) - ui.UpdateStatus(fmt.Sprintf("Downloading update %s... %d%%", version, percentage)) + // The progress bar already shows the percentage, so report the + // human-readable size here instead of repeating the percent. + if total > 0 { + ui.UpdateStatus(fmt.Sprintf("Downloading update %s… %s / %s", version, formatBytes(downloaded), formatBytes(total))) + } else { + ui.UpdateStatus(fmt.Sprintf("Downloading update %s… %s", version, formatBytes(downloaded))) + } }) fyne.Do(func() { @@ -598,82 +603,6 @@ func (ui *LauncherUI) LoadConfiguration() { log.Printf("UI LoadConfiguration: configuration loaded successfully") } -// showDownloadProgress shows a progress window for downloading LocalAI -func (ui *LauncherUI) showDownloadProgress(version, title string) { - fyne.DoAndWait(func() { - // Create progress window using the launcher's app - progressWindow := ui.launcher.app.NewWindow("Downloading LocalAI") - progressWindow.Resize(fyne.NewSize(400, 250)) - progressWindow.CenterOnScreen() - - // Progress bar - progressBar := widget.NewProgressBar() - progressBar.SetValue(0) - - // Status label. Truncate with an ellipsis so a long "Download failed: - // " message can't stretch the window (and progress bar) to fit the - // whole error on one line; the full error is shown in the dialog below. - statusLabel := widget.NewLabel("Preparing download...") - statusLabel.Truncation = fyne.TextTruncateEllipsis - - // Release notes button - releaseNotesButton := widget.NewButton("View Release Notes", func() { - releaseNotesURL, err := ui.launcher.githubReleaseNotesURL(version) - if err != nil { - log.Printf("Failed to parse URL: %v", err) - return - } - - ui.launcher.app.OpenURL(releaseNotesURL) - }) - - // Progress container - progressContainer := container.NewVBox( - widget.NewLabel(title), - progressBar, - statusLabel, - widget.NewSeparator(), - releaseNotesButton, - ) - - progressWindow.SetContent(progressContainer) - progressWindow.Show() - - // Start download in background - go func() { - err := ui.launcher.DownloadUpdate(version, func(progress float64) { - // Update progress bar - fyne.Do(func() { - progressBar.SetValue(progress) - percentage := int(progress * 100) - statusLabel.SetText(fmt.Sprintf("Downloading... %d%%", percentage)) - }) - }) - - // Handle completion - fyne.Do(func() { - if err != nil { - statusLabel.SetText(fmt.Sprintf("Download failed: %v", err)) - // Show error dialog - dialog.ShowError(err, progressWindow) - } else { - statusLabel.SetText("Download completed successfully!") - progressBar.SetValue(1.0) - - // Show success dialog - dialog.ShowConfirm("Installation Complete", - "LocalAI has been downloaded and installed successfully. You can now start LocalAI from the launcher.", - func(close bool) { - progressWindow.Close() - // Update status - ui.UpdateStatus("LocalAI installed successfully") - }, progressWindow) - } - }) - }() - }) -} - // UpdateRunningState updates UI based on LocalAI running state func (ui *LauncherUI) UpdateRunningState(isRunning bool) { fyne.Do(func() {