Add invite strings (waste: URI) for peer onboarding

- internal/invite: Encode/Decode waste:<base64json{anchor,network}>
- proto: CmdGenerateInvite + EvtInviteGenerated + InviteString field
- ipc: track active network name; handle generate_invite (returns invite
  string when joined to a network, errors if not joined or no anchor)
- daemon: --join <invite> flag — decodes anchor URL + network name,
  sets anchor and auto-joins on startup
- tui: --join <invite> flag — extracts network name, skips -network
  requirement; ctrl+i generates invite and shows it full-screen;
  Esc dismisses the invite overlay
- FUTURE.md: document multi-network derived-identity design

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-21 18:56:24 +02:00
parent 5a0bcee8c6
commit abc6bf46ce
7 changed files with 229 additions and 14 deletions

View File

@@ -16,6 +16,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/waste-go/internal/invite"
"github.com/waste-go/internal/mesh"
"github.com/waste-go/internal/proto"
)
@@ -24,8 +25,9 @@ import (
type JoinFunc func(ctx context.Context, networkName string)
// Run starts the IPC listener. Blocks until the listener fails.
// anchorURL is the configured anchor WebSocket URL (used for invite generation).
// Network join/leave state is daemon-scoped (shared across all IPC clients).
func Run(m *mesh.Mesh, port int, join JoinFunc) error {
func Run(m *mesh.Mesh, port int, anchorURL string, join JoinFunc) error {
addr := fmt.Sprintf("127.0.0.1:%d", port)
ln, err := net.Listen("tcp", addr)
if err != nil {
@@ -33,11 +35,12 @@ func Run(m *mesh.Mesh, port int, join JoinFunc) error {
}
log.Printf("ipc: listening on %s", addr)
// networkCancel is shared across all clients — any client can join/leave,
// and the join persists even after the commanding client disconnects.
// networkCancel / networkName are shared across all clients — any client
// can join/leave, and the join persists after the commanding client disconnects.
var (
networkMu sync.Mutex
networkCancel context.CancelFunc
networkName string
)
doJoin := func(name string) {
@@ -47,6 +50,7 @@ func Run(m *mesh.Mesh, port int, join JoinFunc) error {
}
ctx, cancel := context.WithCancel(context.Background())
networkCancel = cancel
networkName = name
networkMu.Unlock()
go join(ctx, name)
}
@@ -56,21 +60,28 @@ func Run(m *mesh.Mesh, port int, join JoinFunc) error {
if networkCancel != nil {
networkCancel()
networkCancel = nil
networkName = ""
}
networkMu.Unlock()
}
currentNetwork := func() string {
networkMu.Lock()
defer networkMu.Unlock()
return networkName
}
for {
conn, err := ln.Accept()
if err != nil {
return fmt.Errorf("ipc accept: %w", err)
}
log.Printf("ipc: UI client connected from %s", conn.RemoteAddr())
go handleClient(conn, m, doJoin, doLeave)
go handleClient(conn, m, anchorURL, currentNetwork, doJoin, doLeave)
}
}
func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func()) {
func handleClient(conn net.Conn, m *mesh.Mesh, anchorURL string, currentNetwork func() string, doJoin func(string), doLeave func()) {
defer conn.Close()
events := m.Subscribe()
@@ -187,6 +198,23 @@ func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func
Rooms: []string{"general"},
})
case proto.CmdGenerateInvite:
net := currentNetwork()
if net == "" {
send(errMsg("generate_invite: not currently joined to a network"))
continue
}
if anchorURL == "" {
send(errMsg("generate_invite: daemon was started without -anchor flag"))
continue
}
inv, err := invite.Encode(anchorURL, net)
if err != nil {
send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
continue
}
send(proto.IpcMessage{Type: proto.EvtInviteGenerated, InviteString: inv})
case proto.CmdSendFile:
send(errMsg("file transfer not yet implemented"))