Skip to content

Commit 68d9d71

Browse files
committed
cleanup
Signed-off-by: abzcoding <abzcoding@gmail.com>
1 parent a30639a commit 68d9d71

13 files changed

Lines changed: 743 additions & 43 deletions

README.md

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
## Features
1111

1212
- **Parallel downloading** — splits files into chunks and downloads them simultaneously over multiple connections
13-
- **Live "carrier" TUI** — refined phosphor-cyan / amber telemetry palette, per-part progress with status glyphs (`⬢ ◉ ◯`), animated overall bar, ETA, per-connection speed readouts, plus a rolling **speed-history sparkline** showing recent throughput trend and peak. Driven by [Bubble Tea](https://github.com/charmbracelet/bubbletea), [Bubbles](https://github.com/charmbracelet/bubbles), [Lip Gloss](https://github.com/charmbracelet/lipgloss), and [Harmonica](https://github.com/charmbracelet/harmonica) spring physics
13+
- **Retro modem-themed TUI** — vintage data-link terminal aesthetic with animated dial-up handshake, blinking status LEDs (PWR/CD/TX/RX/OH/AA), per-channel progress bars, and aggregate signal meter. Animated file assembly and GPG verification phases. All driven by [Bubble Tea](https://github.com/charmbracelet/bubbletea), [Bubbles](https://github.com/charmbracelet/bubbles), [Lip Gloss](https://github.com/charmbracelet/lipgloss), and [Harmonica](https://github.com/charmbracelet/harmonica) spring physics
14+
- **Auto-resume detection** — automatically detects partial downloads and prompts to resume without requiring `--resume` flag
1415
- **Real cancellation**`q` / `Ctrl-C` actually stops the current download (state saved when resumable); `s` in batch mode skips the current item; `Ctrl-C` (or `q` in batch mode) aborts the entire queue. A live "stopping…/skipping…" overlay shows progress while state is drained safely
16+
- **Visual state feedback** — LED patterns signal every state: handshake (TX/RX blink), downloading (all active), stopping (OH blinks amber), skipping (all blink red), complete (all solid), error (OH solid red)
1517
- **Beautiful console logging** — colored, structured output via [charmbracelet/log](https://github.com/charmbracelet/log) when running non-interactively
1618
- **GPG signature verification** — pass `--verify` to automatically fetch the `.sig` file and verify it with GPG; result is shown inline in the TUI completion screen
1719
- **Smart re-download prompt** — if the target file already exists, a styled [Huh](https://github.com/charmbracelet/huh) confirmation form asks before overwriting
18-
- **Interrupt & resume**`q` / `Ctrl-C` at any point persists byte offsets to `~/.hget/<task>/state.json`; resume later with `--resume`
20+
- **Interrupt & resume**`q` / `Ctrl-C` at any point persists byte offsets to `~/.hget/<task>/state.json`; resume automatically on next run or explicitly with `--resume`
1921
- **State reconstruction** — if the state file is missing, reconstructs progress from existing part files
2022
- **Bandwidth limiting** — cap aggregate download speed with `--rate` (e.g. `5MiB`, `500kB`)
2123
- **Proxy support** — HTTP and SOCKS5 proxies via `--proxy`
@@ -43,6 +45,10 @@ make install # builds to ./bin/hget and copies to /usr/local/bin
4345
# Download a file with 8 parallel connections
4446
hget -n 8 https://example.com/largefile.iso
4547

48+
# If you interrupt (Ctrl-C), just run the same command again
49+
# hget will detect the partial download and ask if you want to resume
50+
hget -n 8 https://example.com/largefile.iso
51+
4652
# Limit bandwidth to 5 MB/s across all connections
4753
hget -n 4 -rate 5MiB https://example.com/largefile.iso
4854

@@ -92,15 +98,17 @@ hget -n 16 -rate 10MiB "https://releases.ubuntu.com/24.04/ubuntu-24.04-live-serv
9298

9399
## How It Works
94100

95-
1. **Probe**`HEAD` request (falls back to `GET bytes=0-0`) detects `Accept-Ranges` and `Content-Length`.
96-
2. **Exists check** — if the destination file is already present, a [Huh](https://github.com/charmbracelet/huh) confirmation form asks whether to overwrite (skipped in non-TTY mode).
97-
3. **Split** — file is divided into *n* equal byte ranges; each range is stored as `~/.hget/<task>/<task>.part000000``.partN`.
98-
4. **Download** — goroutines download each part concurrently, appending bytes to their part file; a shared `rate.Limiter` enforces the bandwidth cap across all connections.
99-
5. **Join** — on completion, part files are sorted lexicographically and concatenated into the final file in the working directory.
100-
6. **Verify** *(optional, `--verify`)* — the `.sig` file is fetched from `<URL>.sig`, written next to the download, and passed to `gpg --verify`. The result (`✓ Valid` / `✗ Invalid`) is shown on the TUI completion screen and the `.sig` file is cleaned up.
101-
7. **Cleanup**`~/.hget/<task>/` is removed.
102-
103-
On **Ctrl-C** the current byte offsets are persisted to `~/.hget/<task>/state.json` so the download resumes exactly where it left off.
101+
1. **Handshake** — animated modem connection sequence shows 4 phases: DIALING → CARRIER DETECT → HANDSHAKE → LINK ESTABLISHED
102+
2. **Probe**`HEAD` request (falls back to `GET bytes=0-0`) detects `Accept-Ranges` and `Content-Length`
103+
3. **Resume check** — if partial download exists, prompts "Resume from where you left off? [Y/n]"
104+
4. **Exists check** — if the destination file is already present, a [Huh](https://github.com/charmbracelet/huh) confirmation form asks whether to overwrite (skipped in non-TTY mode)
105+
5. **Split** — file is divided into *n* equal byte ranges; each range is stored as `~/.hget/<task>/<task>.part000000``.partN`
106+
6. **Download** — data-link panel shows per-channel progress with blinking activity LEDs; goroutines download each part concurrently; a shared `rate.Limiter` enforces the bandwidth cap
107+
7. **Join** — animated assembly visualization shows parts being merged; files are sorted lexicographically and concatenated into the final file
108+
8. **Verify** *(optional, `--verify`)* — animated key/lock visualization while `.sig` file is fetched and `gpg --verify` runs; result shown on completion screen
109+
9. **Cleanup**`~/.hget/<task>/` is removed
110+
111+
On **Ctrl-C** or **q**, the data-link panel shows STOPPING status with blinking OH LED while byte offsets are persisted to `~/.hget/<task>/state.json`. On **s** (skip in batch mode), all LEDs blink red while the download is discarded.
104112

105113
## Project Structure
106114

cmd/hget/main.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"flag"
7+
"fmt"
78
"os"
89
"os/signal"
910
"runtime"
@@ -78,21 +79,27 @@ func main() {
7879
downloadURL := args[0]
7980
destFile := util.TaskFromURL(downloadURL)
8081

82+
// Check if final file already exists
8183
if _, err := os.Stat(destFile); err == nil {
8284
if !ui.ConfirmRedownload(destFile) {
83-
ui.Warnf("Skipping download — %s already exists.\n", destFile)
85+
ui.ShowMessage(ui.MessageInfo, "DOWNLOAD SKIPPED", fmt.Sprintf("File already exists: %s", destFile))
8486
if *verify {
8587
ok, detail := downloader.RunVerify(downloadURL, *skiptls, proxy, *timeout)
8688
ui.PrintVerifySummary(ok, detail)
8789
}
8890
return
8991
}
92+
// User wants to redownload — clean up any partial state
93+
if util.ExistDir(state.FolderOf(downloadURL)) {
94+
err := os.RemoveAll(state.FolderOf(downloadURL))
95+
util.FatalCheck(err)
96+
}
9097
}
9198

92-
if util.ExistDir(state.FolderOf(downloadURL)) {
93-
ui.Warnf("Downloading task already exists, remove it first\n")
94-
err := os.RemoveAll(state.FolderOf(downloadURL))
95-
util.FatalCheck(err)
99+
// Check for resumable partial download
100+
var st *state.State
101+
if state.Exists(downloadURL) {
102+
st, _ = state.PromptResume(downloadURL)
96103
}
97104

98105
itemCtx, cancelItem := context.WithCancelCause(rootCtx)
@@ -110,7 +117,7 @@ func main() {
110117
BatchCurrent: 0,
111118
BatchTotal: 0,
112119
}, func() error {
113-
if err := downloader.Execute(itemCtx, downloadURL, nil, *conn, *skiptls, proxy, bwLimit, *timeout); err != nil {
120+
if err := downloader.Execute(itemCtx, downloadURL, st, *conn, *skiptls, proxy, bwLimit, *timeout); err != nil {
114121
return err
115122
}
116123
if *verify {
@@ -137,20 +144,20 @@ func runResume(rootCtx context.Context, resumeTask string, conn int, skiptls boo
137144
st, err := state.Resume(resumeTask)
138145
if err != nil {
139146
if !os.IsNotExist(err) {
140-
ui.Errorf("Resume failed: %v\n", err)
147+
ui.ShowMessage(ui.MessageError, "RESUME FAILED", fmt.Sprintf("Could not load saved state: %v", err))
141148
os.Exit(1)
142149
}
143150
// No state.json — try to reconstruct from existing part files.
144151
st, err = downloader.ReconstructStateFromParts(resumeTask, skiptls, proxy, timeout)
145152
if err == nil {
146-
ui.Printf("Reconstructed state from %d part files — resuming.\n", len(st.Parts))
153+
ui.ShowMessage(ui.MessageInfo, "STATE RECONSTRUCTED", fmt.Sprintf("Recovered %d part files — resuming download", len(st.Parts)))
147154
runOne(rootCtx, st.URL, st, conn, skiptls, proxy, bwLimit, timeout)
148155
return
149156
}
150157
// No part files either — start fresh if it looks like a URL.
151-
ui.Warnf("No saved state found for %q — starting fresh download.\n", resumeTask)
158+
ui.ShowMessage(ui.MessageWarning, "NO SAVED STATE", fmt.Sprintf("Starting fresh download for: %s", resumeTask))
152159
if !util.IsURL(resumeTask) {
153-
ui.Errorf("No saved state found for task %q and it is not a URL.\n", resumeTask)
160+
ui.ShowMessage(ui.MessageError, "INVALID TASK", fmt.Sprintf("No saved state found and not a valid URL: %s", resumeTask))
154161
os.Exit(1)
155162
}
156163
runOne(rootCtx, resumeTask, nil, conn, skiptls, proxy, bwLimit, timeout)

internal/batch/batch.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func RunBatchDownloads(ctx context.Context, filePath string, conn int, skiptls b
2727
// ── Read and validate URL list ────────────────────────────────────────────
2828
f, err := os.Open(filePath)
2929
if err != nil {
30-
ui.Errorf("could not open URL list %s: %v\n", filePath, err)
30+
ui.ShowMessage(ui.MessageError, "FILE ERROR", fmt.Sprintf("Could not open URL list: %s\n%v", filePath, err))
3131
return
3232
}
3333
defer f.Close()
@@ -42,12 +42,12 @@ func RunBatchDownloads(ctx context.Context, filePath string, conn int, skiptls b
4242
urls = append(urls, line)
4343
}
4444
if scanErr := scanner.Err(); scanErr != nil {
45-
ui.Errorf("error reading %s: %v\n", filePath, scanErr)
45+
ui.ShowMessage(ui.MessageError, "READ ERROR", fmt.Sprintf("Error reading file: %s\n%v", filePath, scanErr))
4646
return
4747
}
4848

4949
if len(urls) == 0 {
50-
ui.Warnf("No URLs found in %s\n", filePath)
50+
ui.ShowMessage(ui.MessageWarning, "EMPTY FILE", fmt.Sprintf("No URLs found in: %s", filePath))
5151
return
5252
}
5353

@@ -252,7 +252,7 @@ func RunBatchDownloads(ctx context.Context, filePath string, conn int, skiptls b
252252
// File-exists check (isatty gate is inside ui.ConfirmRedownload).
253253
if _, statErr := os.Stat(it.file); statErr == nil {
254254
if !ui.ConfirmRedownload(it.file) {
255-
ui.Warnf("Skipping — %s already exists.\n", it.file)
255+
ui.ShowMessage(ui.MessageInfo, "FILE EXISTS", fmt.Sprintf("Skipping: %s", it.file))
256256
it.status = statusSkipped
257257
it.reason = "already exists"
258258
if verify {
@@ -266,13 +266,18 @@ func RunBatchDownloads(ctx context.Context, filePath string, conn int, skiptls b
266266
fmt.Println()
267267
continue
268268
}
269+
// User wants to redownload — clean up any partial state
270+
if util.ExistDir(state.FolderOf(it.url)) {
271+
if rmErr := os.RemoveAll(state.FolderOf(it.url)); rmErr != nil {
272+
ui.Warnf("Could not remove old state: %v\n", rmErr)
273+
}
274+
}
269275
}
270276

271-
// Remove stale temp dir.
272-
if util.ExistDir(state.FolderOf(it.url)) {
273-
if rmErr := os.RemoveAll(state.FolderOf(it.url)); rmErr != nil {
274-
ui.Warnf("Could not remove old temp dir: %v\n", rmErr)
275-
}
277+
// Check for resumable partial download
278+
var st *state.State
279+
if state.Exists(it.url) {
280+
st, _ = state.PromptResume(it.url)
276281
}
277282

278283
// Per-item context derived from the batch context, with cancel
@@ -299,7 +304,7 @@ func RunBatchDownloads(ctx context.Context, filePath string, conn int, skiptls b
299304
BatchCurrent: i + 1,
300305
BatchTotal: len(items),
301306
}, func() error {
302-
if err := downloader.Execute(itemCtx, it.url, nil, conn, skiptls, proxy, bwLimit, timeout); err != nil {
307+
if err := downloader.Execute(itemCtx, it.url, st, conn, skiptls, proxy, bwLimit, timeout); err != nil {
303308
return err
304309
}
305310
if verify {

internal/state/resume.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,60 @@ import (
1111
"github.com/abzcoding/hget/internal/util"
1212
)
1313

14+
// Exists checks if saved state exists for the given URL or task name.
15+
func Exists(urlOrTask string) bool {
16+
_, err := Read(urlOrTask)
17+
return err == nil
18+
}
19+
20+
// PromptResume checks if resumable state exists and asks the user whether to
21+
// resume or start fresh. Returns the state if resuming, nil if starting fresh.
22+
func PromptResume(urlOrTask string) (*State, bool) {
23+
if !Exists(urlOrTask) {
24+
return nil, true // no state, proceed with fresh download
25+
}
26+
27+
// Load state to get download progress
28+
st, err := Read(urlOrTask)
29+
if err != nil {
30+
return nil, true
31+
}
32+
33+
// Calculate total downloaded bytes
34+
var downloaded int64
35+
var total int64
36+
for _, part := range st.Parts {
37+
downloaded += part.RangeFrom
38+
total += (part.RangeTo - part.RangeFrom + 1)
39+
}
40+
41+
// Show animated TUI prompt
42+
resume, err := ui.ResumePrompt(util.TaskFromURL(urlOrTask), downloaded, total)
43+
if err != nil {
44+
// On error, default to fresh download
45+
return nil, true
46+
}
47+
48+
if resume {
49+
// Validate part files
50+
st, err := Resume(urlOrTask)
51+
if err != nil {
52+
// If resume fails, start fresh
53+
folder := FolderOf(urlOrTask)
54+
_ = os.RemoveAll(folder)
55+
return nil, true
56+
}
57+
return st, true
58+
}
59+
60+
// User chose not to resume — clean up old state
61+
folder := FolderOf(urlOrTask)
62+
if err := os.RemoveAll(folder); err != nil {
63+
// Silently ignore cleanup errors
64+
}
65+
return nil, true
66+
}
67+
1468
// TaskPrint reads and prints data about current download jobs.
1569
func TaskPrint() error {
1670
usr, err := user.Current()

internal/ui/datalink.go

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ package ui
1515
// one canonical home.
1616

1717
import (
18+
cryptorand "crypto/rand"
19+
"encoding/binary"
1820
"fmt"
1921
"math"
2022
"math/rand"
@@ -72,12 +74,21 @@ type dataLink struct {
7274
}
7375

7476
func newDataLink() dataLink {
77+
// Use crypto/rand for seed to satisfy gosec
78+
var seed int64
79+
var b [8]byte
80+
if _, err := cryptorand.Read(b[:]); err == nil {
81+
seed = int64(binary.LittleEndian.Uint64(b[:]))
82+
} else {
83+
seed = time.Now().UnixNano()
84+
}
85+
7586
return dataLink{
7687
pwr: led{brightness: 1},
7788
cd: led{brightness: 1},
7889
oh: led{brightness: 1},
7990
aa: led{brightness: 0.7},
80-
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
91+
rng: rand.New(rand.NewSource(seed)),
8192
}
8293
}
8394

@@ -196,10 +207,18 @@ func (d *dataLink) View(
196207
// All LEDs solid on completion
197208
txOn, rxOn, aaOn = true, true, true
198209
case "STOPPING", "SKIPPING":
199-
statusColor = colorAmber
210+
if status == "SKIPPING" {
211+
statusColor = colorMagenta // Red for skip
212+
} else {
213+
statusColor = colorAmber // Amber for stop
214+
}
200215
pwrOn, cdOn = true, true
201-
// OH (overload/halt) blinks
216+
// OH blinks, TX/RX blink red for SKIPPING
202217
ohOn = d.ticks%3 < 2
218+
if status == "SKIPPING" {
219+
txOn = d.ticks%4 < 2
220+
rxOn = d.ticks%4 >= 2
221+
}
203222
case "ERROR":
204223
statusColor = colorMagenta
205224
pwrOn = true
@@ -305,7 +324,7 @@ func (d *dataLink) View(
305324
// ── channel rows ─────────────────────────────────────────────────────
306325
channelLines := make([]string, 0, len(channels))
307326
for i, ch := range channels {
308-
line := d.renderChannelRow(ch, i)
327+
line := d.renderChannelRow(ch, i, status)
309328
channelLines = append(channelLines, line)
310329
}
311330

@@ -331,7 +350,7 @@ func (d *dataLink) View(
331350
// renderChannelRow renders a single per-connection line:
332351
//
333352
// ▸ CH·01 ┃▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱┃ 72.3% ↓ 1.2 MB/s ●●
334-
func (d *dataLink) renderChannelRow(ch channelRow, idx int) string {
353+
func (d *dataLink) renderChannelRow(ch channelRow, idx int, status string) string {
335354
bullet := lipgloss.NewStyle().Foreground(colorAmber).Render("▸")
336355
label := lipgloss.NewStyle().Foreground(colorSteel).Render(fmt.Sprintf("CH·%02d", ch.Index+1))
337356

@@ -363,9 +382,16 @@ func (d *dataLink) renderChannelRow(ch channelRow, idx int) string {
363382

364383
// Per-channel activity LEDs (2 dots).
365384
var act string
385+
ledColor := colorPhosphor
386+
if status == "SKIPPING" {
387+
ledColor = colorMagenta // Red LEDs when skipping
388+
} else if status == "ERROR" {
389+
ledColor = colorMagenta
390+
}
391+
366392
if idx < len(d.chanLEDs) {
367-
act = d.chanLEDs[idx][0].render(colorPhosphor, colorSlate) +
368-
d.chanLEDs[idx][1].render(colorPhosphor, colorSlate)
393+
act = d.chanLEDs[idx][0].render(ledColor, colorSlate) +
394+
d.chanLEDs[idx][1].render(ledColor, colorSlate)
369395
} else {
370396
act = lipgloss.NewStyle().Foreground(colorSlate).Render("●●")
371397
}

0 commit comments

Comments
 (0)