diff --git a/setup/internal/installer/assets.go b/setup/internal/installer/assets.go index 3a12ea5fe..7a284defe 100644 --- a/setup/internal/installer/assets.go +++ b/setup/internal/installer/assets.go @@ -15,7 +15,7 @@ import ( // use this so you can replace the assets const limboAssetsEnv = "SKYBLOCK_WORLD_ASSETS" -func DownloadLimboAssets(ctx context.Context, installDir string) error { +func DownloadLimboAssets(ctx context.Context, installDir string, events chan<- Event) error { configDir := filepath.Join(installDir, "configuration") if LimboAssetsPresent(configDir) { return nil @@ -25,13 +25,14 @@ func DownloadLimboAssets(ctx context.Context, installDir string) error { return importLocalLimboAssets(localPath, configDir) } - reqCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + reqCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, LimboAssetsURL, nil) if err != nil { return err } + req.Header.Set("User-Agent", "skyblock-installer/"+Version) resp, err := http.DefaultClient.Do(req) if err != nil { return err @@ -41,6 +42,12 @@ func DownloadLimboAssets(ctx context.Context, installDir string) error { if resp.StatusCode < 200 || resp.StatusCode > 299 { return fmt.Errorf("download returned %s", resp.Status) } + emitAssetProgress(events, "Downloading world assets from %s", LimboAssetsURL) + if resp.ContentLength > 0 { + emitAssetProgress(events, "Remote asset size: %s", formatBytes(resp.ContentLength)) + } else { + emitAssetProgress(events, "Remote asset size is unknown; showing downloaded bytes") + } tmp, err := os.CreateTemp("", "skyblock-limbo-assets-*.zip") if err != nil { @@ -49,7 +56,7 @@ func DownloadLimboAssets(ctx context.Context, installDir string) error { tmpPath := tmp.Name() defer os.Remove(tmpPath) - if _, err := io.Copy(tmp, resp.Body); err != nil { + if err := copyWithProgress(reqCtx, cancel, tmp, resp.Body, resp.ContentLength, events); err != nil { _ = tmp.Close() return err } @@ -57,7 +64,14 @@ func DownloadLimboAssets(ctx context.Context, installDir string) error { return err } - return extractZip(tmpPath, configDir) + emitAssetProgress(events, "Extracting world assets") + if err := extractZip(tmpPath, configDir); err != nil { + return err + } + if !LimboAssetsPresent(configDir) { + return fmt.Errorf("downloaded zip did not contain limbo.schem and world/") + } + return nil } func LimboAssetsPresent(configDir string) bool { @@ -80,9 +94,132 @@ func importLocalLimboAssets(path, configDir string) error { return err } if info.IsDir() { - return CopyDir(path, configDir) + if err := CopyDir(path, configDir); err != nil { + return err + } + } else if err := extractZip(path, configDir); err != nil { + return err + } + if !LimboAssetsPresent(configDir) { + return fmt.Errorf("local assets did not contain limbo.schem and world/") + } + return nil +} + +func copyWithProgress(ctx context.Context, cancel context.CancelFunc, dst io.Writer, src io.Reader, total int64, events chan<- Event) error { + buf := make([]byte, 256*1024) + var downloaded int64 + lastEmit := time.Now().Add(-time.Minute) + progress := make(chan struct{}, 1) + done := make(chan struct{}) + defer close(done) + + go func() { + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + for { + select { + case <-timer.C: + cancel() + return + case <-progress: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(30 * time.Second) + case <-done: + return + } + } + }() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("download stalled or timed out after %s: %w", formatBytes(downloaded), ctx.Err()) + default: + } + + n, readErr := src.Read(buf) + if n > 0 { + written, writeErr := dst.Write(buf[:n]) + if writeErr != nil { + return writeErr + } + if written != n { + return io.ErrShortWrite + } + downloaded += int64(n) + select { + case progress <- struct{}{}: + default: + } + if shouldEmitProgress(downloaded, total, lastEmit) { + emitDownloadProgress(events, downloaded, total) + lastEmit = time.Now() + } + } + if readErr == io.EOF { + if downloaded == 0 { + return fmt.Errorf("download returned no data") + } + emitDownloadProgress(events, downloaded, total) + return nil + } + if readErr != nil { + return readErr + } + } +} + +func shouldEmitProgress(downloaded, total int64, lastEmit time.Time) bool { + if time.Since(lastEmit) >= 2*time.Second { + return true + } + if total > 0 { + percent := downloaded * 100 / total + lastPercent := (downloaded - 256*1024) * 100 / total + return percent != lastPercent + } + return downloaded%(1024*1024) < 256*1024 +} + +func emitDownloadProgress(events chan<- Event, downloaded, total int64) { + if total > 0 { + percent := int(downloaded * 100 / total) + emitAssetProgress(events, "World assets %s %s/%s %d%%", progressBar(percent), formatBytes(downloaded), formatBytes(total), percent) + return + } + emitAssetProgress(events, "World assets downloaded %s", formatBytes(downloaded)) +} + +func progressBar(percent int) string { + const width = 20 + if percent < 0 { + percent = 0 + } + if percent > 100 { + percent = 100 + } + filled := percent * width / 100 + return "[" + strings.Repeat("#", filled) + strings.Repeat("-", width-filled) + "]" +} + +func formatBytes(bytes int64) string { + const mb = 1024 * 1024 + if bytes >= mb { + return fmt.Sprintf("%.1f MB", float64(bytes)/mb) + } + return fmt.Sprintf("%.0f KB", float64(bytes)/1024) +} + +func emitAssetProgress(events chan<- Event, format string, args ...any) { + if events != nil { + events <- Event{Line: fmt.Sprintf(format, args...)} } - return extractZip(path, configDir) } func extractZip(zipPath, dst string) error { diff --git a/setup/internal/installer/runner.go b/setup/internal/installer/runner.go index 83e27b52d..6f4f46254 100644 --- a/setup/internal/installer/runner.go +++ b/setup/internal/installer/runner.go @@ -86,7 +86,7 @@ func (r *Runner) install(ctx context.Context, cfg Config, sourceConfigDir string } r.emit("Checking for world assets") - if err := DownloadLimboAssets(ctx, cfg.InstallDir); err != nil { + if err := DownloadLimboAssets(ctx, cfg.InstallDir, r.Events); err != nil { r.emit("asset import failed: %v", err) r.emit("Manual fallback: %s", LimboAssetsManualHint()) } else {