diff --git a/share b/share index 31362d5..ff79dbc 100755 Binary files a/share and b/share differ diff --git a/src/cli/cli.go b/src/cli/cli.go index f602674..020104d 100644 --- a/src/cli/cli.go +++ b/src/cli/cli.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "os" + "os/exec" "path/filepath" "runtime" "strconv" @@ -56,10 +57,14 @@ USAGE EXAMPLES: Restart (or resume a paused) room on its own: share restart secret-code + Open the local web dashboard in your browser: + share web + COMMANDS: send seed file(s) into a room (see options with: share send -h) relay start your own relay (optional; see options with: share relay -h) status show the rooms the background daemon knows about + web open the local web dashboard in your browser stop stop the daemon, or pause just one room if given a room code leave forget one specific room for good restart restart the daemon, or just one room if given a room code @@ -105,6 +110,8 @@ func Run() error { return runRestart(splice(args, i)) case "status": return runStatus(splice(args, i)) + case "web": + return runWeb(splice(args, i)) case "daemon": return runDaemon(splice(args, i)) } @@ -384,6 +391,48 @@ func runStatus(args []string) error { return nil } +func runWeb(args []string) error { + fs := flag.NewFlagSet("web", flag.ExitOnError) + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "share web [options]") + fs.PrintDefaults() + } + debug := fs.Bool("debug", false, "toggle debug mode") + if err := fs.Parse(args); err != nil { + return err + } + + if err := daemon.EnsureRunning(*debug); err != nil { + return err + } + addr, err := daemon.WebAddr() + if err != nil { + return err + } + url := "http://" + addr + "/" + + if err := openBrowser(url); err != nil { + log.Debugf("could not open a browser automatically: %v", err) + fmt.Println(url) + return nil + } + fmt.Printf("Opened %s\n", url) + return nil +} + +// openBrowser launches the OS's default browser at url. There's no +// portable stdlib way to do this — each OS has its own launcher command. +func openBrowser(url string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url).Start() + case "linux": + return exec.Command("xdg-open", url).Start() + default: + return fmt.Errorf("don't know how to open a browser on %s", runtime.GOOS) + } +} + func runDaemon(args []string) error { fs := flag.NewFlagSet("daemon", flag.ExitOnError) fs.Usage = func() { diff --git a/src/daemon/client.go b/src/daemon/client.go index 23d34e2..1d5ff6b 100644 --- a/src/daemon/client.go +++ b/src/daemon/client.go @@ -157,6 +157,19 @@ func Status() ([]swarm.RoomStatus, error) { return resp.Rooms, nil } +// WebAddr returns the address (host:port) of the running daemon's local +// web dashboard. +func WebAddr() (string, error) { + if !IsRunning() { + return "", fmt.Errorf("daemon is not running") + } + resp, err := call(request{Action: "web"}) + if err != nil { + return "", err + } + return resp.WebAddr, nil +} + func call(req request) (response, error) { path, err := socketPath() if err != nil { diff --git a/src/daemon/protocol.go b/src/daemon/protocol.go index b5080cb..ece90fa 100644 --- a/src/daemon/protocol.go +++ b/src/daemon/protocol.go @@ -5,7 +5,7 @@ import "github.com/tzgyn/share/src/swarm" // request/response are the only vocabulary spoken over the control // socket between a CLI invocation and the background daemon. type request struct { - Action string `json:"action"` // "join", "stop-room", "leave", "restart-room", "stop", or "status" + Action string `json:"action"` // "join", "stop-room", "leave", "restart-room", "stop", "status", or "web" Join *joinRequest `json:"join,omitempty"` Code string `json:"code,omitempty"` // "stop-room"/"leave"/"restart-room" only } @@ -23,7 +23,8 @@ type joinRequest struct { } type response struct { - OK bool `json:"ok"` - Error string `json:"error,omitempty"` - Rooms []swarm.RoomStatus `json:"rooms,omitempty"` // "status" only + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Rooms []swarm.RoomStatus `json:"rooms,omitempty"` // "status" only + WebAddr string `json:"webAddr,omitempty"` // "web" only } diff --git a/src/daemon/server.go b/src/daemon/server.go index 1c26616..7679594 100644 --- a/src/daemon/server.go +++ b/src/daemon/server.go @@ -28,6 +28,10 @@ type server struct { // completion goroutine (started in join) knows what to do next. leaving map[string]bool restarting map[string]bool + + // webAddr is set once, before the Accept loop starts, and never + // written again — safe to read from any goroutine without a lock. + webAddr string } // Serve runs the daemon: it listens on the control socket and services @@ -61,6 +65,14 @@ func Serve() error { } log.Infof("daemon listening on %s", path) + webAddr, err := s.startWebServer() + if err != nil { + log.Warnf("could not start web dashboard: %v", err) + } else { + s.webAddr = webAddr + log.Infof("web dashboard on http://%s/", webAddr) + } + // Resuming persisted rooms happens in the background, not before the // Accept loop below: joining a room can block for a while (e.g. its // relay is slow or unreachable), and this socket already looks @@ -164,6 +176,13 @@ func (s *server) handleConn(conn net.Conn) (shutdown bool) { case "status": respond(conn, response{OK: true, Rooms: s.status()}) return false + case "web": + if s.webAddr == "" { + respond(conn, response{OK: false, Error: "web dashboard is not running"}) + return false + } + respond(conn, response{OK: true, WebAddr: s.webAddr}) + return false default: respond(conn, response{OK: false, Error: fmt.Sprintf("unknown action %q", req.Action)}) return false diff --git a/src/daemon/web.go b/src/daemon/web.go new file mode 100644 index 0000000..62d9604 --- /dev/null +++ b/src/daemon/web.go @@ -0,0 +1,118 @@ +package daemon + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "net" + "net/http" + "path/filepath" + + log "github.com/tzgyn/share/src/logger" + "github.com/tzgyn/share/src/utils" +) + +//go:embed webui/index.html webui/app.js +var webUI embed.FS + +// startWebServer starts the local dashboard: a plain HTTP server bound to +// 127.0.0.1 only (never the network — this controls file transfers, so it +// must never be reachable by anything but the same machine) on an +// OS-assigned free port. It talks to this same server's state directly, +// not through the Unix socket — it's just another transport for the same +// join/stop/leave/restart/status operations. +func (s *server) startWebServer() (string, error) { + assets, err := fs.Sub(webUI, "webui") + if err != nil { + return "", err + } + + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", fmt.Errorf("could not start web dashboard: %w", err) + } + addr := l.Addr().String() + + mux := http.NewServeMux() + mux.Handle("/", http.FileServer(http.FS(assets))) + mux.HandleFunc("/api/status", s.handleAPIStatus) + mux.HandleFunc("/api/join", s.handleAPIJoin) + mux.HandleFunc("/api/stop", s.handleAPIRoomAction(s.stopRoom)) + mux.HandleFunc("/api/leave", s.handleAPIRoomAction(s.leave)) + mux.HandleFunc("/api/restart", s.handleAPIRoomAction(s.restartRoom)) + + go func() { + if err := http.Serve(l, mux); err != nil { + log.Debugf("web dashboard stopped: %v", err) + } + }() + + return addr, nil +} + +func (s *server) handleAPIStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.status()) +} + +func (s *server) handleAPIJoin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + var req joinRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + for _, f := range req.Files { + if !filepath.IsAbs(f) { + http.Error(w, fmt.Sprintf("file path must be absolute: %s", f), http.StatusBadRequest) + return + } + } + if req.OutDir != "" && !filepath.IsAbs(req.OutDir) { + http.Error(w, fmt.Sprintf("output folder must be absolute: %s", req.OutDir), http.StatusBadRequest) + return + } + if req.Code == "" { + req.Code = utils.GetRandomName() + } + + if err := s.join(req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]string{"code": req.Code}) +} + +// handleAPIRoomAction adapts a single-room server method (which all share +// the same "code in, error out" shape) into an HTTP handler. +func (s *server) handleAPIRoomAction(action func(code string) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + var body struct { + Code string `json:"code"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := action(body.Code); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) + } +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Debugf("could not write JSON response: %v", err) + } +} diff --git a/src/daemon/webui/app.js b/src/daemon/webui/app.js new file mode 100644 index 0000000..749695a --- /dev/null +++ b/src/daemon/webui/app.js @@ -0,0 +1,100 @@ +async function refresh() { + const res = await fetch('/api/status'); + const rooms = await res.json(); + const container = document.getElementById('rooms'); + if (!rooms || rooms.length === 0) { + container.innerHTML = '

No rooms.

'; + return; + } + container.innerHTML = rooms.map(roomHTML).join(''); +} + +function roomHTML(r) { + const badge = r.stopped + ? 'stopped' + : `running · ${r.peers} peer${r.peers === 1 ? '' : 's'}`; + const files = (r.files || []).map(f => ` +
+ ${escapeHTML(f.name)} (${formatBytes(f.size)}) + ${f.haveChunks}/${f.totalChunks} chunks${f.complete ? ' · complete' : ''} +
+ `).join(''); + return ` +
+
+ ${escapeHTML(r.code)} + ${badge} +
+
relay: ${escapeHTML(r.relayAddress)}
+ ${files} +
+ ${r.stopped + ? `` + : ``} + +
+
+ `; +} + +async function act(action, code) { + const res = await fetch('/api/' + action, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + if (!res.ok) { + alert('Could not ' + action + ': ' + await res.text()); + } + refresh(); +} + +document.getElementById('joinForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const files = document.getElementById('files').value + .split('\n').map(s => s.trim()).filter(Boolean); + const body = { + code: document.getElementById('code').value.trim(), + relay: document.getElementById('relay').value.trim(), + files, + out: document.getElementById('out').value.trim(), + oneTime: document.getElementById('oneTime').checked, + }; + const res = await fetch('/api/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + alert('Could not join: ' + await res.text()); + return; + } + const result = await res.json(); + if (result.code) { + alert('Joined room: ' + result.code); + } + document.getElementById('joinForm').reset(); + refresh(); +}); + +function escapeHTML(s) { + return (s || '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +function escapeAttr(s) { + return (s || '').replace(/'/g, "\\'"); +} + +function formatBytes(n) { + if (n < 1024) return n + ' B'; + const units = ['KB', 'MB', 'GB', 'TB']; + let i = -1; + do { + n /= 1024; + i++; + } while (n >= 1024 && i < units.length - 1); + return n.toFixed(1) + ' ' + units[i]; +} + +refresh(); +setInterval(refresh, 2000); diff --git a/src/daemon/webui/index.html b/src/daemon/webui/index.html new file mode 100644 index 0000000..acd2611 --- /dev/null +++ b/src/daemon/webui/index.html @@ -0,0 +1,59 @@ + + + + + +share + + + +

share

+
+ +
+

Join or seed a room

+ + + + + + + + + + + + +
+ + +
+ + +
+ + + + diff --git a/src/swarm/client.go b/src/swarm/client.go index 12a66cb..e365f6d 100644 --- a/src/swarm/client.go +++ b/src/swarm/client.go @@ -143,11 +143,11 @@ func (cl *Client) Wait() error { // FileStatus summarizes one file's transfer progress within a room. type FileStatus struct { - Name string - Size int64 - HaveChunks int64 - TotalChunks int64 - Complete bool + Name string `json:"name"` + Size int64 `json:"size"` + HaveChunks int64 `json:"haveChunks"` + TotalChunks int64 `json:"totalChunks"` + Complete bool `json:"complete"` } // RoomStatus summarizes one room this client is participating in. A @@ -155,11 +155,11 @@ type FileStatus struct { // but RelayAddress/Files are still filled in from what's on record so // the display looks the same whether the room is running or paused. type RoomStatus struct { - Code string - RelayAddress string - Stopped bool - Peers int - Files []FileStatus + Code string `json:"code"` + RelayAddress string `json:"relayAddress"` + Stopped bool `json:"stopped"` + Peers int `json:"peers"` + Files []FileStatus `json:"files"` } // Status reports this room's current code, peer count, and per-file