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

@@ -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)
}