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

@@ -11,6 +11,7 @@ import (
"github.com/waste-go/internal/anchor"
"github.com/waste-go/internal/crypto"
"github.com/waste-go/internal/invite"
"github.com/waste-go/internal/ipc"
"github.com/waste-go/internal/mesh"
"github.com/waste-go/internal/proto"
@@ -22,8 +23,21 @@ func main() {
alias := flag.String("alias", "anon", "display name shown to peers (advisory only)")
ipcPort := flag.Int("ipc-port", 17337, "port for local IPC (UI connects here)")
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
flag.Parse()
// --join overrides/sets the anchor URL and triggers an auto-join.
var autoJoinNetwork string
if *joinInvite != "" {
inv, err := invite.Decode(*joinInvite)
if err != nil {
log.Fatalf("invalid invite: %v", err)
}
*anchorURL = inv.Anchor
autoJoinNetwork = inv.Network
log.Printf("daemon: invite decoded — anchor=%s network=%s", inv.Anchor, inv.Network)
}
dir := expandHome(*dataDir)
id, err := crypto.LoadOrCreate(dir, *alias)
if err != nil {
@@ -51,7 +65,14 @@ func main() {
log.Printf("daemon: left network %q", networkName)
}
if err := ipc.Run(m, *ipcPort, joinFn); err != nil {
// Auto-join from --join flag before starting IPC (non-blocking).
if autoJoinNetwork != "" {
ctx, cancel := context.WithCancel(context.Background())
_ = cancel // lifecycle managed by the joinFn / ipc.Run leave
go joinFn(ctx, autoJoinNetwork)
}
if err := ipc.Run(m, *ipcPort, *anchorURL, joinFn); err != nil {
log.Fatalf("ipc: %v", err)
}
}

View File

@@ -18,6 +18,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/waste-go/internal/invite"
"github.com/waste-go/internal/proto"
)
@@ -114,8 +115,9 @@ type model struct {
viewport viewport.Model
vpReady bool
status string
errMsg string
status string
errMsg string
invitePopup string // non-empty = show invite overlay
}
func newModel(ipcPort int, network string) model {
@@ -200,7 +202,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case msg.Type == tea.KeyCtrlC:
return m, tea.Quit
case msg.Type == tea.KeyEsc:
if m.invitePopup != "" {
m.invitePopup = ""
return m, nil
}
return m, tea.Quit
case msg.String() == "ctrl+i":
if m.enc != nil {
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGenerateInvite}))
}
case msg.Type == tea.KeyEnter:
m, cmds = m.doSend(cmds)
case msg.Type == tea.KeyTab:
@@ -272,6 +282,9 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
m.peerOrder = filterIDs(m.peerOrder, pid)
}
case proto.EvtInviteGenerated:
m.invitePopup = evt.InviteString
case proto.EvtMessageReceived:
if evt.Message != nil {
msg := evt.Message
@@ -417,11 +430,31 @@ func (m model) View() string {
if m.errMsg != "" {
statusLine = styleErr.Render(" ✗ " + m.errMsg)
} else {
hint := " tab: rooms · pgup/dn: scroll · ctrl+c: quit"
hint := " tab: rooms · ctrl+i: invite · ctrl+c: quit"
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
}
return lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine)
view := lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine)
// ── invite popup (full-screen overlay) ───────────────────────────────────
if m.invitePopup != "" {
label := styleActive.Render("Invite — share this with anyone you want to add:")
code := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("238")).
Padding(0, 1).
Width(m.width - 6).
Render(m.invitePopup)
hint := styleStatus.Render("Esc to close · ctrl+c to quit")
popup := lipgloss.NewStyle().
Width(m.width).
Height(m.height).
Align(lipgloss.Center, lipgloss.Center).
Render(lipgloss.JoinVertical(lipgloss.Left, label, "", code, "", hint))
return popup
}
return view
}
func (m model) renderRooms(boxH int) string {
@@ -565,12 +598,23 @@ func min(a, b int) int {
// ── main ──────────────────────────────────────────────────────────────────────
func main() {
ipcPort := flag.Int("ipc", 17337, "daemon IPC port")
network := flag.String("network", "", "network name to join on startup (required)")
ipcPort := flag.Int("ipc", 17337, "daemon IPC port")
network := flag.String("network", "", "network name to join on startup")
joinInvite := flag.String("join", "", "waste: invite string — auto-sets the network name")
flag.Parse()
// --join overrides --network.
if *joinInvite != "" {
inv, err := invite.Decode(*joinInvite)
if err != nil {
fmt.Fprintln(os.Stderr, "error: invalid invite:", err)
os.Exit(1)
}
*network = inv.Network
}
if *network == "" {
fmt.Fprintln(os.Stderr, "error: -network is required")
fmt.Fprintln(os.Stderr, "error: -network or -join is required")
flag.Usage()
os.Exit(1)
}