govno
This commit is contained in:
parent
90583f66c7
commit
61709a44f5
8 changed files with 214 additions and 23 deletions
BIN
downloads/.parts/ubuntu-25.10-desktop-amd64.iso-c8295ce630f2.part
Executable file
BIN
downloads/.parts/ubuntu-25.10-desktop-amd64.iso-c8295ce630f2.part
Executable file
Binary file not shown.
|
|
@ -14,8 +14,8 @@ func NewController() *Controller {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) StartTorrent(path string) error {
|
func (c *Controller) StartTorrent(path, outputRoot string) error {
|
||||||
return c.engine.LoadTorrent(path)
|
return c.engine.LoadTorrent(path, outputRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) StopTorrent() {
|
func (c *Controller) StopTorrent() {
|
||||||
|
|
|
||||||
17
internal/torrent/debug.go
Normal file
17
internal/torrent/debug.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -39,6 +40,10 @@ type Engine struct {
|
||||||
completedPieces int
|
completedPieces int
|
||||||
totalPieces int
|
totalPieces int
|
||||||
outputPath string
|
outputPath string
|
||||||
|
outputRoot string
|
||||||
|
downloadSpeed float64
|
||||||
|
speedAt time.Time
|
||||||
|
speedBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
|
|
@ -50,6 +55,7 @@ type Status struct {
|
||||||
|
|
||||||
CompletedPieces int
|
CompletedPieces int
|
||||||
DownloadedBytes int64
|
DownloadedBytes int64
|
||||||
|
DownloadSpeed float64
|
||||||
TotalBytes int64
|
TotalBytes int64
|
||||||
OutputPath string
|
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.Stop()
|
||||||
e.resetStateForNewLoad()
|
e.resetStateForNewLoad()
|
||||||
e.setPhase("loading_metadata")
|
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)
|
tf, err := torrentfile.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("failed to parse torrent %s: %v", path, err)
|
||||||
e.setError(err)
|
e.setError(err)
|
||||||
e.setPhase("failed")
|
e.setPhase("failed")
|
||||||
return err
|
return err
|
||||||
|
|
@ -131,7 +141,11 @@ func (e *Engine) LoadTorrent(path string) error {
|
||||||
e.totalPieces = len(tf.PieceHashes)
|
e.totalPieces = len(tf.PieceHashes)
|
||||||
e.downloadedBytes = 0
|
e.downloadedBytes = 0
|
||||||
e.completedPieces = 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 {
|
if len(peers) == 0 {
|
||||||
warnErr := fmt.Errorf("no peers yet via %d trackers", len(trackerStatuses))
|
warnErr := fmt.Errorf("no peers yet via %d trackers", len(trackerStatuses))
|
||||||
if trErr := firstTrackerError(trackerStatuses); trErr != nil {
|
if trErr := firstTrackerError(trackerStatuses); trErr != nil {
|
||||||
|
|
@ -143,6 +157,7 @@ func (e *Engine) LoadTorrent(path string) error {
|
||||||
}
|
}
|
||||||
e.phase = "ready"
|
e.phase = "ready"
|
||||||
e.mu.Unlock()
|
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)
|
go e.runDownload(downloadCtx, tf)
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -182,6 +197,7 @@ func (e *Engine) Status() Status {
|
||||||
Phase: e.phase,
|
Phase: e.phase,
|
||||||
CompletedPieces: e.completedPieces,
|
CompletedPieces: e.completedPieces,
|
||||||
DownloadedBytes: e.downloadedBytes,
|
DownloadedBytes: e.downloadedBytes,
|
||||||
|
DownloadSpeed: e.downloadSpeed,
|
||||||
OutputPath: e.outputPath,
|
OutputPath: e.outputPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,6 +223,7 @@ func (e *Engine) queryTrackers(ctx context.Context, tf *torrentfile.TorrentFile,
|
||||||
statuses := make([]TrackerStatus, 0, len(trackersToTry))
|
statuses := make([]TrackerStatus, 0, len(trackersToTry))
|
||||||
|
|
||||||
for _, announce := range trackersToTry {
|
for _, announce := range trackersToTry {
|
||||||
|
log.Printf("tracker announce: %s", announce)
|
||||||
perTrackerCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
perTrackerCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||||
peers, err := tracker.GetPeersFromURL(perTrackerCtx, announce, tf, opts)
|
peers, err := tracker.GetPeersFromURL(perTrackerCtx, announce, tf, opts)
|
||||||
cancel()
|
cancel()
|
||||||
|
|
@ -215,6 +232,7 @@ func (e *Engine) queryTrackers(ctx context.Context, tf *torrentfile.TorrentFile,
|
||||||
if err != nil {
|
if err != nil {
|
||||||
st.State = "error"
|
st.State = "error"
|
||||||
st.Error = err.Error()
|
st.Error = err.Error()
|
||||||
|
log.Printf("tracker announce failed %s: %v", announce, err)
|
||||||
statuses = append(statuses, st)
|
statuses = append(statuses, st)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -226,6 +244,7 @@ func (e *Engine) queryTrackers(ctx context.Context, tf *torrentfile.TorrentFile,
|
||||||
st.PeerCount = len(peers)
|
st.PeerCount = len(peers)
|
||||||
}
|
}
|
||||||
statuses = append(statuses, st)
|
statuses = append(statuses, st)
|
||||||
|
log.Printf("tracker announce result %s: peers=%d state=%s", announce, st.PeerCount, st.State)
|
||||||
|
|
||||||
for _, peer := range peers {
|
for _, peer := range peers {
|
||||||
addPeer(peerMap, peer, announce)
|
addPeer(peerMap, peer, announce)
|
||||||
|
|
@ -250,9 +269,11 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) {
|
||||||
defer e.clearCancel()
|
defer e.clearCancel()
|
||||||
|
|
||||||
e.setPhase("preparing_download")
|
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 {
|
if err != nil {
|
||||||
|
log.Printf("failed to create part file: %v", err)
|
||||||
e.setTerminalError("failed", fmt.Errorf("create temp file: %w", err))
|
e.setTerminalError("failed", fmt.Errorf("create temp file: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -294,6 +315,7 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) {
|
||||||
defer cleanupWorkers()
|
defer cleanupWorkers()
|
||||||
|
|
||||||
e.setPhase("downloading")
|
e.setPhase("downloading")
|
||||||
|
log.Printf("download started: pieces=%d peers=%d", len(tf.PieceHashes), len(e.snapshotPeers()))
|
||||||
lastProgressAt := time.Now()
|
lastProgressAt := time.Now()
|
||||||
lastReannounceAt := time.Now()
|
lastReannounceAt := time.Now()
|
||||||
startWorkers(e.snapshotPeers())
|
startWorkers(e.snapshotPeers())
|
||||||
|
|
@ -313,22 +335,27 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lastProgressAt = time.Now()
|
lastProgressAt = time.Now()
|
||||||
|
debugf("piece completed: %d/%d", e.currentCompletedPieces(), len(tf.PieceHashes))
|
||||||
case <-scheduler.Done():
|
case <-scheduler.Done():
|
||||||
downloadCompleted = true
|
downloadCompleted = true
|
||||||
case workerKey := <-workerDoneCh:
|
case workerKey := <-workerDoneCh:
|
||||||
delete(startedWorkers, workerKey)
|
delete(startedWorkers, workerKey)
|
||||||
|
debugf("worker finished for peer %s", workerKey)
|
||||||
case <-heartbeatTicker.C:
|
case <-heartbeatTicker.C:
|
||||||
|
e.updateSpeed(time.Now())
|
||||||
if time.Since(lastReannounceAt) >= reannounceInterval {
|
if time.Since(lastReannounceAt) >= reannounceInterval {
|
||||||
e.setPhase("querying_trackers")
|
e.setPhase("querying_trackers")
|
||||||
refreshCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
refreshCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
_ = e.refreshPeersFromTrackers(refreshCtx, tf)
|
added := e.refreshPeersFromTrackers(refreshCtx, tf)
|
||||||
cancel()
|
cancel()
|
||||||
lastReannounceAt = time.Now()
|
lastReannounceAt = time.Now()
|
||||||
e.setPhase("downloading")
|
e.setPhase("downloading")
|
||||||
|
debugf("reannounce complete, added peers=%d total=%d", added, len(e.snapshotPeers()))
|
||||||
}
|
}
|
||||||
startWorkers(e.snapshotPeers())
|
startWorkers(e.snapshotPeers())
|
||||||
|
|
||||||
if time.Since(lastProgressAt) >= downloadStallTimeout {
|
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)))
|
e.setTerminalError("stalled", fmt.Errorf("download stalled: no piece progress for %s", downloadStallTimeout.Round(time.Second)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -338,19 +365,24 @@ func (e *Engine) runDownload(ctx context.Context, tf *torrentfile.TorrentFile) {
|
||||||
cleanupWorkers()
|
cleanupWorkers()
|
||||||
|
|
||||||
e.setPhase("writing_files")
|
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))
|
e.setTerminalError("failed", fmt.Errorf("write output files: %w", err))
|
||||||
return
|
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.mu.Lock()
|
||||||
e.downloadedBytes = int64(tf.Length)
|
e.downloadedBytes = int64(tf.Length)
|
||||||
e.completedPieces = len(tf.PieceHashes)
|
e.completedPieces = len(tf.PieceHashes)
|
||||||
e.phase = "completed"
|
e.phase = "completed"
|
||||||
e.lastErr = nil
|
e.lastErr = nil
|
||||||
|
e.downloadSpeed = 0
|
||||||
e.mu.Unlock()
|
e.mu.Unlock()
|
||||||
|
log.Printf("download completed: %s", e.outputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) runPeerWorker(
|
func (e *Engine) runPeerWorker(
|
||||||
|
|
@ -363,11 +395,13 @@ func (e *Engine) runPeerWorker(
|
||||||
e.setPeerState(peer.Address, peer.Port, "connecting", "")
|
e.setPeerState(peer.Address, peer.Port, "connecting", "")
|
||||||
|
|
||||||
peerAddr := net.JoinHostPort(peer.Address, strconv.Itoa(int(peer.Port)))
|
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))
|
client, err := newPeerClient(ctx, peerAddr, tf.InfoHash, e.peerID, len(tf.PieceHashes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
e.setPeerState(peer.Address, peer.Port, "stopped", "")
|
e.setPeerState(peer.Address, peer.Port, "stopped", "")
|
||||||
} else {
|
} else {
|
||||||
|
log.Printf("peer %s connection failed: %v", peerAddr, err)
|
||||||
e.setPeerState(peer.Address, peer.Port, "error", err.Error())
|
e.setPeerState(peer.Address, peer.Port, "error", err.Error())
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
@ -389,6 +423,7 @@ func (e *Engine) runPeerWorker(
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
e.setPeerState(peer.Address, peer.Port, "stopped", "")
|
e.setPeerState(peer.Address, peer.Port, "stopped", "")
|
||||||
} else {
|
} else {
|
||||||
|
log.Printf("peer %s scheduler acquire failed: %v", peerAddr, err)
|
||||||
e.setPeerState(peer.Address, peer.Port, "error", err.Error())
|
e.setPeerState(peer.Address, peer.Port, "error", err.Error())
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
@ -411,13 +446,16 @@ func (e *Engine) runPeerWorker(
|
||||||
pieceSize := pieceSizeForIndex(tf, task.Index)
|
pieceSize := pieceSizeForIndex(tf, task.Index)
|
||||||
pieceData, err := client.DownloadPiece(ctx, task.Index, pieceSize)
|
pieceData, err := client.DownloadPiece(ctx, task.Index, pieceSize)
|
||||||
if err != nil {
|
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 {
|
if ctx.Err() != nil {
|
||||||
e.setPeerState(peer.Address, peer.Port, "stopped", "")
|
e.setPeerState(peer.Address, peer.Port, "stopped", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
consecutiveFailures++
|
consecutiveFailures++
|
||||||
|
log.Printf("peer %s disconnected: %v", peerAddr, err)
|
||||||
e.setPeerState(peer.Address, peer.Port, "error", err.Error())
|
e.setPeerState(peer.Address, peer.Port, "error", err.Error())
|
||||||
if shouldDropPeer(err) || consecutiveFailures >= 3 {
|
if shouldDropPeer(err) || consecutiveFailures >= 3 {
|
||||||
return
|
return
|
||||||
|
|
@ -427,8 +465,11 @@ func (e *Engine) runPeerWorker(
|
||||||
|
|
||||||
hash := sha1.Sum(pieceData)
|
hash := sha1.Sum(pieceData)
|
||||||
if hash != tf.PieceHashes[task.Index] {
|
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++
|
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))
|
e.setPeerState(peer.Address, peer.Port, "error", fmt.Sprintf("piece %d hash mismatch", task.Index))
|
||||||
if consecutiveFailures >= 3 {
|
if consecutiveFailures >= 3 {
|
||||||
return
|
return
|
||||||
|
|
@ -438,7 +479,10 @@ func (e *Engine) runPeerWorker(
|
||||||
|
|
||||||
offset := int64(task.Index * tf.PieceLength)
|
offset := int64(task.Index * tf.PieceLength)
|
||||||
if _, err := partFile.WriteAt(pieceData, offset); err != nil {
|
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))
|
e.setPeerState(peer.Address, peer.Port, "error", fmt.Sprintf("write piece %d: %v", task.Index, err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -454,6 +498,9 @@ func (e *Engine) runPeerWorker(
|
||||||
}
|
}
|
||||||
if accepted {
|
if accepted {
|
||||||
e.recordPieceComplete(peer.Address, peer.Port, len(pieceData))
|
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
|
consecutiveFailures = 0
|
||||||
|
|
@ -512,6 +559,7 @@ func (e *Engine) refreshPeersFromTrackers(ctx context.Context, tf *torrentfile.T
|
||||||
waitErr = fmt.Errorf("waiting for peers: %w", trErr)
|
waitErr = fmt.Errorf("waiting for peers: %w", trErr)
|
||||||
}
|
}
|
||||||
e.lastErr = waitErr
|
e.lastErr = waitErr
|
||||||
|
debugf("reannounce returned no peers")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
e.lastErr = nil
|
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
|
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
|
return added
|
||||||
}
|
}
|
||||||
|
|
@ -550,11 +601,18 @@ func (e *Engine) currentDownloadedBytes() int64 {
|
||||||
return e.downloadedBytes
|
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) {
|
func (e *Engine) recordPieceComplete(address string, port uint16, pieceSize int) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
e.downloadedBytes += int64(pieceSize)
|
e.downloadedBytes += int64(pieceSize)
|
||||||
e.completedPieces++
|
e.completedPieces++
|
||||||
|
e.updateSpeedLocked(time.Now())
|
||||||
for i := range e.peers {
|
for i := range e.peers {
|
||||||
if e.peers[i].Address == address && e.peers[i].Port == port {
|
if e.peers[i].Address == address && e.peers[i].Port == port {
|
||||||
e.peers[i].DownloadedPieces++
|
e.peers[i].DownloadedPieces++
|
||||||
|
|
@ -580,6 +638,7 @@ func (e *Engine) setTerminalError(phase string, err error) {
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
e.phase = phase
|
e.phase = phase
|
||||||
e.lastErr = err
|
e.lastErr = err
|
||||||
|
e.downloadSpeed = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) progressUnsafe() float64 {
|
func (e *Engine) progressUnsafe() float64 {
|
||||||
|
|
@ -628,6 +687,36 @@ func (e *Engine) resetStateForNewLoad() {
|
||||||
e.completedPieces = 0
|
e.completedPieces = 0
|
||||||
e.totalPieces = 0
|
e.totalPieces = 0
|
||||||
e.outputPath = ""
|
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) {
|
func (e *Engine) setError(err error) {
|
||||||
|
|
@ -755,8 +844,8 @@ func firstTrackerError(statuses []TrackerStatus) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createPartFile(tf *torrentfile.TorrentFile) (string, *os.File, error) {
|
func createPartFile(tf *torrentfile.TorrentFile, outputRoot string) (string, *os.File, error) {
|
||||||
partsDir := filepath.Join("downloads", ".parts")
|
partsDir := filepath.Join(outputRoot, ".ztorrent-parts")
|
||||||
if err := os.MkdirAll(partsDir, 0o755); err != nil {
|
if err := os.MkdirAll(partsDir, 0o755); err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -834,12 +923,7 @@ func safeOutputPath(root, rel string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildOutputPath(tf *torrentfile.TorrentFile, outputRoot string) string {
|
func buildOutputPath(tf *torrentfile.TorrentFile, outputRoot string) string {
|
||||||
if len(tf.Files) == 1 {
|
return filepath.Join(outputRoot, tf.Name)
|
||||||
if path, err := safeOutputPath(outputRoot, tf.Files[0].Path); err == nil {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filepath.Join(outputRoot, sanitizePathPart(tf.Name))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizePathPart(s string) string {
|
func sanitizePathPart(s string) string {
|
||||||
|
|
@ -864,3 +948,16 @@ func sanitizePathPart(s string) string {
|
||||||
}
|
}
|
||||||
return out
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -50,8 +51,10 @@ func newPeerClient(ctx context.Context, addr string, infoHash [20]byte, peerID [
|
||||||
dialer := net.Dialer{Timeout: peerConnectTimeout}
|
dialer := net.Dialer{Timeout: peerConnectTimeout}
|
||||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("peer dial failed %s: %v", addr, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
log.Printf("connected to peer %s", addr)
|
||||||
|
|
||||||
pc := &peerClient{
|
pc := &peerClient{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
|
|
@ -72,8 +75,10 @@ func newPeerClient(ctx context.Context, addr string, infoHash [20]byte, peerID [
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
debugf("sent interested to %s", addr)
|
||||||
|
|
||||||
if err := pc.readInitialMessages(ctx); err != nil {
|
if err := pc.readInitialMessages(ctx); err != nil {
|
||||||
|
log.Printf("peer %s initial message read failed: %v", addr, err)
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +137,7 @@ func (pc *peerClient) readInitialMessages(ctx context.Context) error {
|
||||||
msg, err := readWireMessage(pc.conn)
|
msg, err := readWireMessage(pc.conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isTimeout(err) {
|
if isTimeout(err) {
|
||||||
|
debugf("peer initial message window finished")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|
@ -166,12 +172,14 @@ func (pc *peerClient) waitForUnchoke(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
pc.consumeMessage(msg)
|
pc.consumeMessage(msg)
|
||||||
if msg.ID == msgUnchoke {
|
if msg.ID == msgUnchoke {
|
||||||
|
debugf("peer unchoked")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pc *peerClient) sendRequest(ctx context.Context, pieceIndex, begin, length int) error {
|
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)
|
payload := make([]byte, 12)
|
||||||
binary.BigEndian.PutUint32(payload[0:4], uint32(pieceIndex))
|
binary.BigEndian.PutUint32(payload[0:4], uint32(pieceIndex))
|
||||||
binary.BigEndian.PutUint32(payload[4:8], uint32(begin))
|
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 {
|
if len(block) > expectedLen {
|
||||||
block = block[:expectedLen]
|
block = block[:expectedLen]
|
||||||
}
|
}
|
||||||
|
debugf("received piece %d offset %d block=%d", pieceIndex, begin, len(block))
|
||||||
return block, nil
|
return block, nil
|
||||||
case msgChoke:
|
case msgChoke:
|
||||||
pc.consumeMessage(msg)
|
pc.consumeMessage(msg)
|
||||||
|
|
@ -228,8 +237,10 @@ func (pc *peerClient) consumeMessage(msg wireMessage) {
|
||||||
switch msg.ID {
|
switch msg.ID {
|
||||||
case msgChoke:
|
case msgChoke:
|
||||||
pc.peerIsChoked = true
|
pc.peerIsChoked = true
|
||||||
|
debugf("peer sent choke")
|
||||||
case msgUnchoke:
|
case msgUnchoke:
|
||||||
pc.peerIsChoked = false
|
pc.peerIsChoked = false
|
||||||
|
debugf("peer sent unchoke")
|
||||||
case msgHave:
|
case msgHave:
|
||||||
if len(msg.Payload) < 4 {
|
if len(msg.Payload) < 4 {
|
||||||
return
|
return
|
||||||
|
|
@ -238,9 +249,11 @@ func (pc *peerClient) consumeMessage(msg wireMessage) {
|
||||||
if idx >= 0 && idx < len(pc.have) {
|
if idx >= 0 && idx < len(pc.have) {
|
||||||
pc.have[idx] = true
|
pc.have[idx] = true
|
||||||
pc.hasPieceInfo = true
|
pc.hasPieceInfo = true
|
||||||
|
debugf("received have piece %d", idx)
|
||||||
}
|
}
|
||||||
case msgBitfield:
|
case msgBitfield:
|
||||||
pc.hasPieceInfo = true
|
pc.hasPieceInfo = true
|
||||||
|
debugf("received bitfield (%d bytes)", len(msg.Payload))
|
||||||
for i := range pc.have {
|
for i := range pc.have {
|
||||||
pc.have[i] = bitfieldHasPiece(msg.Payload, i)
|
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 {
|
func (pc *peerClient) sendHandshake(ctx context.Context, infoHash [20]byte, peerID [20]byte) error {
|
||||||
|
debugf("sending handshake")
|
||||||
payload := make([]byte, 49+len(wireProtocolString))
|
payload := make([]byte, 49+len(wireProtocolString))
|
||||||
payload[0] = byte(len(wireProtocolString))
|
payload[0] = byte(len(wireProtocolString))
|
||||||
copy(payload[1:1+len(wireProtocolString)], 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[:]) {
|
if !bytes.Equal(rest[infoHashOffset:infoHashOffset+20], expectedInfoHash[:]) {
|
||||||
return errors.New("peer info_hash mismatch")
|
return errors.New("peer info_hash mismatch")
|
||||||
}
|
}
|
||||||
|
debugf("handshake complete")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@ func (ps *pieceScheduler) run(pieceCount int) {
|
||||||
pieceIndex := selectPendingPiece(states, req.have, req.hasInfo)
|
pieceIndex := selectPendingPiece(states, req.have, req.hasInfo)
|
||||||
if pieceIndex >= 0 {
|
if pieceIndex >= 0 {
|
||||||
states[pieceIndex] = pieceInProgress
|
states[pieceIndex] = pieceInProgress
|
||||||
|
debugf("scheduler assigned piece %d", pieceIndex)
|
||||||
req.responseCh <- assignPieceResponse{
|
req.responseCh <- assignPieceResponse{
|
||||||
task: pieceTask{Index: pieceIndex},
|
task: pieceTask{Index: pieceIndex},
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -164,11 +165,13 @@ func (ps *pieceScheduler) run(pieceCount int) {
|
||||||
|
|
||||||
if req.success {
|
if req.success {
|
||||||
if states[req.pieceIndex] == pieceDone {
|
if states[req.pieceIndex] == pieceDone {
|
||||||
|
debugf("scheduler report piece %d ignored (already done)", req.pieceIndex)
|
||||||
req.responseCh <- false
|
req.responseCh <- false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
states[req.pieceIndex] = pieceDone
|
states[req.pieceIndex] = pieceDone
|
||||||
completed++
|
completed++
|
||||||
|
debugf("scheduler report piece %d success (%d/%d)", req.pieceIndex, completed, pieceCount)
|
||||||
select {
|
select {
|
||||||
case ps.progressCh <- req.pieceIndex:
|
case ps.progressCh <- req.pieceIndex:
|
||||||
default:
|
default:
|
||||||
|
|
@ -179,6 +182,7 @@ func (ps *pieceScheduler) run(pieceCount int) {
|
||||||
|
|
||||||
if states[req.pieceIndex] == pieceInProgress {
|
if states[req.pieceIndex] == pieceInProgress {
|
||||||
states[req.pieceIndex] = piecePending
|
states[req.pieceIndex] = piecePending
|
||||||
|
debugf("scheduler report piece %d failed, re-queued", req.pieceIndex)
|
||||||
}
|
}
|
||||||
req.responseCh <- false
|
req.responseCh <- false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
torrent-client
Executable file
BIN
torrent-client
Executable file
Binary file not shown.
66
ui/window.go
66
ui/window.go
|
|
@ -27,6 +27,7 @@ type desktopUI struct {
|
||||||
window fyne.Window
|
window fyne.Window
|
||||||
|
|
||||||
pathEntry *widget.Entry
|
pathEntry *widget.Entry
|
||||||
|
downloadDirEntry *widget.Entry
|
||||||
progress *widget.ProgressBar
|
progress *widget.ProgressBar
|
||||||
phaseLabel *widget.Label
|
phaseLabel *widget.Label
|
||||||
phaseMetricLabel *widget.Label
|
phaseMetricLabel *widget.Label
|
||||||
|
|
@ -38,6 +39,7 @@ type desktopUI struct {
|
||||||
pieceCountLabel *widget.Label
|
pieceCountLabel *widget.Label
|
||||||
downloadedLabel *widget.Label
|
downloadedLabel *widget.Label
|
||||||
outputLabel *widget.Label
|
outputLabel *widget.Label
|
||||||
|
speedLabel *widget.Label
|
||||||
peerCountLabel *widget.Label
|
peerCountLabel *widget.Label
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
@ -68,6 +70,7 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI {
|
||||||
window: w,
|
window: w,
|
||||||
stopRefresh: make(chan struct{}),
|
stopRefresh: make(chan struct{}),
|
||||||
pathEntry: widget.NewEntry(),
|
pathEntry: widget.NewEntry(),
|
||||||
|
downloadDirEntry: widget.NewEntry(),
|
||||||
progress: widget.NewProgressBar(),
|
progress: widget.NewProgressBar(),
|
||||||
phaseLabel: widget.NewLabel("Phase: idle"),
|
phaseLabel: widget.NewLabel("Phase: idle"),
|
||||||
phaseMetricLabel: widget.NewLabel("idle"),
|
phaseMetricLabel: widget.NewLabel("idle"),
|
||||||
|
|
@ -79,14 +82,22 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI {
|
||||||
pieceCountLabel: widget.NewLabel("-"),
|
pieceCountLabel: widget.NewLabel("-"),
|
||||||
downloadedLabel: widget.NewLabel("0 / 0"),
|
downloadedLabel: widget.NewLabel("0 / 0"),
|
||||||
outputLabel: widget.NewLabel("-"),
|
outputLabel: widget.NewLabel("-"),
|
||||||
|
speedLabel: widget.NewLabel("0 B/s"),
|
||||||
peerCountLabel: widget.NewLabel("0"),
|
peerCountLabel: widget.NewLabel("0"),
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.pathEntry.SetPlaceHolder("/path/to/file.torrent")
|
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)
|
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,
|
metaGrid := container.NewGridWithColumns(4,
|
||||||
wrapMetric("Name", ui.nameLabel),
|
wrapMetric("Name", ui.nameLabel),
|
||||||
|
|
@ -94,6 +105,7 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI {
|
||||||
wrapMetric("Piece length", ui.pieceLabel),
|
wrapMetric("Piece length", ui.pieceLabel),
|
||||||
wrapMetric("Completed pieces", ui.pieceCountLabel),
|
wrapMetric("Completed pieces", ui.pieceCountLabel),
|
||||||
wrapMetric("Downloaded", ui.downloadedLabel),
|
wrapMetric("Downloaded", ui.downloadedLabel),
|
||||||
|
wrapMetric("Speed", ui.speedLabel),
|
||||||
wrapMetric("Output", ui.outputLabel),
|
wrapMetric("Output", ui.outputLabel),
|
||||||
wrapMetric("Discovered peers", ui.peerCountLabel),
|
wrapMetric("Discovered peers", ui.peerCountLabel),
|
||||||
wrapMetric("Phase", ui.phaseMetricLabel),
|
wrapMetric("Phase", ui.phaseMetricLabel),
|
||||||
|
|
@ -117,7 +129,7 @@ func newDesktopUI(controller *appcore.Controller) *desktopUI {
|
||||||
)
|
)
|
||||||
|
|
||||||
content := container.NewBorder(
|
content := container.NewBorder(
|
||||||
container.NewVBox(topRow),
|
topRows,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
|
@ -172,12 +184,26 @@ func (ui *desktopUI) onLoad() {
|
||||||
ui.setError("select a .torrent file first")
|
ui.setError("select a .torrent file first")
|
||||||
return
|
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.setMessage("Loading torrent...")
|
||||||
ui.setError("")
|
ui.setError("")
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
err := ui.controller.StartTorrent(path)
|
err := ui.controller.StartTorrent(path, downloadRoot)
|
||||||
status := ui.controller.Status()
|
status := ui.controller.Status()
|
||||||
ui.updateFromStatus(status)
|
ui.updateFromStatus(status)
|
||||||
if err != nil {
|
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() {
|
func (ui *desktopUI) refreshLoop() {
|
||||||
ticker := time.NewTicker(1200 * time.Millisecond)
|
ticker := time.NewTicker(1200 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
@ -216,6 +256,7 @@ func (ui *desktopUI) updateFromStatus(status torrent.Status) {
|
||||||
ui.pieceCountLabel.SetText("-")
|
ui.pieceCountLabel.SetText("-")
|
||||||
}
|
}
|
||||||
ui.downloadedLabel.SetText(fmt.Sprintf("%d / %d", status.DownloadedBytes, status.TotalBytes))
|
ui.downloadedLabel.SetText(fmt.Sprintf("%d / %d", status.DownloadedBytes, status.TotalBytes))
|
||||||
|
ui.speedLabel.SetText(formatBytesRate(status.DownloadSpeed))
|
||||||
ui.outputLabel.SetText(status.OutputPath)
|
ui.outputLabel.SetText(status.OutputPath)
|
||||||
ui.peerCountLabel.SetText(strconv.Itoa(status.PeerCount))
|
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) {
|
func (ui *desktopUI) setMessage(msg string) {
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
ui.messageLabel.SetText(msg)
|
ui.messageLabel.SetText(msg)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue