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 => ` +