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:
54
internal/invite/invite.go
Normal file
54
internal/invite/invite.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Package invite encodes and decodes waste invite strings.
|
||||
// An invite carries the anchor URL and network name needed to join a network.
|
||||
// Format: "waste:<url-safe-base64(json)>"
|
||||
package invite
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const prefix = "waste:"
|
||||
|
||||
// Invite holds the information needed to join a network.
|
||||
type Invite struct {
|
||||
Anchor string `json:"anchor"` // WebSocket anchor URL
|
||||
Network string `json:"network"` // plaintext network name
|
||||
}
|
||||
|
||||
// Encode returns a waste: invite string for the given anchor URL and network name.
|
||||
func Encode(anchor, network string) (string, error) {
|
||||
if anchor == "" {
|
||||
return "", fmt.Errorf("anchor URL is required")
|
||||
}
|
||||
if network == "" {
|
||||
return "", fmt.Errorf("network name is required")
|
||||
}
|
||||
b, err := json.Marshal(Invite{Anchor: anchor, Network: network})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix + base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// Decode parses a waste: invite string and returns the Invite.
|
||||
func Decode(s string) (Invite, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, prefix) {
|
||||
return Invite{}, fmt.Errorf("not a waste invite (expected 'waste:' prefix)")
|
||||
}
|
||||
b, err := base64.URLEncoding.DecodeString(strings.TrimPrefix(s, prefix))
|
||||
if err != nil {
|
||||
return Invite{}, fmt.Errorf("invalid invite: %w", err)
|
||||
}
|
||||
var inv Invite
|
||||
if err := json.Unmarshal(b, &inv); err != nil {
|
||||
return Invite{}, fmt.Errorf("invalid invite payload: %w", err)
|
||||
}
|
||||
if inv.Anchor == "" || inv.Network == "" {
|
||||
return Invite{}, fmt.Errorf("invite is missing anchor or network name")
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
38
internal/invite/invite_test.go
Normal file
38
internal/invite/invite_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package invite
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
s, err := Encode("ws://my-vps:17339/ws", "friends")
|
||||
if err != nil {
|
||||
t.Fatalf("Encode: %v", err)
|
||||
}
|
||||
if len(s) < 7 || s[:6] != "waste:" {
|
||||
t.Fatalf("expected waste: prefix, got %q", s)
|
||||
}
|
||||
|
||||
inv, err := Decode(s)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if inv.Anchor != "ws://my-vps:17339/ws" {
|
||||
t.Fatalf("anchor = %q", inv.Anchor)
|
||||
}
|
||||
if inv.Network != "friends" {
|
||||
t.Fatalf("network = %q", inv.Network)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeInvalid(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"notaninvite",
|
||||
"waste:!!!",
|
||||
"waste:" + "eyJhbmNob3IiOiIifQ==", // missing network
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, err := Decode(c); err == nil {
|
||||
t.Errorf("Decode(%q) expected error, got nil", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -185,7 +185,8 @@ const (
|
||||
CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext)
|
||||
CmdLeaveNetwork IpcMsgType = "leave_network"
|
||||
CmdGetState IpcMsgType = "get_state"
|
||||
CmdSendFile IpcMsgType = "send_file"
|
||||
CmdSendFile IpcMsgType = "send_file"
|
||||
CmdGenerateInvite IpcMsgType = "generate_invite"
|
||||
|
||||
// Events (daemon → UI)
|
||||
EvtMessageReceived IpcMsgType = "message_received"
|
||||
@@ -196,6 +197,7 @@ const (
|
||||
EvtFileProgress IpcMsgType = "file_progress"
|
||||
EvtStateSnapshot IpcMsgType = "state_snapshot"
|
||||
EvtError IpcMsgType = "error"
|
||||
EvtInviteGenerated IpcMsgType = "invite_generated"
|
||||
)
|
||||
|
||||
// IpcMessage covers both commands and events.
|
||||
@@ -226,4 +228,5 @@ type IpcMessage struct {
|
||||
ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"`
|
||||
Rooms []string `json:"rooms,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
InviteString string `json:"invite,omitempty"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user