From add7c5fea8052d975d1aa7b298a9434782cfea16 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Mon, 22 Jun 2026 23:29:45 +0200 Subject: [PATCH] Add yaw2 invite interoperability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol: - invite.go: include full 64-char `net` hash in waste: invite blob (matches yaw2's `net` field — any client parsing the base64 JSON can join without knowing the plaintext name). Expose NetHash() helper. - netmgr: add JoinByHash() — join via full 64-char hex hash alone, storing the short ID as display name. Enables joining yaw2 networks from a URL that only carries the hash. - anchor: expose RunByHash() so netmgr can pass a pre-computed hash directly without a name→hash roundtrip. - ipc/proto: add network_hash field to join_network — routes to JoinByHash when present and network_name is absent. Web UI: - Parse ?net=<64hex> (yaw2 URL param) and ?a= in addition to existing ?n= / ?invite= params. Hash-only joins send network_hash. - Sidebar shows yaw: contact card (yaw:?n=) using the master identity — compatible with yaw2 contact card format. Click to copy to clipboard. Co-Authored-By: Claude Sonnet 4.6 --- internal/anchor/client.go | 7 ++- internal/invite/invite.go | 28 ++++++++++-- internal/ipc/ipc.go | 17 +++++-- internal/netmgr/manager.go | 82 ++++++++++++++++++++++++++++++++++ internal/proto/proto.go | 3 +- web/src/components/Sidebar.tsx | 23 ++++++++-- web/src/pages/Onboarding.tsx | 65 +++++++++++++++++++-------- web/src/types.ts | 1 + 8 files changed, 193 insertions(+), 33 deletions(-) diff --git a/internal/anchor/client.go b/internal/anchor/client.go index 20b309f..971026a 100644 --- a/internal/anchor/client.go +++ b/internal/anchor/client.go @@ -60,7 +60,12 @@ func (s *peerSession) close() { // Run connects to anchorURL, joins networkName, and blocks handling signaling. // Reconnects automatically on disconnect. Cancel ctx to stop. func Run(ctx context.Context, anchorURL, networkName string, id *crypto.Identity, m *mesh.Mesh) { - netHash := hashNetName(networkName) + RunByHash(ctx, anchorURL, hashNetName(networkName), id, m) +} + +// RunByHash is like Run but accepts the pre-computed full 64-char hex network hash +// directly. Use this when joining by hash rather than by name. +func RunByHash(ctx context.Context, anchorURL, netHash string, id *crypto.Identity, m *mesh.Mesh) { for { if err := runOnce(ctx, anchorURL, netHash, id, m); err != nil { if ctx.Err() != nil { diff --git a/internal/invite/invite.go b/internal/invite/invite.go index aac27b5..ce7fd84 100644 --- a/internal/invite/invite.go +++ b/internal/invite/invite.go @@ -1,10 +1,17 @@ // Package invite encodes and decodes waste invite strings. -// An invite carries the anchor URL and network name needed to join a network. +// // Format: "waste:" +// +// The JSON payload is compatible with yaw2: the `net` field carries the full +// 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly +// to the signaling server. A yaw2 client that can parse the base64 JSON can join +// the same network without knowing the plaintext name. package invite import ( + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "strings" @@ -14,8 +21,9 @@ 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 + Anchor string `json:"anchor"` // WebSocket anchor URL + Network string `json:"network"` // plaintext network name + Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field } // Encode returns a waste: invite string for the given anchor URL and network name. @@ -26,7 +34,12 @@ func Encode(anchor, network string) (string, error) { if network == "" { return "", fmt.Errorf("network name is required") } - b, err := json.Marshal(Invite{Anchor: anchor, Network: network}) + h := sha256.Sum256([]byte("yaw2-net:" + network)) + b, err := json.Marshal(Invite{ + Anchor: anchor, + Network: network, + Net: hex.EncodeToString(h[:]), + }) if err != nil { return "", err } @@ -52,3 +65,10 @@ func Decode(s string) (Invite, error) { } return inv, nil } + +// NetHash returns the full 64-char hex network hash for the given name +// (SHA-256("yaw2-net:" + name)). This is the `net` field sent to the anchor. +func NetHash(name string) string { + h := sha256.Sum256([]byte("yaw2-net:" + name)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index b0d5f00..71d1681 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -137,16 +137,25 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) { switch cmd.Type { case proto.CmdJoinNetwork: - if cmd.NetworkName == "" { - send(errMsg("join_network: network_name is required")) + var ( + netID string + err error + ) + switch { + case cmd.NetworkName != "": + netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir) + case len(cmd.NetworkHash) == 64: + // yaw2-compatible: join by full 64-char hex hash (net field) + netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir) + default: + send(errMsg("join_network: network_name or network_hash (64 hex chars) required")) continue } - netID, err := mgr.Join(cmd.NetworkName, cmd.ShareDir) if err != nil { send(errMsg(fmt.Sprintf("join_network: %v", err))) continue } - // network_joined event (with share_dir) is emitted by Manager.Join. + // network_joined event (with share_dir) is emitted by Manager.Join/JoinByHash. _ = netID case proto.CmdLeaveNetwork: diff --git a/internal/netmgr/manager.go b/internal/netmgr/manager.go index 2c5124e..e89e2ef 100644 --- a/internal/netmgr/manager.go +++ b/internal/netmgr/manager.go @@ -147,6 +147,88 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) { return netID, nil } +// JoinByHash joins a network using its pre-computed full 64-char hex hash +// (yaw2 `net` field) instead of the plaintext name. The network is stored +// with an empty name; the network_id (first 8 bytes) is used for display. +// This enables joining networks whose names are unknown — e.g. from a yaw2 +// invite URL that only contains the hash. +func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) { + if len(netHash64) != 64 { + return "", fmt.Errorf("netHash must be 64 hex chars, got %d", len(netHash64)) + } + netID := netHash64[:16] // first 8 bytes = 16 hex chars + + mgr.mu.Lock() + if _, exists := mgr.networks[netID]; exists { + mgr.mu.Unlock() + return netID, nil + } + mgr.mu.Unlock() + + derived, err := crypto.DeriveForNetwork(mgr.cfg.MasterIdentity, netHash64) + if err != nil { + return "", fmt.Errorf("derive identity for net %s: %w", netID, err) + } + + dbPath := filepath.Join(mgr.cfg.StoreDir, "messages-"+netID+".db") + st, err := store.Open(dbPath) + if err != nil { + return "", fmt.Errorf("open store for net %s: %w", netID, err) + } + + m := mesh.New(derived, st) + if shareDir != "" { + m.ShareDir = shareDir + } else if mgr.cfg.ShareDir != "" { + m.ShareDir = mgr.cfg.ShareDir + } + m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID) + + meshEvents := m.Subscribe() + go func() { + for evt := range meshEvents { + evt.NetworkID = netID + mgr.emit(evt) + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + + net := &Network{ + ID: netID, + Name: netID, // display as short hash when name is unknown + Hash: netHash64, + Identity: derived, + Mesh: m, + Store: st, + cancel: cancel, + } + + mgr.mu.Lock() + mgr.networks[netID] = net + mgr.order = append(mgr.order, netID) + mgr.mu.Unlock() + + log.Printf("netmgr: joining network by hash id=%s peer=%s", netID, derived.PeerID().Short()) + + if mgr.cfg.AnchorURL != "" { + go func() { + anchor.RunByHash(ctx, mgr.cfg.AnchorURL, netHash64, derived, m) + log.Printf("netmgr: left network %s", netID) + }() + } + + mgr.emit(proto.IpcMessage{ + Type: proto.EvtNetworkJoined, + NetworkID: netID, + NetworkName: netID, + LocalPeer: peerPtr(derived.PeerInfo()), + ShareDir: m.ShareDir, + }) + + return netID, nil +} + // Leave cancels a network context by ID. Closes its store. func (mgr *Manager) Leave(netID string) { mgr.mu.Lock() diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 4748de7..32b3d75 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -265,7 +265,8 @@ type IpcMessage struct { // join_network / leave_network NetworkName string `json:"network_name,omitempty"` - ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory + NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name + ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory // send_file / set_share_dir / file_complete path Path string `json:"path,omitempty"` diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 0eff184..92e9e56 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,7 +1,12 @@ import { useWaste } from '../store' +function makeYawCard(id: string, alias: string): string { + const nick = encodeURIComponent(alias.trim().slice(0, 40)) + return nick ? `yaw:${id}?n=${nick}` : `yaw:${id}` +} + export function Sidebar() { - const { localPeer, networks, activeNetworkId, activeRoom, setActiveRoom, setActiveNetwork, messages } = useWaste() + const { localPeer, masterId, masterAlias, networks, activeNetworkId, activeRoom, setActiveRoom, setActiveNetwork, messages } = useWaste() // Rooms = general + any DM threads that have messages const rooms = ['general'] @@ -9,11 +14,21 @@ export function Sidebar() { if (r.startsWith('dm:') && !rooms.includes(r)) rooms.push(r) }) + const displayAlias = localPeer?.alias ?? masterAlias ?? '' + const displayId = localPeer?.id ?? masterId ?? '' + const card = displayId ? makeYawCard(displayId, displayAlias) : null + return (