ебаааать
This commit is contained in:
parent
636fd5baa8
commit
9bcf57101b
10 changed files with 600 additions and 30 deletions
15
README.md
15
README.md
|
|
@ -5,14 +5,23 @@ Minimal BitTorrent client skeleton in Go.
|
|||
## Run
|
||||
|
||||
```bash
|
||||
go run ./cmd/torrent-client /path/to/file.torrent
|
||||
go run ./cmd/torrent-client
|
||||
```
|
||||
|
||||
App starts a local GUI in your browser (auto-opens on macOS/Linux/Windows).
|
||||
|
||||
## Current features
|
||||
|
||||
- Parse `.torrent` metadata and piece hashes
|
||||
- Compute `info_hash` from raw `info` bytes (spec-compatible)
|
||||
- Single-file and multi-file torrents
|
||||
- Send tracker announce request with compact peer mode
|
||||
- Send tracker announce requests via HTTP(S) and UDP trackers
|
||||
- Parse compact peer list
|
||||
- Show loaded torrent status and files in the UI layer
|
||||
- Browser GUI for loading torrents by path or file upload
|
||||
- Show loaded torrent status and files in the GUI
|
||||
|
||||
## Build (macOS/Linux/Windows)
|
||||
|
||||
```bash
|
||||
./scripts/build-all.sh
|
||||
```
|
||||
|
|
|
|||
BIN
dist/ztorrent-darwin-amd64
vendored
Executable file
BIN
dist/ztorrent-darwin-amd64
vendored
Executable file
Binary file not shown.
BIN
dist/ztorrent-darwin-arm64
vendored
Executable file
BIN
dist/ztorrent-darwin-arm64
vendored
Executable file
Binary file not shown.
BIN
dist/ztorrent-linux-amd64
vendored
Executable file
BIN
dist/ztorrent-linux-amd64
vendored
Executable file
Binary file not shown.
BIN
dist/ztorrent-linux-arm64
vendored
Executable file
BIN
dist/ztorrent-linux-arm64
vendored
Executable file
Binary file not shown.
BIN
dist/ztorrent-windows-amd64.exe
vendored
Executable file
BIN
dist/ztorrent-windows-amd64.exe
vendored
Executable file
Binary file not shown.
|
|
@ -2,6 +2,8 @@ package tracker
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
|
@ -40,9 +42,26 @@ type httpDoer interface {
|
|||
}
|
||||
|
||||
func GetPeers(ctx context.Context, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
|
||||
if tf == nil {
|
||||
return nil, errors.New("torrent metadata is nil")
|
||||
}
|
||||
|
||||
opts = normalizeOptions(opts)
|
||||
client := &http.Client{Timeout: opts.Timeout}
|
||||
return getPeersWithClient(ctx, client, tf, opts)
|
||||
|
||||
announceURL, err := url.Parse(tf.Announce)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid tracker URL %q: %w", tf.Announce, err)
|
||||
}
|
||||
|
||||
switch announceURL.Scheme {
|
||||
case "http", "https":
|
||||
client := &http.Client{Timeout: opts.Timeout}
|
||||
return getPeersWithClient(ctx, client, tf, opts)
|
||||
case "udp":
|
||||
return getPeersUDP(ctx, announceURL, tf, opts)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported tracker scheme %q (only http/https/udp are supported)", announceURL.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
func getPeersWithClient(ctx context.Context, client httpDoer, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
|
||||
|
|
@ -90,6 +109,79 @@ func getPeersWithClient(ctx context.Context, client httpDoer, tf *torrentfile.To
|
|||
return parsePeers([]byte(tr.Peers))
|
||||
}
|
||||
|
||||
func getPeersUDP(ctx context.Context, announceURL *url.URL, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
|
||||
if announceURL == nil {
|
||||
return nil, errors.New("tracker URL is nil")
|
||||
}
|
||||
if announceURL.Scheme != "udp" {
|
||||
return nil, fmt.Errorf("tracker URL scheme %q is not udp", announceURL.Scheme)
|
||||
}
|
||||
if announceURL.Host == "" {
|
||||
return nil, errors.New("tracker URL host is empty")
|
||||
}
|
||||
|
||||
dialer := net.Dialer{Timeout: opts.Timeout}
|
||||
conn, err := dialer.DialContext(ctx, "udp", announceURL.Host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
deadline := time.Now().Add(opts.Timeout)
|
||||
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
|
||||
deadline = ctxDeadline
|
||||
}
|
||||
if err := conn.SetDeadline(deadline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connectTx, err := randomUint32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connectReq := buildUDPConnectRequest(connectTx)
|
||||
if _, err := conn.Write(connectReq[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := make([]byte, 65535)
|
||||
n, err := conn.Read(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connectionID, err := parseUDPConnectResponse(resp[:n], connectTx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
announceTx, err := randomUint32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := randomUint32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
announceReq, err := buildUDPAnnounceRequest(connectionID, announceTx, tf, opts, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := conn.Write(announceReq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n, err = conn.Read(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return parseUDPAnnounceResponse(resp[:n], announceTx)
|
||||
}
|
||||
|
||||
func normalizeOptions(opts AnnounceOptions) AnnounceOptions {
|
||||
if opts.Port == 0 {
|
||||
opts.Port = 6881
|
||||
|
|
@ -154,6 +246,105 @@ func buildAnnounceURL(tf *torrentfile.TorrentFile, opts AnnounceOptions) (string
|
|||
return baseURL.String(), nil
|
||||
}
|
||||
|
||||
func buildUDPConnectRequest(transactionID uint32) [16]byte {
|
||||
var req [16]byte
|
||||
binary.BigEndian.PutUint64(req[0:8], 0x41727101980)
|
||||
binary.BigEndian.PutUint32(req[8:12], 0)
|
||||
binary.BigEndian.PutUint32(req[12:16], transactionID)
|
||||
return req
|
||||
}
|
||||
|
||||
func parseUDPConnectResponse(payload []byte, expectedTransactionID uint32) (uint64, error) {
|
||||
if len(payload) < 8 {
|
||||
return 0, fmt.Errorf("udp tracker connect response too short: %d bytes", len(payload))
|
||||
}
|
||||
|
||||
action := binary.BigEndian.Uint32(payload[0:4])
|
||||
transactionID := binary.BigEndian.Uint32(payload[4:8])
|
||||
if transactionID != expectedTransactionID {
|
||||
return 0, fmt.Errorf("udp tracker connect transaction mismatch: expected %d, got %d", expectedTransactionID, transactionID)
|
||||
}
|
||||
|
||||
if action == 3 {
|
||||
return 0, parseUDPTrackerError(payload)
|
||||
}
|
||||
if action != 0 {
|
||||
return 0, fmt.Errorf("unexpected udp tracker connect action %d", action)
|
||||
}
|
||||
if len(payload) < 16 {
|
||||
return 0, fmt.Errorf("udp tracker connect response too short: %d bytes", len(payload))
|
||||
}
|
||||
|
||||
return binary.BigEndian.Uint64(payload[8:16]), nil
|
||||
}
|
||||
|
||||
func buildUDPAnnounceRequest(connectionID uint64, transactionID uint32, tf *torrentfile.TorrentFile, opts AnnounceOptions, key uint32) ([]byte, error) {
|
||||
if tf == nil {
|
||||
return nil, errors.New("torrent metadata is nil")
|
||||
}
|
||||
|
||||
uploaded := opts.Uploaded
|
||||
if uploaded < 0 {
|
||||
uploaded = 0
|
||||
}
|
||||
downloaded := opts.Downloaded
|
||||
if downloaded < 0 {
|
||||
downloaded = 0
|
||||
}
|
||||
left := int64(tf.Length) - downloaded
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
|
||||
req := make([]byte, 98)
|
||||
binary.BigEndian.PutUint64(req[0:8], connectionID)
|
||||
binary.BigEndian.PutUint32(req[8:12], 1)
|
||||
binary.BigEndian.PutUint32(req[12:16], transactionID)
|
||||
copy(req[16:36], tf.InfoHash[:])
|
||||
copy(req[36:56], opts.PeerID[:])
|
||||
binary.BigEndian.PutUint64(req[56:64], uint64(downloaded))
|
||||
binary.BigEndian.PutUint64(req[64:72], uint64(left))
|
||||
binary.BigEndian.PutUint64(req[72:80], uint64(uploaded))
|
||||
binary.BigEndian.PutUint32(req[80:84], 0)
|
||||
binary.BigEndian.PutUint32(req[84:88], 0)
|
||||
binary.BigEndian.PutUint32(req[88:92], key)
|
||||
binary.BigEndian.PutUint32(req[92:96], uint32(int32(opts.NumWant)))
|
||||
binary.BigEndian.PutUint16(req[96:98], opts.Port)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func parseUDPAnnounceResponse(payload []byte, expectedTransactionID uint32) ([]Peer, error) {
|
||||
if len(payload) < 8 {
|
||||
return nil, fmt.Errorf("udp tracker announce response too short: %d bytes", len(payload))
|
||||
}
|
||||
|
||||
action := binary.BigEndian.Uint32(payload[0:4])
|
||||
transactionID := binary.BigEndian.Uint32(payload[4:8])
|
||||
if transactionID != expectedTransactionID {
|
||||
return nil, fmt.Errorf("udp tracker announce transaction mismatch: expected %d, got %d", expectedTransactionID, transactionID)
|
||||
}
|
||||
|
||||
if action == 3 {
|
||||
return nil, parseUDPTrackerError(payload)
|
||||
}
|
||||
if action != 1 {
|
||||
return nil, fmt.Errorf("unexpected udp tracker announce action %d", action)
|
||||
}
|
||||
if len(payload) < 20 {
|
||||
return nil, fmt.Errorf("udp tracker announce response too short: %d bytes", len(payload))
|
||||
}
|
||||
|
||||
return parsePeers(payload[20:])
|
||||
}
|
||||
|
||||
func parseUDPTrackerError(payload []byte) error {
|
||||
if len(payload) > 8 {
|
||||
return fmt.Errorf("udp tracker failure: %s", strings.TrimSpace(string(payload[8:])))
|
||||
}
|
||||
return errors.New("udp tracker failure")
|
||||
}
|
||||
|
||||
func escapeBinary(data []byte) string {
|
||||
const hex = "0123456789ABCDEF"
|
||||
|
||||
|
|
@ -210,3 +401,11 @@ func parsePeers(data []byte) ([]Peer, error) {
|
|||
|
||||
return peers, nil
|
||||
}
|
||||
|
||||
func randomUint32() (uint32, error) {
|
||||
var b [4]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return binary.BigEndian.Uint32(b[:]), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package tracker
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
|
@ -189,6 +190,86 @@ func TestGetPeersReturnsTrackerFailure(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseUDPConnectResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transactionID := uint32(42)
|
||||
connectionID := uint64(0x0102030405060708)
|
||||
|
||||
payload := make([]byte, 16)
|
||||
binary.BigEndian.PutUint32(payload[0:4], 0)
|
||||
binary.BigEndian.PutUint32(payload[4:8], transactionID)
|
||||
binary.BigEndian.PutUint64(payload[8:16], connectionID)
|
||||
|
||||
got, err := parseUDPConnectResponse(payload, transactionID)
|
||||
if err != nil {
|
||||
t.Fatalf("parseUDPConnectResponse returned error: %v", err)
|
||||
}
|
||||
if got != connectionID {
|
||||
t.Fatalf("unexpected connection ID: got %x want %x", got, connectionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUDPAnnounceResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transactionID := uint32(99)
|
||||
payload := make([]byte, 20+6)
|
||||
binary.BigEndian.PutUint32(payload[0:4], 1)
|
||||
binary.BigEndian.PutUint32(payload[4:8], transactionID)
|
||||
binary.BigEndian.PutUint32(payload[8:12], 1800)
|
||||
binary.BigEndian.PutUint32(payload[12:16], 10)
|
||||
binary.BigEndian.PutUint32(payload[16:20], 20)
|
||||
copy(payload[20:], []byte{127, 0, 0, 1, 0x1A, 0xE1})
|
||||
|
||||
peers, err := parseUDPAnnounceResponse(payload, transactionID)
|
||||
if err != nil {
|
||||
t.Fatalf("parseUDPAnnounceResponse returned error: %v", err)
|
||||
}
|
||||
if len(peers) != 1 {
|
||||
t.Fatalf("unexpected peer count: got %d", len(peers))
|
||||
}
|
||||
if got, want := peers[0].Port, uint16(6881); got != want {
|
||||
t.Fatalf("unexpected peer port: got %d want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUDPAnnounceRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tf := &torrentfile.TorrentFile{
|
||||
Length: 100,
|
||||
}
|
||||
copy(tf.InfoHash[:], []byte("abcdefghijklmnopqrst"))
|
||||
|
||||
var peerID [20]byte
|
||||
copy(peerID[:], []byte("-ZT0001-123456789012"))
|
||||
|
||||
req, err := buildUDPAnnounceRequest(0x0102030405060708, 42, tf, AnnounceOptions{
|
||||
PeerID: peerID,
|
||||
Port: 6881,
|
||||
Uploaded: -1,
|
||||
Downloaded: 20,
|
||||
NumWant: 50,
|
||||
}, 0xAABBCCDD)
|
||||
if err != nil {
|
||||
t.Fatalf("buildUDPAnnounceRequest returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(req) != 98 {
|
||||
t.Fatalf("unexpected announce request length: got %d", len(req))
|
||||
}
|
||||
if got, want := binary.BigEndian.Uint32(req[8:12]), uint32(1); got != want {
|
||||
t.Fatalf("unexpected action: got %d want %d", got, want)
|
||||
}
|
||||
if got, want := binary.BigEndian.Uint64(req[64:72]), uint64(80); got != want {
|
||||
t.Fatalf("unexpected left bytes: got %d want %d", got, want)
|
||||
}
|
||||
if got, want := binary.BigEndian.Uint64(req[72:80]), uint64(0); got != want {
|
||||
t.Fatalf("unexpected uploaded bytes: got %d want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeClient struct {
|
||||
do func(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
|
|
|||
32
scripts/build-all.sh
Executable file
32
scripts/build-all.sh
Executable file
|
|
@ -0,0 +1,32 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DIST_DIR="${ROOT_DIR}/dist"
|
||||
|
||||
mkdir -p "${DIST_DIR}"
|
||||
: "${GOCACHE:=/tmp/go-build}"
|
||||
mkdir -p "${GOCACHE}"
|
||||
|
||||
TARGETS=(
|
||||
"darwin amd64"
|
||||
"darwin arm64"
|
||||
"linux amd64"
|
||||
"linux arm64"
|
||||
"windows amd64"
|
||||
)
|
||||
|
||||
for target in "${TARGETS[@]}"; do
|
||||
read -r goos goarch <<<"${target}"
|
||||
|
||||
ext=""
|
||||
if [[ "${goos}" == "windows" ]]; then
|
||||
ext=".exe"
|
||||
fi
|
||||
|
||||
output="${DIST_DIR}/ztorrent-${goos}-${goarch}${ext}"
|
||||
echo "Building ${output}"
|
||||
GOCACHE="${GOCACHE}" CGO_ENABLED=0 GOOS="${goos}" GOARCH="${goarch}" go build -o "${output}" ./cmd/torrent-client
|
||||
done
|
||||
|
||||
echo "Build complete. Artifacts are in ${DIST_DIR}"
|
||||
299
ui/window.go
299
ui/window.go
|
|
@ -1,42 +1,291 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/veggiedefender/torrent-client/internal/app"
|
||||
"github.com/veggiedefender/torrent-client/internal/torrent"
|
||||
)
|
||||
|
||||
type webUI struct {
|
||||
controller *app.Controller
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Status torrent.Status
|
||||
Message string
|
||||
Error string
|
||||
}
|
||||
|
||||
var pageTemplate = template.Must(template.New("index").Parse(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Ztorrent</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--card: #111827;
|
||||
--text: #e5e7eb;
|
||||
--muted: #94a3b8;
|
||||
--accent: #22c55e;
|
||||
--danger: #ef4444;
|
||||
--border: #1f2937;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
background: radial-gradient(circle at top, #1f2937 0%, var(--bg) 45%);
|
||||
color: var(--text);
|
||||
}
|
||||
.wrap {
|
||||
max-width: 960px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px 24px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--card), transparent 10%);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
h1, h2 { margin: 0 0 12px; }
|
||||
.sub { color: var(--muted); margin: 0 0 16px; }
|
||||
label { display: block; margin-bottom: 8px; font-weight: 600; }
|
||||
input[type="text"], input[type="file"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #334155;
|
||||
background: #0b1220;
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.row { display: grid; gap: 10px; grid-template-columns: 1fr; }
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
color: #06120a;
|
||||
background: var(--accent);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.msg, .err {
|
||||
margin-top: 10px;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.msg { background: color-mix(in srgb, var(--accent), transparent 85%); }
|
||||
.err { background: color-mix(in srgb, var(--danger), transparent 85%); }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.kv {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: #0b1220;
|
||||
}
|
||||
.k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.v { margin-top: 6px; font-weight: 600; word-break: break-word; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px 4px;
|
||||
text-align: left;
|
||||
}
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h1>Ztorrent</h1>
|
||||
<p class="sub">Load a torrent by path or upload .torrent file</p>
|
||||
<form method="post" action="/load" enctype="multipart/form-data">
|
||||
<div class="row">
|
||||
<label for="path">Torrent path</label>
|
||||
<input type="text" name="path" id="path" placeholder="/path/to/file.torrent">
|
||||
<label for="torrent-file">Or upload .torrent</label>
|
||||
<input type="file" name="torrent_file" id="torrent-file" accept=".torrent,application/x-bittorrent">
|
||||
<button type="submit">Load Torrent</button>
|
||||
</div>
|
||||
</form>
|
||||
{{if .Message}}<div class="msg">{{.Message}}</div>{{end}}
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Status</h2>
|
||||
{{if .Status.Loaded}}
|
||||
<div class="grid">
|
||||
<div class="kv"><div class="k">Name</div><div class="v">{{.Status.Name}}</div></div>
|
||||
<div class="kv"><div class="k">Size</div><div class="v">{{.Status.Length}} bytes</div></div>
|
||||
<div class="kv"><div class="k">Piece Length</div><div class="v">{{.Status.PieceLength}} bytes</div></div>
|
||||
<div class="kv"><div class="k">Pieces</div><div class="v">{{.Status.PieceCount}}</div></div>
|
||||
<div class="kv"><div class="k">Files</div><div class="v">{{len .Status.Files}}</div></div>
|
||||
<div class="kv"><div class="k">Peers</div><div class="v">{{.Status.PeerCount}}</div></div>
|
||||
<div class="kv"><div class="k">Tracker</div><div class="v">{{.Status.Announce}}</div></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>File</th><th>Size (bytes)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Status.Files}}
|
||||
<tr><td>{{.Path}}</td><td>{{.Length}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="sub">No torrent loaded yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
func Start(controller *app.Controller) {
|
||||
fmt.Println("Ztorrent")
|
||||
ui := &webUI{controller: controller}
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: torrent-client <path-to-torrent-file>")
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", ui.handleIndex)
|
||||
mux.HandleFunc("/load", ui.handleLoad)
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to start GUI listener: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
path := os.Args[1]
|
||||
if err := controller.StartTorrent(path); err != nil {
|
||||
fmt.Printf("Warning: tracker announce failed: %v\n", err)
|
||||
}
|
||||
guiURL := "http://" + listener.Addr().String()
|
||||
fmt.Printf("Ztorrent GUI started at %s\n", guiURL)
|
||||
openBrowser(guiURL)
|
||||
|
||||
status := controller.Status()
|
||||
if !status.Loaded {
|
||||
fmt.Println("No torrent metadata loaded.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Name: %s\n", status.Name)
|
||||
fmt.Printf("Size: %d bytes\n", status.Length)
|
||||
fmt.Printf("Piece length: %d bytes\n", status.PieceLength)
|
||||
fmt.Printf("Pieces: %d\n", status.PieceCount)
|
||||
fmt.Printf("Files: %d\n", len(status.Files))
|
||||
for _, file := range status.Files {
|
||||
fmt.Printf(" - %s (%d bytes)\n", file.Path, file.Length)
|
||||
}
|
||||
fmt.Printf("Tracker: %s\n", status.Announce)
|
||||
fmt.Printf("Peers: %d\n", status.PeerCount)
|
||||
if status.LastError != "" {
|
||||
fmt.Printf("Last error: %s\n", status.LastError)
|
||||
if err := http.Serve(listener, mux); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
fmt.Printf("GUI server error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *webUI) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ui.renderPage(w, pageData{Status: ui.controller.Status()})
|
||||
}
|
||||
|
||||
func (ui *webUI) handleLoad(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
ui.renderPage(w, pageData{
|
||||
Status: ui.controller.Status(),
|
||||
Error: fmt.Sprintf("failed to parse form: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(r.FormValue("path"))
|
||||
var cleanupPath string
|
||||
|
||||
if path == "" {
|
||||
uploadedPath, err := saveUploadedTorrent(r)
|
||||
if err != nil {
|
||||
ui.renderPage(w, pageData{
|
||||
Status: ui.controller.Status(),
|
||||
Error: fmt.Sprintf("failed to read uploaded file: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
path = uploadedPath
|
||||
cleanupPath = uploadedPath
|
||||
}
|
||||
|
||||
if cleanupPath != "" {
|
||||
defer os.Remove(cleanupPath)
|
||||
}
|
||||
|
||||
loadErr := ui.controller.StartTorrent(path)
|
||||
status := ui.controller.Status()
|
||||
|
||||
data := pageData{
|
||||
Status: status,
|
||||
}
|
||||
if loadErr != nil {
|
||||
data.Error = loadErr.Error()
|
||||
if status.Loaded {
|
||||
data.Message = fmt.Sprintf("Metadata loaded from %s, but tracker announce failed", path)
|
||||
}
|
||||
} else {
|
||||
data.Message = fmt.Sprintf("Loaded torrent from %s", path)
|
||||
}
|
||||
ui.renderPage(w, data)
|
||||
}
|
||||
|
||||
func (ui *webUI) renderPage(w http.ResponseWriter, data pageData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := pageTemplate.Execute(w, data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func saveUploadedTorrent(r *http.Request) (string, error) {
|
||||
file, _, err := r.FormFile("torrent_file")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
tmp, err := os.CreateTemp("", "ztorrent-*.torrent")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tmp.Close()
|
||||
|
||||
if _, err := io.Copy(tmp, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tmp.Name(), nil
|
||||
}
|
||||
|
||||
func openBrowser(targetURL string) {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", targetURL)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", targetURL)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", targetURL)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Printf("Failed to open browser automatically: %v\nOpen manually: %s\n", err, targetURL)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue