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"
"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() {
+13
View File
@@ -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 {
+2 -1
View File
@@ -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
}
@@ -26,4 +26,5 @@ type response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
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.
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
+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.
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