Add file listing: share-dir flag, file_list_req/resp DataChannel messages
- proto: FileEntry, FileListResp types; MsgFileListReq/Resp msg types;
CmdGetFileList + EvtFileList IPC types; Files field on IpcMessage
- mesh: ShareDir field + ScanShareDir(); on DataChannel open, auto-send
MsgFileListReq to new peer; handle MsgFileListReq (scan + reply) and
MsgFileListResp (emit EvtFileList to IPC subscribers)
- ipc: get_file_list command — own list returned immediately; remote peer
list requested via DataChannel (response arrives as EvtFileList event)
- daemon: -share-dir flag wired to mesh.ShareDir
- test scripts: pass -share-dir /home/frejoh/Downloads/{alice,bob,charlie};
test-network.sh verifies each peer's own file list via get_file_list
- FUTURE.md: document per-network share directories and multi-network design
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(go build *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,15 @@ No DHT needed at small group scale (10–50 nodes). Keep it simple:
|
|||||||
- Public key = stable identity, not a mutable nickname
|
- 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
|
- No phone number, no central registry — closer to Signal's model than WASTE's original unregistered aliases
|
||||||
|
|
||||||
|
### Per-Network Share Directories
|
||||||
|
|
||||||
|
The current `-share-dir` flag is a single global directory. Eventually, each network should have its own independent share set:
|
||||||
|
- Alice shares `/home/alice/Downloads/work-files` on the "work" network
|
||||||
|
- Alice shares `/home/alice/Music` on the "friends" network
|
||||||
|
- Peers on "work" never see Alice's music, and vice versa
|
||||||
|
|
||||||
|
When multi-network support lands (see below), the `ShareDir` field on `networkCtx` replaces the current global `Mesh.ShareDir`. The IPC `get_file_list` and daemon `-share-dir` flag should move to be per-network configuration — either via a config file or via an IPC command `set_share_dir` scoped to a `network_id`.
|
||||||
|
|
||||||
### Multi-Network Support
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
dataDir := flag.String("data-dir", "~/.waste", "path to identity/config directory")
|
dataDir := flag.String("data-dir", "~/.waste", "path to identity/config directory")
|
||||||
alias := flag.String("alias", "anon", "display name shown to peers (advisory only)")
|
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)")
|
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")
|
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
|
||||||
|
shareDir := flag.String("share-dir", "", "directory to share with peers on the network")
|
||||||
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
|
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -52,6 +53,10 @@ func main() {
|
|||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
m := mesh.New(id, st)
|
m := mesh.New(id, st)
|
||||||
|
if *shareDir != "" {
|
||||||
|
m.ShareDir = expandHome(*shareDir)
|
||||||
|
log.Printf("daemon: sharing %s", m.ShareDir)
|
||||||
|
}
|
||||||
|
|
||||||
// joinFn is passed to the IPC layer; it's called when the UI sends join_network.
|
// joinFn is passed to the IPC layer; it's called when the UI sends join_network.
|
||||||
joinFn := func(ctx context.Context, networkName string) {
|
joinFn := func(ctx context.Context, networkName string) {
|
||||||
|
|||||||
@@ -198,6 +198,26 @@ func handleClient(conn net.Conn, m *mesh.Mesh, anchorURL string, currentNetwork
|
|||||||
Rooms: []string{"general"},
|
Rooms: []string{"general"},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
case proto.CmdGetFileList:
|
||||||
|
if cmd.PeerID == nil || *cmd.PeerID == m.Identity.PeerID() {
|
||||||
|
// Local list — scan immediately.
|
||||||
|
send(proto.IpcMessage{
|
||||||
|
Type: proto.EvtFileList,
|
||||||
|
PeerID: ptr(m.Identity.PeerID()),
|
||||||
|
Files: m.ScanShareDir(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Remote list — send a request over the DataChannel.
|
||||||
|
// The response arrives as EvtFileList via the mesh event bus.
|
||||||
|
req, err := json.Marshal(proto.PeerMessage{Type: proto.MsgFileListReq})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !m.SendTo(*cmd.PeerID, req) {
|
||||||
|
send(errMsg(fmt.Sprintf("get_file_list: peer %s not connected", (*cmd.PeerID).Short())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case proto.CmdGenerateInvite:
|
case proto.CmdGenerateInvite:
|
||||||
net := currentNetwork()
|
net := currentNetwork()
|
||||||
if net == "" {
|
if net == "" {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package mesh
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/waste-go/internal/crypto"
|
"github.com/waste-go/internal/crypto"
|
||||||
@@ -22,6 +23,7 @@ type PeerConn struct {
|
|||||||
type Mesh struct {
|
type Mesh struct {
|
||||||
Identity *crypto.Identity
|
Identity *crypto.Identity
|
||||||
Store *store.Store // may be nil if persistence is disabled
|
Store *store.Store // may be nil if persistence is disabled
|
||||||
|
ShareDir string // directory whose contents are shared with peers; "" = no sharing
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
peers map[proto.PeerID]*PeerConn
|
peers map[proto.PeerID]*PeerConn
|
||||||
@@ -41,6 +43,31 @@ func New(id *crypto.Identity, st *store.Store) *Mesh {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScanShareDir returns the list of files in the local share directory.
|
||||||
|
// Returns an empty slice if ShareDir is unset or the directory is empty.
|
||||||
|
func (m *Mesh) ScanShareDir() []proto.FileEntry {
|
||||||
|
if m.ShareDir == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(m.ShareDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mesh: scan share dir %s: %v", m.ShareDir, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var files []proto.FileEntry
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, proto.FileEntry{Name: e.Name(), SizeBytes: info.Size()})
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
// ── Peer management ───────────────────────────────────────────────────────────
|
// ── Peer management ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// AddPeer registers a connected peer and notifies subscribers.
|
// AddPeer registers a connected peer and notifies subscribers.
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ func WireDataChannel(
|
|||||||
}
|
}
|
||||||
m.AddPeer(peerConn)
|
m.AddPeer(peerConn)
|
||||||
|
|
||||||
|
// Request the peer's file list immediately after connect.
|
||||||
|
if req, err := json.Marshal(proto.PeerMessage{Type: proto.MsgFileListReq}); err == nil {
|
||||||
|
sendCh <- req
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for payload := range sendCh {
|
for payload := range sendCh {
|
||||||
if err := dc.SendText(string(payload)); err != nil {
|
if err := dc.SendText(string(payload)); err != nil {
|
||||||
@@ -147,6 +152,28 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
|
|||||||
|
|
||||||
func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
func dispatchPeerMessage(msg proto.PeerMessage, from proto.PeerID, m *Mesh) {
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
|
case proto.MsgFileListReq:
|
||||||
|
// Peer wants our file list — reply directly on their send channel.
|
||||||
|
files := m.ScanShareDir()
|
||||||
|
resp, err := json.Marshal(proto.PeerMessage{
|
||||||
|
Type: proto.MsgFileListResp,
|
||||||
|
FileListResp: &proto.FileListResp{Files: files},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.SendTo(from, resp)
|
||||||
|
|
||||||
|
case proto.MsgFileListResp:
|
||||||
|
// Received a remote peer's file list — forward to IPC subscribers.
|
||||||
|
if msg.FileListResp != nil {
|
||||||
|
m.Emit(proto.IpcMessage{
|
||||||
|
Type: proto.EvtFileList,
|
||||||
|
PeerID: peerIDPtr(from),
|
||||||
|
Files: msg.FileListResp.Files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
case proto.MsgChat:
|
case proto.MsgChat:
|
||||||
if msg.Chat != nil {
|
if msg.Chat != nil {
|
||||||
m.SaveMessage(msg.Chat)
|
m.SaveMessage(msg.Chat)
|
||||||
|
|||||||
@@ -34,13 +34,15 @@ type PeerInfo struct {
|
|||||||
type MsgType string
|
type MsgType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MsgChat MsgType = "chat"
|
MsgChat MsgType = "chat"
|
||||||
MsgPeerGossip MsgType = "peer_gossip"
|
MsgPeerGossip MsgType = "peer_gossip"
|
||||||
MsgFileOffer MsgType = "file_offer"
|
MsgFileListReq MsgType = "file_list_req"
|
||||||
MsgFileResp MsgType = "file_response"
|
MsgFileListResp MsgType = "file_list_resp"
|
||||||
MsgFileDone MsgType = "file_done"
|
MsgFileOffer MsgType = "file_offer"
|
||||||
MsgPing MsgType = "ping"
|
MsgFileResp MsgType = "file_response"
|
||||||
MsgPong MsgType = "pong"
|
MsgFileDone MsgType = "file_done"
|
||||||
|
MsgPing MsgType = "ping"
|
||||||
|
MsgPong MsgType = "pong"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PeerMessage is the top-level container sent over the "yaw" DataChannel.
|
// PeerMessage is the top-level container sent over the "yaw" DataChannel.
|
||||||
@@ -49,12 +51,13 @@ type PeerMessage struct {
|
|||||||
Type MsgType `json:"type"`
|
Type MsgType `json:"type"`
|
||||||
|
|
||||||
// Only one of these will be set, depending on Type.
|
// Only one of these will be set, depending on Type.
|
||||||
Chat *ChatMessage `json:"chat,omitempty"`
|
Chat *ChatMessage `json:"chat,omitempty"`
|
||||||
Gossip *PeerGossip `json:"gossip,omitempty"`
|
Gossip *PeerGossip `json:"gossip,omitempty"`
|
||||||
FileOffer *FileOffer `json:"file_offer,omitempty"`
|
FileListResp *FileListResp `json:"file_list_resp,omitempty"`
|
||||||
FileResp *FileResponse `json:"file_response,omitempty"`
|
FileOffer *FileOffer `json:"file_offer,omitempty"`
|
||||||
FileDone *FileDone `json:"file_done,omitempty"`
|
FileResp *FileResponse `json:"file_response,omitempty"`
|
||||||
Seq *uint64 `json:"seq,omitempty"` // for ping/pong
|
FileDone *FileDone `json:"file_done,omitempty"`
|
||||||
|
Seq *uint64 `json:"seq,omitempty"` // for ping/pong
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatMessage is a message to a room or a DM.
|
// ChatMessage is a message to a room or a DM.
|
||||||
@@ -80,6 +83,18 @@ type GossipEntry struct {
|
|||||||
LastSeen time.Time `json:"last_seen"`
|
LastSeen time.Time `json:"last_seen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileEntry describes a single file in a peer's shared directory.
|
||||||
|
type FileEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileListResp is the payload for MsgFileListResp.
|
||||||
|
// MsgFileListReq carries no payload — it is a zero-field request.
|
||||||
|
type FileListResp struct {
|
||||||
|
Files []FileEntry `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
// FileOffer initiates a file transfer.
|
// FileOffer initiates a file transfer.
|
||||||
type FileOffer struct {
|
type FileOffer struct {
|
||||||
Mid string `json:"mid"` // dedup id
|
Mid string `json:"mid"` // dedup id
|
||||||
@@ -187,6 +202,7 @@ const (
|
|||||||
CmdGetState IpcMsgType = "get_state"
|
CmdGetState IpcMsgType = "get_state"
|
||||||
CmdSendFile IpcMsgType = "send_file"
|
CmdSendFile IpcMsgType = "send_file"
|
||||||
CmdGenerateInvite IpcMsgType = "generate_invite"
|
CmdGenerateInvite IpcMsgType = "generate_invite"
|
||||||
|
CmdGetFileList IpcMsgType = "get_file_list"
|
||||||
|
|
||||||
// Events (daemon → UI)
|
// Events (daemon → UI)
|
||||||
EvtMessageReceived IpcMsgType = "message_received"
|
EvtMessageReceived IpcMsgType = "message_received"
|
||||||
@@ -198,6 +214,7 @@ const (
|
|||||||
EvtStateSnapshot IpcMsgType = "state_snapshot"
|
EvtStateSnapshot IpcMsgType = "state_snapshot"
|
||||||
EvtError IpcMsgType = "error"
|
EvtError IpcMsgType = "error"
|
||||||
EvtInviteGenerated IpcMsgType = "invite_generated"
|
EvtInviteGenerated IpcMsgType = "invite_generated"
|
||||||
|
EvtFileList IpcMsgType = "file_list"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IpcMessage covers both commands and events.
|
// IpcMessage covers both commands and events.
|
||||||
@@ -229,4 +246,5 @@ type IpcMessage struct {
|
|||||||
Rooms []string `json:"rooms,omitempty"`
|
Rooms []string `json:"rooms,omitempty"`
|
||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
InviteString string `json:"invite,omitempty"`
|
InviteString string `json:"invite,omitempty"`
|
||||||
|
Files []FileEntry `json:"files,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ log "$ALICE_COLOR" "alice" "starting daemon (ipc :${ALICE_IPC})"
|
|||||||
-data-dir "$DATA_ROOT/alice" \
|
-data-dir "$DATA_ROOT/alice" \
|
||||||
-ipc-port "$ALICE_IPC" \
|
-ipc-port "$ALICE_IPC" \
|
||||||
-anchor "$ANCHOR_URL" \
|
-anchor "$ANCHOR_URL" \
|
||||||
|
-share-dir "/home/frejoh/Downloads/alice" \
|
||||||
2> >(while IFS= read -r l; do echo -e "${ALICE_COLOR}${DIM}[alice] ${l}${RESET}"; done) &
|
2> >(while IFS= read -r l; do echo -e "${ALICE_COLOR}${DIM}[alice] ${l}${RESET}"; done) &
|
||||||
PIDS+=($!)
|
PIDS+=($!)
|
||||||
|
|
||||||
@@ -201,6 +202,7 @@ log "$BOB_COLOR" "bob" "starting daemon (ipc :${BOB_IPC})"
|
|||||||
-data-dir "$DATA_ROOT/bob" \
|
-data-dir "$DATA_ROOT/bob" \
|
||||||
-ipc-port "$BOB_IPC" \
|
-ipc-port "$BOB_IPC" \
|
||||||
-anchor "$ANCHOR_URL" \
|
-anchor "$ANCHOR_URL" \
|
||||||
|
-share-dir "/home/frejoh/Downloads/bob" \
|
||||||
2> >(while IFS= read -r l; do echo -e "${BOB_COLOR}${DIM}[bob] ${l}${RESET}"; done) &
|
2> >(while IFS= read -r l; do echo -e "${BOB_COLOR}${DIM}[bob] ${l}${RESET}"; done) &
|
||||||
PIDS+=($!)
|
PIDS+=($!)
|
||||||
|
|
||||||
@@ -210,6 +212,7 @@ log "$CHARLIE_COLOR" "charlie" "starting daemon (ipc :${CHARLIE_IPC})"
|
|||||||
-data-dir "$DATA_ROOT/charlie" \
|
-data-dir "$DATA_ROOT/charlie" \
|
||||||
-ipc-port "$CHARLIE_IPC" \
|
-ipc-port "$CHARLIE_IPC" \
|
||||||
-anchor "$ANCHOR_URL" \
|
-anchor "$ANCHOR_URL" \
|
||||||
|
-share-dir "/home/frejoh/Downloads/charlie" \
|
||||||
2> >(while IFS= read -r l; do echo -e "${CHARLIE_COLOR}${DIM}[charlie]${l}${RESET}"; done) &
|
2> >(while IFS= read -r l; do echo -e "${CHARLIE_COLOR}${DIM}[charlie]${l}${RESET}"; done) &
|
||||||
PIDS+=($!)
|
PIDS+=($!)
|
||||||
|
|
||||||
@@ -309,6 +312,27 @@ ipc "$ALICE_IPC" "$(jq -cn \
|
|||||||
'{"type":"send_message","room":$room,"body":$body,"to":$to}')"
|
'{"type":"send_message","room":$room,"body":$body,"to":$to}')"
|
||||||
sleep 0.4
|
sleep 0.4
|
||||||
|
|
||||||
|
# ── file list check ───────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||||
|
echo -e "file listing"
|
||||||
|
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||||
|
|
||||||
|
for peer_ipc in "$ALICE_IPC" "$BOB_IPC" "$CHARLIE_IPC"; do
|
||||||
|
peer_name=$([ "$peer_ipc" = "$ALICE_IPC" ] && echo "alice" || ([ "$peer_ipc" = "$BOB_IPC" ] && echo "bob" || echo "charlie"))
|
||||||
|
result=$(echo '{"type":"get_file_list"}' \
|
||||||
|
| timeout 2 nc 127.0.0.1 "$peer_ipc" 2>/dev/null \
|
||||||
|
| grep '"type":"file_list"' | head -1)
|
||||||
|
if [ -n "$result" ]; then
|
||||||
|
count=$(echo "$result" | jq '.files | length' 2>/dev/null || echo "?")
|
||||||
|
files=$(echo "$result" | jq -r '.files[].name' 2>/dev/null | tr '\n' ' ')
|
||||||
|
echo -e " ${BOLD}${peer_name}${RESET}: ${count} file(s) — ${files}"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}${peer_name}: no file_list response${RESET}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
# ── leave ─────────────────────────────────────────────────────────────────────
|
# ── leave ─────────────────────────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
echo -e "${DIM}────────────────────────────────────────────────────────${RESET}"
|
||||||
|
|||||||
@@ -77,15 +77,18 @@ ANCHOR_URL="ws://127.0.0.1:${ANCHOR_PORT}/ws"
|
|||||||
|
|
||||||
# Daemons
|
# Daemons
|
||||||
"$DATA_ROOT/bin/waste-daemon" -alias alice -data-dir "$DATA_ROOT/alice" \
|
"$DATA_ROOT/bin/waste-daemon" -alias alice -data-dir "$DATA_ROOT/alice" \
|
||||||
-ipc-port "$ALICE_IPC" -anchor "$ANCHOR_URL" 2>/dev/null &
|
-ipc-port "$ALICE_IPC" -anchor "$ANCHOR_URL" \
|
||||||
|
-share-dir "/home/frejoh/Downloads/alice" 2>/dev/null &
|
||||||
PIDS+=($!)
|
PIDS+=($!)
|
||||||
|
|
||||||
"$DATA_ROOT/bin/waste-daemon" -alias bob -data-dir "$DATA_ROOT/bob" \
|
"$DATA_ROOT/bin/waste-daemon" -alias bob -data-dir "$DATA_ROOT/bob" \
|
||||||
-ipc-port "$BOB_IPC" -anchor "$ANCHOR_URL" 2>/dev/null &
|
-ipc-port "$BOB_IPC" -anchor "$ANCHOR_URL" \
|
||||||
|
-share-dir "/home/frejoh/Downloads/bob" 2>/dev/null &
|
||||||
PIDS+=($!)
|
PIDS+=($!)
|
||||||
|
|
||||||
"$DATA_ROOT/bin/waste-daemon" -alias charlie -data-dir "$DATA_ROOT/charlie" \
|
"$DATA_ROOT/bin/waste-daemon" -alias charlie -data-dir "$DATA_ROOT/charlie" \
|
||||||
-ipc-port "$CHARLIE_IPC" -anchor "$ANCHOR_URL" 2>/dev/null &
|
-ipc-port "$CHARLIE_IPC" -anchor "$ANCHOR_URL" \
|
||||||
|
-share-dir "/home/frejoh/Downloads/charlie" 2>/dev/null &
|
||||||
PIDS+=($!)
|
PIDS+=($!)
|
||||||
|
|
||||||
wait_port "$ALICE_IPC"
|
wait_port "$ALICE_IPC"
|
||||||
|
|||||||
Reference in New Issue
Block a user