Ztorrent/ui/window.go
2026-03-06 12:14:23 +03:00

291 lines
7.9 KiB
Go

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) {
ui := &webUI{controller: controller}
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()
guiURL := "http://" + listener.Addr().String()
fmt.Printf("Ztorrent GUI started at %s\n", guiURL)
openBrowser(guiURL)
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)
}
}