diff --git a/cmd/appherder/cli.go b/cmd/appherder/cli.go index c1f87a1..e532f8c 100644 --- a/cmd/appherder/cli.go +++ b/cmd/appherder/cli.go @@ -52,8 +52,9 @@ func newInstallCommand(a appherder.App) *cobra.Command { var name string var err error if isURL(arg) { - fmt.Fprintf(cmd.OutOrStdout(), "downloading %s...\n", arg) - name, err = a.InstallFromURL(cmd.Context(), arg) + progress := newCliProgress(cmd.ErrOrStderr()) + name, err = a.WithProgress(progress).InstallFromURL(cmd.Context(), arg) + progress.Done() } else { name, err = a.Install(arg) } @@ -168,7 +169,9 @@ func newUpgradeCommand(a appherder.App) *cobra.Command { printUpgradeChecks(out, checks) return nil } - applied := a.ApplyUpgrades(cmd.Context(), checks) + progress := newCliProgress(cmd.ErrOrStderr()) + applied := a.WithProgress(progress).ApplyUpgrades(cmd.Context(), checks) + progress.Done() printUpgradeApplied(out, checks, applied) return nil }, diff --git a/cmd/appherder/progress.go b/cmd/appherder/progress.go new file mode 100644 index 0000000..2920c67 --- /dev/null +++ b/cmd/appherder/progress.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "io" +) + +type cliProgress struct { + out io.Writer +} + +func newCliProgress(out io.Writer) *cliProgress { + return &cliProgress{out: out} +} + +func (p *cliProgress) Download(name string, received, total int64) { + if total > 0 { + percent := received * 100 / total + fmt.Fprintf(p.out, "\r%s: %s / %s (%d%%)", truncate(name), humanSize(received), humanSize(total), percent) + return + } + fmt.Fprintf(p.out, "\r%s: %s", truncate(name), humanSize(received)) +} + +func (p *cliProgress) Done() { + fmt.Fprint(p.out, "\r\033[K") +} + +func truncate(name string) string { + if len(name) > 20 { + return name[:17] + "..." + } + return name +} diff --git a/internal/appherder/app.go b/internal/appherder/app.go index 2b8a66c..a1c4058 100644 --- a/internal/appherder/app.go +++ b/internal/appherder/app.go @@ -15,6 +15,7 @@ type App struct { applicationsDir string iconsDir string binDir string + progress Progress } // NewApp returns an App wired to the current user's home directory. The @@ -43,3 +44,9 @@ func NewAppWithDirs(appimagesDir, applicationsDir, iconsDir, binDir string) App binDir: binDir, } } + +// WithProgress returns a copy of App that reports download progress to p. +func (a App) WithProgress(p Progress) App { + a.progress = p + return a +} diff --git a/internal/appherder/install.go b/internal/appherder/install.go index 0e4289a..a4335b2 100644 --- a/internal/appherder/install.go +++ b/internal/appherder/install.go @@ -106,7 +106,7 @@ func (a App) install(appimage string, want expectedChecksum) (appName string, er // InstallFromURL downloads an AppImage from url and installs it. func (a App) InstallFromURL(ctx context.Context, url string) (string, error) { - tmpName, err := downloadToTemp(ctx, url, "appherder-install") + tmpName, err := downloadToTemp(ctx, url, "appherder-install", a.progress, url) if err != nil { return "", err } diff --git a/internal/appherder/progress.go b/internal/appherder/progress.go new file mode 100644 index 0000000..3b03d07 --- /dev/null +++ b/internal/appherder/progress.go @@ -0,0 +1,31 @@ +package appherder + +import "io" + +// Progress receives download progress updates. total is -1 when the server +// reports no content length. +type Progress interface { + Download(name string, received, total int64) +} + +type progressReader struct { + reader io.Reader + progress Progress + name string + total int64 + received int64 +} + +func newProgressReader(reader io.Reader, progress Progress, name string, total int64) io.Reader { + if progress == nil { + return reader + } + return &progressReader{reader: reader, progress: progress, name: name, total: total} +} + +func (pr *progressReader) Read(buf []byte) (int, error) { + bytesRead, err := pr.reader.Read(buf) + pr.received += int64(bytesRead) + pr.progress.Download(pr.name, pr.received, pr.total) + return bytesRead, err +} diff --git a/internal/appherder/upgrade.go b/internal/appherder/upgrade.go index a23fb8a..513820a 100644 --- a/internal/appherder/upgrade.go +++ b/internal/appherder/upgrade.go @@ -53,7 +53,7 @@ func (a App) ApplyUpgrades(ctx context.Context, checks []UpgradeCheck) []Upgrade if check.Err != nil || check.NoSource || !check.Available { continue } - err := a.applyUpgrade(ctx, check.Release) + err := a.applyUpgrade(ctx, check.Name, check.Release) applied = append(applied, UpgradeApplied{ Name: check.Name, Version: check.Release.Version, @@ -109,8 +109,8 @@ func parallelMap[T, R any](ctx context.Context, items []T, limit int, fn func(co return results } -func (a App) applyUpgrade(ctx context.Context, rel Release) error { - tmpName, err := downloadToTemp(ctx, rel.URL, "appherder-upgrade") +func (a App) applyUpgrade(ctx context.Context, name string, rel Release) error { + tmpName, err := downloadToTemp(ctx, rel.URL, "appherder-upgrade", a.progress, name) if err != nil { return err } @@ -122,13 +122,13 @@ func (a App) applyUpgrade(ctx context.Context, rel Release) error { // downloadToTemp downloads url to a temporary file and returns its path. The // caller must remove the file. -func downloadToTemp(ctx context.Context, url, prefix string) (string, error) { +func downloadToTemp(ctx context.Context, url, prefix string, progress Progress, name string) (string, error) { tmp, err := os.CreateTemp("", prefix+"-*.appimage") if err != nil { return "", fmt.Errorf("create temporary file: %w", err) } tmpName := tmp.Name() - if err := download(ctx, url, tmp); err != nil { + if err := download(ctx, url, tmp, progress, name); err != nil { tmp.Close() os.Remove(tmpName) return "", err @@ -140,7 +140,7 @@ func downloadToTemp(ctx context.Context, url, prefix string) (string, error) { return tmpName, nil } -func download(ctx context.Context, url string, writer io.Writer) error { +func download(ctx context.Context, url string, writer io.Writer, progress Progress, name string) error { ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -149,6 +149,9 @@ func download(ctx context.Context, url string, writer io.Writer) error { return err } defer resp.Body.Close() - _, err = io.Copy(writer, newIdleTimeoutReader(resp.Body, downloadIdleTimeout, cancel)) + + body := io.Reader(newIdleTimeoutReader(resp.Body, downloadIdleTimeout, cancel)) + body = newProgressReader(body, progress, name, resp.ContentLength) + _, err = io.Copy(writer, body) return err }