diff --git a/downloads/.parts/ubuntu-25.10-desktop-amd64.iso-c8295ce630f2.part b/downloads/.parts/ubuntu-25.10-desktop-amd64.iso-c8295ce630f2.part new file mode 100755 index 0000000..1b5edab Binary files /dev/null and b/downloads/.parts/ubuntu-25.10-desktop-amd64.iso-c8295ce630f2.part differ diff --git a/internal/app/controller.go b/internal/app/controller.go index d6f8014..a219f72 100644 --- a/internal/app/controller.go +++ b/internal/app/controller.go @@ -14,8 +14,8 @@ func NewController() *Controller { } } -func (c *Controller) StartTorrent(path string) error { - return c.engine.LoadTorrent(path) +func (c *Controller) StartTorrent(path, outputRoot string) error { + return c.engine.LoadTorrent(path, outputRoot) } func (c *Controller) StopTorrent() { diff --git a/internal/torrent/debug.go b/internal/torrent/debug.go new file mode 100644 index 0000000..80ca281 --- /dev/null +++ b/internal/torrent/debug.go @@ -0,0 +1,17 @@ +package torrent + +import "log" + +// debug enables verbose protocol and scheduler logs. +var debug = true + +func debugf(format string, args ...any) { + if !debug { + return + } + log.Printf(format, args...) +} + +func SetDebug(enabled bool) { + debug = enabled +} diff --git a/internal/torrent/engine.go b/internal/torrent/engine.go index 2407453..54f9e90 100644 --- a/internal/torrent/engine.go +++ b/internal/torrent/engine.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "log" "net" "os" "path/filepath" @@ -39,6 +40,10 @@ type Engine struct { completedPieces int totalPieces int outputPath string + outputRoot string + downloadSpeed float64 + speedAt time.Time + speedBytes int64 } type Status struct { @@ -50,6 +55,7 @@ type Status struct { CompletedPieces int DownloadedBytes int64 + DownloadSpeed float64 TotalBytes int64 OutputPath string @@ -98,13 +104,17 @@ func NewEngine() *Engine { } } -func (e *Engine) LoadTorrent(path string) error { +func (e *Engine) LoadTorrent(path, outputRoot string) error { e.Stop() e.resetStateForNewLoad() e.setPhase("loading_metadata") + outputRoot = normalizeOutputRoot(outputRoot) + log.Printf("loading torrent metadata from %s", path) + debugf("download root selected: %s", outputRoot) tf, err := torrentfile.Open(path) if err != nil { + log.Printf("failed to parse torrent %s: %v", path, err) e.setError(err) e.setPhase("failed") return err @@ -131,7 +141,11 @@ func (e *Engine) LoadTorrent(path string) error { e.totalPieces = len(tf.PieceHashes) e.downloadedBytes = 0 e.completedPieces = 0 - e.outputPath = buildOutputPath(tf, "downloads") + e.outputRoot = outputRoot + e.outputPath = buildOutputPath(tf, outputRoot) + e.downloadSpeed = 0 + e.speedAt = time.Now() + e.speedBytes = 0 if len(peers) == 0 { warnErr := fmt.Errorf("no peers yet via %d trackers", len(trackerStatuses)) if trErr := firstTrackerError(trackerStatuses); trErr != nil { @@ -143,6 +157,7 @@ func (e *Engine) LoadTorrent(path string) error { } e.phase = "ready" e.mu.Unlock() + log.Printf("torrent loaded: name=%s size=%d peers=%d output=%s", tf.Name, tf.Length, len(peers), e.outputPath) go e.runDownload(downloadCtx, tf) return nil @@ -182,6 +197,7 @@ func (e *Engine) Status() Status { Phase: e.phase, CompletedPieces: e.completedPieces, DownloadedBytes: e.downloadedBytes, + DownloadSpeed: e.downloadSpeed, OutputPath: e.outputPath, } @@ -207,6 +223,7 @@ func (e *Engine) queryTrackers(ctx context.Context, tf *torrentfile.TorrentFile, statuses := make([]TrackerStatus, 0, len(trackersToTry)) for _, announce := range trackersToTry { + log.Printf("tracker announce: %s", announce) perTrackerCtx, cancel := context.WithTimeout(ctx, opts.Timeout) peers, err := tracker.GetPeersFromURL(perTrackerCtx, announce, tf, opts) cancel() @@ -215,6 +232,7 @@ func (e *Engine) queryTrackers(ctx context.Context, tf *torrentfile.TorrentFile, if err != nil { st.State = "error" st.Error = err.Error() + log.Printf("tracker announce failed %s: %v", announce, err) statuses = append(statuses, st) continue } @@ -226,6 +244,7 @@ func (e *Engine) queryTrackers(ctx context.Context, tf *torrentfile.TorrentFile, st.PeerCount = len(peers) } statuses = append(statuses, st) + log.Printf("tracker announce result %s: peers=%d state=%s", announce, st.PeerCount, st.State) for _, peer := range peers { addPeer(peerMap, peer, announce) @@ -250,9 +269,11 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) { defer e.clearCancel() e.setPhase("preparing_download") + log.Printf("preparing download to %s", e.outputRoot) - partPath, partFile, err := createPartFile(tf) + partPath, partFile, err := createPartFile(tf, e.outputRoot) if err != nil { + log.Printf("failed to create part file: %v", err) e.setTerminalError("failed", fmt.Errorf("create temp file: %w", err)) return } @@ -294,6 +315,7 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) { defer cleanupWorkers() e.setPhase("downloading") + log.Printf("download started: pieces=%d peers=%d", len(tf.PieceHashes), len(e.snapshotPeers())) lastProgressAt := time.Now() lastReannounceAt := time.Now() startWorkers(e.snapshotPeers()) @@ -313,22 +335,27 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) { continue } lastProgressAt = time.Now() + debugf("piece completed: %d/%d", e.currentCompletedPieces(), len(tf.PieceHashes)) case <-scheduler.Done(): downloadCompleted = true case workerKey := <-workerDoneCh: delete(startedWorkers, workerKey) + debugf("worker finished for peer %s", workerKey) case <-heartbeatTicker.C: + e.updateSpeed(time.Now()) if time.Since(lastReannounceAt) >= reannounceInterval { e.setPhase("querying_trackers") refreshCtx, cancel := context.WithTimeout(ctx, 20*time.Second) - _ = e.refreshPeersFromTrackers(refreshCtx, tf) + added := e.refreshPeersFromTrackers(refreshCtx, tf) cancel() lastReannounceAt = time.Now() e.setPhase("downloading") + debugf("reannounce complete, added peers=%d total=%d", added, len(e.snapshotPeers())) } startWorkers(e.snapshotPeers()) if time.Since(lastProgressAt) >= downloadStallTimeout { + log.Printf("download stalled: no progress for %s", downloadStallTimeout.Round(time.Second)) e.setTerminalError("stalled", fmt.Errorf("download stalled: no piece progress for %s", downloadStallTimeout.Round(time.Second))) return } @@ -338,19 +365,24 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) { cleanupWorkers() e.setPhase("writing_files") - if err := materializeDownloadedFiles(tf, partPath, "downloads"); err != nil { + if err := materializeDownloadedFiles(tf, partPath, e.outputRoot); err != nil { + log.Printf("failed to materialize files: %v", err) e.setTerminalError("failed", fmt.Errorf("write output files: %w", err)) return } - _ = os.Remove(partPath) + if err := os.Remove(partPath); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Printf("failed to remove part file %s: %v", partPath, err) + } e.mu.Lock() e.downloadedBytes = int64(tf.Length) e.completedPieces = len(tf.PieceHashes) e.phase = "completed" e.lastErr = nil + e.downloadSpeed = 0 e.mu.Unlock() + log.Printf("download completed: %s", e.outputPath) } func (e *Engine) runPeerWorker( @@ -363,11 +395,13 @@ func (e *Engine) runPeerWorker( e.setPeerState(peer.Address, peer.Port, "connecting", "") peerAddr := net.JoinHostPort(peer.Address, strconv.Itoa(int(peer.Port))) + debugf("starting peer worker for %s", peerAddr) client, err := newPeerClient(ctx, peerAddr, tf.InfoHash, e.peerID, len(tf.PieceHashes)) if err != nil { if ctx.Err() != nil { e.setPeerState(peer.Address, peer.Port, "stopped", "") } else { + log.Printf("peer %s connection failed: %v", peerAddr, err) e.setPeerState(peer.Address, peer.Port, "error", err.Error()) } return @@ -389,6 +423,7 @@ func (e *Engine) runPeerWorker( if ctx.Err() != nil { e.setPeerState(peer.Address, peer.Port, "stopped", "") } else { + log.Printf("peer %s scheduler acquire failed: %v", peerAddr, err) e.setPeerState(peer.Address, peer.Port, "error", err.Error()) } return @@ -411,13 +446,16 @@ func (e *Engine) runPeerWorker( pieceSize := pieceSizeForIndex(tf, task.Index) pieceData, err := client.DownloadPiece(ctx, task.Index, pieceSize) if err != nil { - _, _ = scheduler.Report(ctx, task.Index, false) + if _, reportErr := scheduler.Report(ctx, task.Index, false); reportErr != nil && ctx.Err() == nil { + log.Printf("scheduler report failure for piece %d after peer error: %v", task.Index, reportErr) + } if ctx.Err() != nil { e.setPeerState(peer.Address, peer.Port, "stopped", "") return } consecutiveFailures++ + log.Printf("peer %s disconnected: %v", peerAddr, err) e.setPeerState(peer.Address, peer.Port, "error", err.Error()) if shouldDropPeer(err) || consecutiveFailures >= 3 { return @@ -427,8 +465,11 @@ func (e *Engine) runPeerWorker( hash := sha1.Sum(pieceData) if hash != tf.PieceHashes[task.Index] { - _, _ = scheduler.Report(ctx, task.Index, false) + if _, reportErr := scheduler.Report(ctx, task.Index, false); reportErr != nil && ctx.Err() == nil { + log.Printf("scheduler report hash mismatch for piece %d failed: %v", task.Index, reportErr) + } consecutiveFailures++ + log.Printf("peer %s piece %d hash mismatch", peerAddr, task.Index) e.setPeerState(peer.Address, peer.Port, "error", fmt.Sprintf("piece %d hash mismatch", task.Index)) if consecutiveFailures >= 3 { return @@ -438,7 +479,10 @@ func (e *Engine) runPeerWorker( offset := int64(task.Index * tf.PieceLength) if _, err := partFile.WriteAt(pieceData, offset); err != nil { - _, _ = scheduler.Report(ctx, task.Index, false) + if _, reportErr := scheduler.Report(ctx, task.Index, false); reportErr != nil && ctx.Err() == nil { + log.Printf("scheduler report write failure for piece %d failed: %v", task.Index, reportErr) + } + log.Printf("io error writing piece %d from peer %s: %v", task.Index, peerAddr, err) e.setPeerState(peer.Address, peer.Port, "error", fmt.Sprintf("write piece %d: %v", task.Index, err)) return } @@ -454,6 +498,9 @@ func (e *Engine) runPeerWorker( } if accepted { e.recordPieceComplete(peer.Address, peer.Port, len(pieceData)) + debugf("peer %s committed piece %d", peerAddr, task.Index) + } else { + debugf("scheduler rejected piece %d report from %s", task.Index, peerAddr) } consecutiveFailures = 0 @@ -512,6 +559,7 @@ func (e *Engine) refreshPeersFromTrackers(ctx context.Context, tf *torrentfile.T waitErr = fmt.Errorf("waiting for peers: %w", trErr) } e.lastErr = waitErr + debugf("reannounce returned no peers") return 0 } e.lastErr = nil @@ -540,6 +588,9 @@ func (e *Engine) refreshPeersFromTrackers(ctx context.Context, tf *torrentfile.T return e.peers[i].Address < e.peers[j].Address }) } + if added > 0 { + log.Printf("discovered %d new peers (total=%d)", added, len(e.peers)) + } return added } @@ -550,11 +601,18 @@ func (e *Engine) currentDownloadedBytes() int64 { return e.downloadedBytes } +func (e *Engine) currentCompletedPieces() int { + e.mu.RLock() + defer e.mu.RUnlock() + return e.completedPieces +} + func (e *Engine) recordPieceComplete(address string, port uint16, pieceSize int) { e.mu.Lock() defer e.mu.Unlock() e.downloadedBytes += int64(pieceSize) e.completedPieces++ + e.updateSpeedLocked(time.Now()) for i := range e.peers { if e.peers[i].Address == address && e.peers[i].Port == port { e.peers[i].DownloadedPieces++ @@ -580,6 +638,7 @@ func (e *Engine) setTerminalError(phase string, err error) { defer e.mu.Unlock() e.phase = phase e.lastErr = err + e.downloadSpeed = 0 } func (e *Engine) progressUnsafe() float64 { @@ -628,6 +687,36 @@ func (e *Engine) resetStateForNewLoad() { e.completedPieces = 0 e.totalPieces = 0 e.outputPath = "" + e.outputRoot = "" + e.downloadSpeed = 0 + e.speedAt = time.Time{} + e.speedBytes = 0 +} + +func (e *Engine) updateSpeed(now time.Time) { + e.mu.Lock() + defer e.mu.Unlock() + e.updateSpeedLocked(now) +} + +func (e *Engine) updateSpeedLocked(now time.Time) { + if e.speedAt.IsZero() { + e.speedAt = now + e.speedBytes = e.downloadedBytes + e.downloadSpeed = 0 + return + } + elapsed := now.Sub(e.speedAt).Seconds() + if elapsed < 0.8 { + return + } + delta := e.downloadedBytes - e.speedBytes + if delta < 0 { + delta = 0 + } + e.downloadSpeed = float64(delta) / elapsed + e.speedAt = now + e.speedBytes = e.downloadedBytes } func (e *Engine) setError(err error) { @@ -755,8 +844,8 @@ func firstTrackerError(statuses []TrackerStatus) error { return nil } -func createPartFile(tf *torrentfile.TorrentFile) (string, *os.File, error) { - partsDir := filepath.Join("downloads", ".parts") +func createPartFile(tf *torrentfile.TorrentFile, outputRoot string) (string, *os.File, error) { + partsDir := filepath.Join(outputRoot, ".ztorrent-parts") if err := os.MkdirAll(partsDir, 0o755); err != nil { return "", nil, err } @@ -834,12 +923,7 @@ func safeOutputPath(root, rel string) (string, error) { } func buildOutputPath(tf *torrentfile.TorrentFile, outputRoot string) string { - if len(tf.Files) == 1 { - if path, err := safeOutputPath(outputRoot, tf.Files[0].Path); err == nil { - return path - } - } - return filepath.Join(outputRoot, sanitizePathPart(tf.Name)) + return filepath.Join(outputRoot, tf.Name) } func sanitizePathPart(s string) string { @@ -864,3 +948,16 @@ func sanitizePathPart(s string) string { } return out } + +func normalizeOutputRoot(root string) string { + root = strings.TrimSpace(root) + if root == "" { + cwd, err := os.Getwd() + if err == nil && cwd != "" { + return cwd + } + return "." + } + clean := filepath.Clean(root) + return clean +} diff --git a/internal/torrent/peerwire.go b/internal/torrent/peerwire.go index f978c94..428a997 100644 --- a/internal/torrent/peerwire.go +++ b/internal/torrent/peerwire.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "log" "net" "time" @@ -50,8 +51,10 @@ func newPeerClient(ctx context.Context, addr string, infoHash [20]byte, peerID [ dialer := net.Dialer{Timeout: peerConnectTimeout} conn, err := dialer.DialContext(ctx, "tcp", addr) if err != nil { + log.Printf("peer dial failed %s: %v", addr, err) return nil, err } + log.Printf("connected to peer %s", addr) pc := &peerClient{ conn: conn, @@ -72,8 +75,10 @@ func newPeerClient(ctx context.Context, addr string, infoHash [20]byte, peerID [ conn.Close() return nil, err } + debugf("sent interested to %s", addr) if err := pc.readInitialMessages(ctx); err != nil { + log.Printf("peer %s initial message read failed: %v", addr, err) conn.Close() return nil, err } @@ -132,6 +137,7 @@ func (pc *peerClient) readInitialMessages(ctx context.Context) error { msg, err := readWireMessage(pc.conn) if err != nil { if isTimeout(err) { + debugf("peer initial message window finished") return nil } return err @@ -166,12 +172,14 @@ func (pc *peerClient) waitForUnchoke(ctx context.Context) error { } pc.consumeMessage(msg) if msg.ID == msgUnchoke { + debugf("peer unchoked") return nil } } } func (pc *peerClient) sendRequest(ctx context.Context, pieceIndex, begin, length int) error { + debugf("request piece %d offset %d length %d", pieceIndex, begin, length) payload := make([]byte, 12) binary.BigEndian.PutUint32(payload[0:4], uint32(pieceIndex)) binary.BigEndian.PutUint32(payload[4:8], uint32(begin)) @@ -214,6 +222,7 @@ func (pc *peerClient) readPieceBlock(ctx context.Context, pieceIndex, begin, exp if len(block) > expectedLen { block = block[:expectedLen] } + debugf("received piece %d offset %d block=%d", pieceIndex, begin, len(block)) return block, nil case msgChoke: pc.consumeMessage(msg) @@ -228,8 +237,10 @@ func (pc *peerClient) consumeMessage(msg wireMessage) { switch msg.ID { case msgChoke: pc.peerIsChoked = true + debugf("peer sent choke") case msgUnchoke: pc.peerIsChoked = false + debugf("peer sent unchoke") case msgHave: if len(msg.Payload) < 4 { return @@ -238,9 +249,11 @@ func (pc *peerClient) consumeMessage(msg wireMessage) { if idx >= 0 && idx < len(pc.have) { pc.have[idx] = true pc.hasPieceInfo = true + debugf("received have piece %d", idx) } case msgBitfield: pc.hasPieceInfo = true + debugf("received bitfield (%d bytes)", len(msg.Payload)) for i := range pc.have { pc.have[i] = bitfieldHasPiece(msg.Payload, i) } @@ -248,6 +261,7 @@ func (pc *peerClient) consumeMessage(msg wireMessage) { } func (pc *peerClient) sendHandshake(ctx context.Context, infoHash [20]byte, peerID [20]byte) error { + debugf("sending handshake") payload := make([]byte, 49+len(wireProtocolString)) payload[0] = byte(len(wireProtocolString)) copy(payload[1:1+len(wireProtocolString)], wireProtocolString) @@ -288,6 +302,7 @@ func (pc *peerClient) readHandshake(ctx context.Context, expectedInfoHash [20]by if !bytes.Equal(rest[infoHashOffset:infoHashOffset+20], expectedInfoHash[:]) { return errors.New("peer info_hash mismatch") } + debugf("handshake complete") return nil } diff --git a/internal/torrent/piecescheduler.go b/internal/torrent/piecescheduler.go index 861214e..19b59b3 100644 --- a/internal/torrent/piecescheduler.go +++ b/internal/torrent/piecescheduler.go @@ -149,6 +149,7 @@ func (ps *pieceScheduler) run(pieceCount int) { pieceIndex := selectPendingPiece(states, req.have, req.hasInfo) if pieceIndex >= 0 { states[pieceIndex] = pieceInProgress + debugf("scheduler assigned piece %d", pieceIndex) req.responseCh <- assignPieceResponse{ task: pieceTask{Index: pieceIndex}, ok: true, @@ -164,11 +165,13 @@ func (ps *pieceScheduler) run(pieceCount int) { if req.success { if states[req.pieceIndex] == pieceDone { + debugf("scheduler report piece %d ignored (already done)", req.pieceIndex) req.responseCh <- false continue } states[req.pieceIndex] = pieceDone completed++ + debugf("scheduler report piece %d success (%d/%d)", req.pieceIndex, completed, pieceCount) select { case ps.progressCh <- req.pieceIndex: default: @@ -179,6 +182,7 @@ func (ps *pieceScheduler) run(pieceCount int) { if states[req.pieceIndex] == pieceInProgress { states[req.pieceIndex] = piecePending + debugf("scheduler report piece %d failed, re-queued", req.pieceIndex) } req.responseCh <- false } diff --git a/torrent-client b/torrent-client new file mode 100755 index 0000000..0cf267a Binary files /dev/null and b/torrent-client differ diff --git a/ui/window.go b/ui/window.go index 5366979..907f5ec 100644 --- a/ui/window.go +++ b/ui/window.go @@ -27,6 +27,7 @@ type desktopUI struct { window fyne.Window pathEntry *widget.Entry + downloadDirEntry *widget.Entry progress *widget.ProgressBar phaseLabel *widget.Label phaseMetricLabel *widget.Label @@ -38,6 +39,7 @@ type desktopUI struct { pieceCountLabel *widget.Label downloadedLabel *widget.Label outputLabel *widget.Label + speedLabel *widget.Label peerCountLabel *widget.Label mu sync.RWMutex @@ -68,6 +70,7 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI { window: w, stopRefresh: make(chan struct{}), pathEntry: widget.NewEntry(), + downloadDirEntry: widget.NewEntry(), progress: widget.NewProgressBar(), phaseLabel: widget.NewLabel("Phase: idle"), phaseMetricLabel: widget.NewLabel("idle"), @@ -79,14 +82,22 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI { pieceCountLabel: widget.NewLabel("-"), downloadedLabel: widget.NewLabel("0 / 0"), outputLabel: widget.NewLabel("-"), + speedLabel: widget.NewLabel("0 B/s"), peerCountLabel: widget.NewLabel("0"), } ui.pathEntry.SetPlaceHolder("/path/to/file.torrent") + ui.downloadDirEntry.SetPlaceHolder("/path/to/download/folder") + if home, err := os.UserHomeDir(); err == nil { + ui.downloadDirEntry.SetText(home) + } - browseButton := widget.NewButton("Browse", ui.onBrowse) + browseButton := widget.NewButton("Browse Torrent", ui.onBrowse) + browseOutputButton := widget.NewButton("Browse Folder", ui.onBrowseOutputFolder) loadButton := widget.NewButton("Load Torrent", ui.onLoad) - topRow := container.NewBorder(nil, nil, nil, container.NewHBox(browseButton, loadButton), ui.pathEntry) + torrentRow := container.NewBorder(nil, nil, nil, browseButton, ui.pathEntry) + outputRow := container.NewBorder(nil, nil, nil, container.NewHBox(browseOutputButton, loadButton), ui.downloadDirEntry) + topRows := container.NewVBox(torrentRow, outputRow) metaGrid := container.NewGridWithColumns(4, wrapMetric("Name", ui.nameLabel), @@ -94,6 +105,7 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI { wrapMetric("Piece length", ui.pieceLabel), wrapMetric("Completed pieces", ui.pieceCountLabel), wrapMetric("Downloaded", ui.downloadedLabel), + wrapMetric("Speed", ui.speedLabel), wrapMetric("Output", ui.outputLabel), wrapMetric("Discovered peers", ui.peerCountLabel), wrapMetric("Phase", ui.phaseMetricLabel), @@ -117,7 +129,7 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI { ) content := container.NewBorder( - container.NewVBox(topRow), + topRows, nil, nil, nil, @@ -172,12 +184,26 @@ func (ui *desktopUI) onLoad() { ui.setError("select a .torrent file first") return } + downloadRoot := strings.TrimSpace(ui.downloadDirEntry.Text) + if downloadRoot == "" { + ui.setError("select a download folder first") + return + } + info, err := os.Stat(downloadRoot) + if err != nil { + ui.setError("download folder is not accessible: " + err.Error()) + return + } + if !info.IsDir() { + ui.setError("download path must be a folder") + return + } ui.setMessage("Loading torrent...") ui.setError("") go func() { - err := ui.controller.StartTorrent(path) + err := ui.controller.StartTorrent(path, downloadRoot) status := ui.controller.Status() ui.updateFromStatus(status) if err != nil { @@ -188,6 +214,20 @@ func (ui *desktopUI) onLoad() { }() } +func (ui *desktopUI) onBrowseOutputFolder() { + fd := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) { + if err != nil { + ui.setError(err.Error()) + return + } + if uri == nil { + return + } + ui.downloadDirEntry.SetText(uri.Path()) + }, ui.window) + fd.Show() +} + func (ui *desktopUI) refreshLoop() { ticker := time.NewTicker(1200 * time.Millisecond) defer ticker.Stop() @@ -216,6 +256,7 @@ func (ui *desktopUI) updateFromStatus(status torrent.Status) { ui.pieceCountLabel.SetText("-") } ui.downloadedLabel.SetText(fmt.Sprintf("%d / %d", status.DownloadedBytes, status.TotalBytes)) + ui.speedLabel.SetText(formatBytesRate(status.DownloadSpeed)) ui.outputLabel.SetText(status.OutputPath) ui.peerCountLabel.SetText(strconv.Itoa(status.PeerCount)) @@ -237,6 +278,23 @@ func (ui *desktopUI) updateFromStatus(status torrent.Status) { }) } +func formatBytesRate(bytesPerSec float64) string { + if bytesPerSec < 0 { + bytesPerSec = 0 + } + units := []string{"B/s", "KB/s", "MB/s", "GB/s", "TB/s"} + value := bytesPerSec + unit := 0 + for value >= 1024 && unit < len(units)-1 { + value /= 1024 + unit++ + } + if unit == 0 { + return fmt.Sprintf("%.0f %s", value, units[unit]) + } + return fmt.Sprintf("%.2f %s", value, units[unit]) +} + func (ui *desktopUI) setMessage(msg string) { fyne.Do(func() { ui.messageLabel.SetText(msg)