Add a local web dashboard and "share web"

Daemon now also runs a plain HTTP server bound to 127.0.0.1 only (never
the network), serving an embedded vanilla HTML/CSS/JS page plus a small
JSON API (status/join/stop/leave/restart) that calls the same internal
methods the Unix-socket protocol already uses. No frontend framework or
build step — the page is embedded straight into the binary via
go:embed, keeping distribution a single file.

"share web" ensures the daemon is running, asks it (a new "web" control
action) for the dashboard's address, and opens it in the default browser
(open/xdg-open), falling back to printing the URL if that fails.

RoomStatus/FileStatus gained JSON tags for clean camelCase output, and
RoomStatus now carries RelayAddress so the dashboard can show it.
This commit is contained in:
2026-07-20 16:26:34 +08:00
parent 04613e6685
commit 0110f9da69
9 changed files with 373 additions and 14 deletions
BIN
View File
Binary file not shown.
+49
View File
@@ -4,6 +4,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
@@ -56,10 +57,14 @@ USAGE EXAMPLES:
Restart (or resume a paused) room on its own: Restart (or resume a paused) room on its own:
share restart secret-code share restart secret-code
Open the local web dashboard in your browser:
share web
COMMANDS: COMMANDS:
send seed file(s) into a room (see options with: share send -h) 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) relay start your own relay (optional; see options with: share relay -h)
status show the rooms the background daemon knows about 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 stop stop the daemon, or pause just one room if given a room code
leave forget one specific room for good leave forget one specific room for good
restart restart the daemon, or just one room if given a room code 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)) return runRestart(splice(args, i))
case "status": case "status":
return runStatus(splice(args, i)) return runStatus(splice(args, i))
case "web":
return runWeb(splice(args, i))
case "daemon": case "daemon":
return runDaemon(splice(args, i)) return runDaemon(splice(args, i))
} }
@@ -384,6 +391,48 @@ func runStatus(args []string) error {
return nil 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 { func runDaemon(args []string) error {
fs := flag.NewFlagSet("daemon", flag.ExitOnError) fs := flag.NewFlagSet("daemon", flag.ExitOnError)
fs.Usage = func() { fs.Usage = func() {
+13
View File
@@ -157,6 +157,19 @@ func Status() ([]swarm.RoomStatus, error) {
return resp.Rooms, nil 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) { func call(req request) (response, error) {
path, err := socketPath() path, err := socketPath()
if err != nil { if err != nil {
+5 -4
View File
@@ -5,7 +5,7 @@ import "github.com/tzgyn/share/src/swarm"
// request/response are the only vocabulary spoken over the control // request/response are the only vocabulary spoken over the control
// socket between a CLI invocation and the background daemon. // socket between a CLI invocation and the background daemon.
type request struct { 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"` Join *joinRequest `json:"join,omitempty"`
Code string `json:"code,omitempty"` // "stop-room"/"leave"/"restart-room" only Code string `json:"code,omitempty"` // "stop-room"/"leave"/"restart-room" only
} }
@@ -23,7 +23,8 @@ type joinRequest struct {
} }
type response struct { type response struct {
OK bool `json:"ok"` OK bool `json:"ok"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Rooms []swarm.RoomStatus `json:"rooms,omitempty"` // "status" only Rooms []swarm.RoomStatus `json:"rooms,omitempty"` // "status" only
WebAddr string `json:"webAddr,omitempty"` // "web" only
} }
+19
View File
@@ -28,6 +28,10 @@ type server struct {
// completion goroutine (started in join) knows what to do next. // completion goroutine (started in join) knows what to do next.
leaving map[string]bool leaving map[string]bool
restarting 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 // 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) 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 // Resuming persisted rooms happens in the background, not before the
// Accept loop below: joining a room can block for a while (e.g. its // Accept loop below: joining a room can block for a while (e.g. its
// relay is slow or unreachable), and this socket already looks // 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": case "status":
respond(conn, response{OK: true, Rooms: s.status()}) respond(conn, response{OK: true, Rooms: s.status()})
return false 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: default:
respond(conn, response{OK: false, Error: fmt.Sprintf("unknown action %q", req.Action)}) respond(conn, response{OK: false, Error: fmt.Sprintf("unknown action %q", req.Action)})
return false return false
+118
View File
@@ -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)
}
}
+100
View File
@@ -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 = '<p>No rooms.</p>';
return;
}
container.innerHTML = rooms.map(roomHTML).join('');
}
function roomHTML(r) {
const badge = r.stopped
? '<span class="badge stopped">stopped</span>'
: `<span class="badge running">running &middot; ${r.peers} peer${r.peers === 1 ? '' : 's'}</span>`;
const files = (r.files || []).map(f => `
<div class="file">
<span>${escapeHTML(f.name)} (${formatBytes(f.size)})</span>
<span>${f.haveChunks}/${f.totalChunks} chunks${f.complete ? ' &middot; complete' : ''}</span>
</div>
`).join('');
return `
<div class="room">
<div class="room-header">
<span class="code">${escapeHTML(r.code)}</span>
${badge}
</div>
<div class="relay">relay: ${escapeHTML(r.relayAddress)}</div>
${files}
<div class="actions">
${r.stopped
? `<button onclick="act('restart','${escapeAttr(r.code)}')">Restart</button>`
: `<button onclick="act('stop','${escapeAttr(r.code)}')">Stop</button>`}
<button onclick="act('leave','${escapeAttr(r.code)}')">Leave</button>
</div>
</div>
`;
}
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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);
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>share</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
h1 { font-size: 1.25rem; }
h3 { font-size: 1rem; margin-top: 0; }
.room { border: 1px solid #ddd; border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 0.75rem; }
.room-header { display: flex; justify-content: space-between; align-items: center; }
.code { font-weight: 600; font-family: ui-monospace, monospace; }
.badge { font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 999px; white-space: nowrap; }
.badge.running { background: #dcf5e0; color: #14631f; }
.badge.stopped { background: #f2f2f2; color: #666; }
.relay { font-size: 0.85rem; color: #777; margin-top: 0.3rem; }
.file { display: flex; justify-content: space-between; font-size: 0.9rem; color: #444; margin-top: 0.4rem; gap: 1rem; }
.file span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.actions { margin-top: 0.6rem; }
.actions button { margin-right: 0.4rem; }
form.join { border: 1px dashed #ccc; border-radius: 8px; padding: 1rem; margin-top: 1.5rem; }
input, textarea { width: 100%; box-sizing: border-box; padding: 0.4rem; margin: 0.2rem 0 0.6rem; font: inherit; }
label { font-size: 0.85rem; color: #555; }
.checkbox-row { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.8rem; }
.checkbox-row input { width: auto; margin: 0; }
.checkbox-row label { margin: 0; }
button { cursor: pointer; }
</style>
</head>
<body>
<h1>share</h1>
<div id="rooms"></div>
<form class="join" id="joinForm">
<h3>Join or seed a room</h3>
<label>Room code (leave empty to generate one when seeding)</label>
<input id="code" placeholder="e.g. 1234-apple-tree-monkey">
<label>Relay address</label>
<input id="relay" placeholder="127.0.0.1:9009">
<label>Files to seed — one absolute path per line (leave empty to just receive)</label>
<textarea id="files" rows="3" placeholder="/Users/me/file.txt"></textarea>
<label>Output folder (for receiving)</label>
<input id="out" placeholder=".">
<div class="checkbox-row">
<input type="checkbox" id="oneTime">
<label for="oneTime">One-time: exit once complete instead of reseeding</label>
</div>
<button type="submit">Join</button>
</form>
<script src="/app.js"></script>
</body>
</html>
+10 -10
View File
@@ -143,11 +143,11 @@ func (cl *Client) Wait() error {
// FileStatus summarizes one file's transfer progress within a room. // FileStatus summarizes one file's transfer progress within a room.
type FileStatus struct { type FileStatus struct {
Name string Name string `json:"name"`
Size int64 Size int64 `json:"size"`
HaveChunks int64 HaveChunks int64 `json:"haveChunks"`
TotalChunks int64 TotalChunks int64 `json:"totalChunks"`
Complete bool Complete bool `json:"complete"`
} }
// RoomStatus summarizes one room this client is participating in. A // 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 // 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. // the display looks the same whether the room is running or paused.
type RoomStatus struct { type RoomStatus struct {
Code string Code string `json:"code"`
RelayAddress string RelayAddress string `json:"relayAddress"`
Stopped bool Stopped bool `json:"stopped"`
Peers int Peers int `json:"peers"`
Files []FileStatus Files []FileStatus `json:"files"`
} }
// Status reports this room's current code, peer count, and per-file // Status reports this room's current code, peer count, and per-file