Compare commits
2 Commits
5a0bcee8c6
...
1a5f416ee4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a5f416ee4 | ||
|
|
abc6bf46ce |
27
FUTURE.md
27
FUTURE.md
@@ -59,6 +59,33 @@ No DHT needed at small group scale (10–50 nodes). Keep it simple:
|
||||
- Public key = stable identity, not a mutable nickname
|
||||
- No phone number, no central registry — closer to Signal's model than WASTE's original unregistered aliases
|
||||
|
||||
### Multi-Network Support
|
||||
|
||||
A single client should be able to participate in multiple networks simultaneously (e.g. "work" and "friends") without leaking that both identities belong to the same person.
|
||||
|
||||
**Privacy constraint:** if the same Ed25519 keypair is used across networks, any peer who is a member of both networks can trivially correlate you. The anchor also sees the same public key across networks.
|
||||
|
||||
**Solution — per-network derived identities:**
|
||||
- One master Ed25519 seed in `identity.json`
|
||||
- Per-network keypair = `HKDF(masterSeed, "yaw2-net", networkHash)`
|
||||
- Same master + same network name = same derived keypair (stable identity within a network)
|
||||
- Different networks = different peer IDs; correlation is impossible without knowing both network names
|
||||
- The anchor sees only the derived public key
|
||||
|
||||
**Daemon changes:**
|
||||
- Replace the single `networkCancel` with a `map[networkID]*networkCtx`
|
||||
- Each context holds its own: derived identity, mesh, anchor connection, store (`messages-<netHash>.db`)
|
||||
- `join_network` returns a `network_id` token used to scope subsequent commands
|
||||
|
||||
**IPC changes (breaking):**
|
||||
- All commands and events gain a `network_id` field
|
||||
- `get_state` returns an array of all joined networks
|
||||
- `join_network` responds with `network_joined` carrying the derived peer ID for that network
|
||||
|
||||
**TUI changes:**
|
||||
- Top-level network switcher (e.g. `[work] [friends]`)
|
||||
- Rooms and peers are scoped per network underneath
|
||||
|
||||
### Transport (Long-term)
|
||||
Current transport is TCP with custom framing. QUIC is worth revisiting once the core is solid — it gives multiplexing and better NAT traversal behavior essentially for free.
|
||||
|
||||
|
||||
52
README.md
52
README.md
@@ -41,7 +41,7 @@ go run ./cmd/anchor -bind 127.0.0.1:17339
|
||||
# Terminal 2 — peer A
|
||||
go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -ipc-port 17337 -anchor ws://127.0.0.1:17339/ws
|
||||
|
||||
# Terminal 3 — peer B
|
||||
# Terminal 3 — peer B (or use --join with an invite from peer A)
|
||||
go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -ipc-port 17341 -anchor ws://127.0.0.1:17339/ws
|
||||
```
|
||||
|
||||
@@ -98,16 +98,29 @@ Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`.
|
||||
{"type":"join_network","network_name":"friends"}
|
||||
{"type":"leave_network"}
|
||||
{"type":"send_message","room":"general","body":"hi"}
|
||||
{"type":"send_message","room":"dm:<peer-hex>","body":"hey","to":"<peer-hex>"}
|
||||
{"type":"get_state"}
|
||||
{"type":"generate_invite"}
|
||||
```
|
||||
|
||||
**Events the daemon pushes:**
|
||||
```jsonc
|
||||
{"type":"state_snapshot","local_peer":{...},"connected_peers":[...]}
|
||||
{"type":"peer_connected","peer":{...}}
|
||||
{"type":"session_ready","peer_id":"<hex>","nick":"alice"}
|
||||
{"type":"message_received","message":{"from":"<hex>","body":"hi","room":"general"}}
|
||||
{"type":"peer_disconnected","peer_id":"<hex>"}
|
||||
// Sent immediately on connect and in response to get_state
|
||||
{"type":"state_snapshot","local_peer":{"id":"<64-hex>","alias":"alice","public_key":"<64-hex>","created_at":"..."},"connected_peers":[...],"rooms":["general"]}
|
||||
|
||||
// Peer lifecycle
|
||||
{"type":"peer_connected","peer":{"id":"<64-hex>","alias":"bob",...}}
|
||||
{"type":"session_ready","peer_id":"<64-hex>","nick":"bob"}
|
||||
{"type":"peer_disconnected","peer_id":"<64-hex>"}
|
||||
|
||||
// Incoming message — mid is a 32-hex dedup token, to is set for DMs
|
||||
{"type":"message_received","message":{"mid":"<32-hex>","from":"<64-hex>","to":"<64-hex>","room":"general","body":"hi","sent_at":"..."}}
|
||||
|
||||
// Invite generation response
|
||||
{"type":"invite_generated","invite":"waste:<base64>"}
|
||||
|
||||
// Error
|
||||
{"type":"error","error_message":"..."}
|
||||
```
|
||||
|
||||
## Crypto choices
|
||||
@@ -125,6 +138,28 @@ Replaces WASTE's original Blowfish/PCBC (broken cipher mode) + RSA.
|
||||
> Peer IDs are 64-char lowercase hex (Ed25519 public key). Existing `identity.json` files
|
||||
> on disk are unaffected — only the over-the-wire representation changed from base64url.
|
||||
|
||||
## Onboarding a new peer
|
||||
|
||||
Alice is already on the network and wants to add Bob.
|
||||
|
||||
**Alice generates an invite** (from the TUI with `Ctrl+I`, or via IPC directly):
|
||||
```bash
|
||||
echo '{"type":"generate_invite"}' | nc 127.0.0.1 17337
|
||||
# → {"type":"invite_generated","invite":"waste:eyJhbmNob3IiOiJ3czovL..."}
|
||||
```
|
||||
|
||||
**Bob starts his daemon using the invite** — the `--join` flag sets the anchor URL and auto-joins the network:
|
||||
```bash
|
||||
go run ./cmd/daemon -alias bob -data-dir ~/.waste-bob --join 'waste:eyJhbmNob3IiOiJ3czovL...'
|
||||
```
|
||||
|
||||
**Bob opens the TUI** — `--join` also accepts the invite to skip the `-network` flag:
|
||||
```bash
|
||||
go run ./cmd/tui --join 'waste:eyJhbmNob3IiOiJ3czovL...'
|
||||
```
|
||||
|
||||
The invite encodes the anchor URL and network name as a `waste:` URI. Share it over Signal, email, or any side channel — the anchor never sees plaintext messages, so the invite leaking to a third party only lets them join the same network (which is by design: same network = mutual trust).
|
||||
|
||||
## Terminal UI
|
||||
|
||||
Start the daemon first (see Getting started above), then:
|
||||
@@ -137,7 +172,8 @@ Options:
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `-network` | *(required)* | Network name to join on startup |
|
||||
| `-network` | *(required unless -join)* | Network name to join on startup |
|
||||
| `-join` | — | `waste:` invite string — sets the network name automatically |
|
||||
| `-ipc` | `17337` | Daemon IPC port |
|
||||
|
||||
**Layout:**
|
||||
@@ -153,7 +189,7 @@ Options:
|
||||
╰─────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
**Key bindings:** `Tab` / `Shift+Tab` — switch rooms · `PgUp` / `PgDn` — scroll · `Enter` — send · `Ctrl+C` — quit
|
||||
**Key bindings:** `Tab` / `Shift+Tab` — switch rooms · `PgUp` / `PgDn` — scroll · `Enter` — send · `Ctrl+I` — generate invite · `Esc` — close invite overlay · `Ctrl+C` — quit
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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