494 lines
13 KiB
Go
494 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
|
|
proto "git.jeffthecoder.xyz/public/chat-signaling-server/pkg/proto/signaling"
|
|
"github.com/charmbracelet/bubbles/textarea"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/spf13/cobra"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
|
|
"github.com/pion/webrtc/v3"
|
|
)
|
|
|
|
type errMsg error
|
|
|
|
type peerMsg struct {
|
|
Peer string
|
|
Message string
|
|
}
|
|
|
|
type systemMsg string
|
|
|
|
type SignalClientUI struct {
|
|
viewport viewport.Model
|
|
textarea textarea.Model
|
|
}
|
|
|
|
var (
|
|
StyleSender = lipgloss.NewStyle()
|
|
StyleSenderBold = StyleSender.Bold(true)
|
|
StyleError = lipgloss.NewStyle().Foreground(lipgloss.Color("red"))
|
|
StylePeer = lipgloss.NewStyle()
|
|
StylePeerSelected = lipgloss.NewStyle().Background(lipgloss.Color("gray"))
|
|
|
|
dataChannelName = "chat"
|
|
)
|
|
|
|
type SignalClient struct {
|
|
UI SignalClientUI
|
|
Program *tea.Program
|
|
|
|
webrtcConfig webrtc.Configuration
|
|
|
|
Server string
|
|
Credential credentials.TransportCredentials
|
|
|
|
Room string
|
|
Name string
|
|
|
|
Messages []string
|
|
Error error
|
|
|
|
Ready bool
|
|
|
|
MessageIsValid bool
|
|
Message string
|
|
Receiver string
|
|
|
|
PeerConns map[string]*webrtc.PeerConnection
|
|
Channels map[string]*webrtc.DataChannel
|
|
Lock sync.Locker
|
|
}
|
|
|
|
func New(server, room string, iceServers []string) *SignalClient {
|
|
ta := textarea.New()
|
|
ta.Prompt = "Send a message"
|
|
ta.Focus()
|
|
|
|
ta.Prompt = " ! "
|
|
ta.ShowLineNumbers = false
|
|
ta.SetHeight(1)
|
|
|
|
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
|
|
|
vp := viewport.New(30, 5)
|
|
vp.SetContent(`Welcome to the chat room!
|
|
Type a message and press Enter to send.`)
|
|
|
|
ta.KeyMap.InsertNewline.SetEnabled(false)
|
|
|
|
transportCredential := insecure.NewCredentials()
|
|
parsedUrl, err := url.Parse(server)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
port := parsedUrl.Port()
|
|
if parsedUrl.Scheme == "https" {
|
|
transportCredential = credentials.NewTLS(&tls.Config{})
|
|
}
|
|
if port == "" {
|
|
switch parsedUrl.Scheme {
|
|
case "https":
|
|
port = "443"
|
|
default:
|
|
port = "80"
|
|
}
|
|
}
|
|
|
|
client := &SignalClient{
|
|
UI: SignalClientUI{
|
|
viewport: vp,
|
|
textarea: ta,
|
|
},
|
|
|
|
webrtcConfig: webrtc.Configuration{
|
|
ICEServers: []webrtc.ICEServer{
|
|
{
|
|
URLs: iceServers,
|
|
},
|
|
},
|
|
},
|
|
|
|
Server: fmt.Sprintf("%v:%v", parsedUrl.Hostname(), port),
|
|
Credential: transportCredential,
|
|
|
|
Room: room,
|
|
|
|
PeerConns: make(map[string]*webrtc.PeerConnection),
|
|
Channels: make(map[string]*webrtc.DataChannel),
|
|
Lock: &sync.Mutex{},
|
|
}
|
|
|
|
return client
|
|
}
|
|
|
|
func (client *SignalClient) Init() tea.Cmd {
|
|
go client.ConnectServer(context.Background())
|
|
return textarea.Blink
|
|
}
|
|
|
|
func (client *SignalClient) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var (
|
|
tiCmd tea.Cmd
|
|
vpCmd tea.Cmd
|
|
)
|
|
|
|
client.UI.textarea, tiCmd = client.UI.textarea.Update(msg)
|
|
client.UI.viewport, vpCmd = client.UI.viewport.Update(msg)
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.Type {
|
|
case tea.KeyCtrlC, tea.KeyEsc, tea.KeyCtrlD:
|
|
fmt.Println()
|
|
return client, tea.Quit
|
|
case tea.KeyEnter:
|
|
if client.MessageIsValid {
|
|
client.Channels[client.Receiver].SendText(client.Message)
|
|
|
|
client.Messages = append(client.Messages, StyleSenderBold.Render("You -> "+client.Receiver+": ")+client.Message)
|
|
client.UI.viewport.SetContent(strings.Join(client.Messages, "\n"))
|
|
client.UI.textarea.Reset()
|
|
client.UI.viewport.GotoBottom()
|
|
}
|
|
}
|
|
case tea.WindowSizeMsg:
|
|
client.UI.textarea.SetWidth(msg.Width)
|
|
client.UI.viewport.Width = msg.Width
|
|
client.UI.viewport.Height = msg.Height - 1
|
|
|
|
// We handle errors just like any other message
|
|
case errMsg:
|
|
client.Error = msg
|
|
return client, nil
|
|
case peerMsg:
|
|
client.Messages = append(client.Messages, StyleSenderBold.Render(msg.Peer+" -> You: ")+msg.Message)
|
|
client.UI.viewport.SetContent(strings.Join(client.Messages, "\n"))
|
|
client.UI.viewport.GotoBottom()
|
|
case systemMsg:
|
|
client.Messages = append(client.Messages, string(msg))
|
|
client.UI.viewport.SetContent(strings.Join(client.Messages, "\n"))
|
|
client.UI.viewport.GotoBottom()
|
|
}
|
|
|
|
if selected, message, ok := strings.Cut(client.UI.textarea.Value(), ">"); ok {
|
|
selected = strings.TrimSpace(selected)
|
|
message = strings.TrimLeft(message, " \t")
|
|
if _, ok := client.PeerConns[selected]; ok {
|
|
client.MessageIsValid = true
|
|
client.Message = message
|
|
client.Receiver = selected
|
|
} else {
|
|
client.MessageIsValid = false
|
|
}
|
|
} else {
|
|
client.MessageIsValid = false
|
|
}
|
|
|
|
if client.MessageIsValid {
|
|
client.UI.textarea.Prompt = " | "
|
|
} else {
|
|
client.UI.textarea.Prompt = " ! "
|
|
}
|
|
|
|
return client, tea.Batch(tiCmd, vpCmd)
|
|
}
|
|
|
|
func (client *SignalClient) View() string {
|
|
return fmt.Sprint(
|
|
client.UI.viewport.View()+"\n",
|
|
StyleError.String()+client.UI.textarea.View()+"\n",
|
|
)
|
|
}
|
|
|
|
func (client *SignalClient) ConnectServer(ctx context.Context) {
|
|
client.Program.Send(systemMsg("Dialing to server..."))
|
|
grpcClient, err := grpc.Dial(client.Server, grpc.WithTransportCredentials(client.Credential))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
client.Program.Send(systemMsg("Connecting to room..."))
|
|
signal_server := proto.NewSignalingClient(grpcClient)
|
|
stream, err := signal_server.Biu(context.Background())
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
client.Program.Send(systemMsg("Connected."))
|
|
|
|
go client.HandleConnection(ctx, grpcClient, stream)
|
|
}
|
|
|
|
func (client *SignalClient) HandleConnection(ctx context.Context, grpcClient *grpc.ClientConn, stream proto.Signaling_BiuClient) {
|
|
defer grpcClient.Close()
|
|
room := client.Room
|
|
|
|
client.Program.Send(systemMsg("Waiting for server to be bootstrapped."))
|
|
|
|
stream.Send(&proto.SignalingMessage{
|
|
Room: room,
|
|
Message: &proto.SignalingMessage_Bootstrap{},
|
|
})
|
|
|
|
client.Program.Send(systemMsg("Bootstrapped."))
|
|
|
|
for {
|
|
msg, err := stream.Recv()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
switch inner := msg.Message.(type) {
|
|
case *proto.SignalingMessage_Bootstrap:
|
|
client.OnBootstrapReady(ctx, stream, room, msg.Sender)
|
|
case *proto.SignalingMessage_DiscoverRequest:
|
|
client.OnDiscoverRequest(ctx, stream, room, msg.Sender)
|
|
case *proto.SignalingMessage_DiscoverResponse:
|
|
client.OnDiscoverResponse(ctx, stream, room, msg.Sender)
|
|
case *proto.SignalingMessage_SessionOffer:
|
|
client.OnOffer(ctx, stream, room, msg.Sender, inner.SessionOffer.SDP)
|
|
case *proto.SignalingMessage_SessionAnswer:
|
|
client.OnAnswer(ctx, msg.Sender, inner.SessionAnswer.SDP)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (client *SignalClient) OnBootstrapReady(ctx context.Context, stream proto.Signaling_BiuClient, room, name string) {
|
|
client.Name = name
|
|
|
|
client.Program.Send(systemMsg("Server ready!"))
|
|
stream.Send(&proto.SignalingMessage{
|
|
Room: room,
|
|
Sender: client.Name,
|
|
Message: &proto.SignalingMessage_DiscoverRequest{},
|
|
})
|
|
}
|
|
|
|
func (client *SignalClient) OnDiscoverRequest(ctx context.Context, stream proto.Signaling_BiuClient, room, sender string) {
|
|
client.Program.Send(systemMsg("Client " + sender + " is joining into the room " + room))
|
|
stream.Send(&proto.SignalingMessage{
|
|
Room: room,
|
|
Sender: client.Name,
|
|
Receiver: &sender,
|
|
Message: &proto.SignalingMessage_DiscoverResponse{},
|
|
})
|
|
}
|
|
|
|
func (client *SignalClient) OnDiscoverResponse(ctx context.Context, stream proto.Signaling_BiuClient, room, sender string) {
|
|
client.Program.Send(systemMsg("Client " + sender + " ponged"))
|
|
|
|
peerConnection, err := client.GetOrCreatePeerConnection(sender)
|
|
if err != nil {
|
|
return
|
|
}
|
|
dataChannel, err := peerConnection.CreateDataChannel(dataChannelName, nil)
|
|
if err != nil {
|
|
client.Program.Send(systemMsg(fmt.Sprint("failed to create answer: ", err)))
|
|
return
|
|
}
|
|
client.Channels[sender] = dataChannel
|
|
client.SetupDataChannel(peerConnection, dataChannel, sender)
|
|
|
|
sdp, err := peerConnection.CreateOffer(&webrtc.OfferOptions{})
|
|
if err != nil {
|
|
client.Program.Send(systemMsg(fmt.Sprint("Failed to create offer for peer "+sender+": ", err)))
|
|
peerConnection.Close()
|
|
return
|
|
}
|
|
peerConnection.SetLocalDescription(sdp)
|
|
|
|
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
|
<-gatherComplete
|
|
|
|
buffer := &bytes.Buffer{}
|
|
if err := json.NewEncoder(buffer).Encode(peerConnection.LocalDescription()); err != nil {
|
|
client.Program.Send(systemMsg(fmt.Sprint("Failed to encode offer for peer "+sender+": ", err)))
|
|
peerConnection.Close()
|
|
return
|
|
}
|
|
|
|
client.PeerConns[sender] = peerConnection
|
|
|
|
stream.Send(&proto.SignalingMessage{
|
|
Room: room,
|
|
Sender: client.Name,
|
|
Receiver: &sender,
|
|
Message: &proto.SignalingMessage_SessionOffer{
|
|
SessionOffer: &proto.SDPMessage{
|
|
SDP: buffer.String(),
|
|
Type: proto.SDPMessageType_Data,
|
|
Sender: client.Name,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (client *SignalClient) OnOffer(ctx context.Context, stream proto.Signaling_BiuClient, room, sender, sdp string) {
|
|
client.Program.Send(systemMsg("Client " + sender + " is offering"))
|
|
|
|
peerConnection, err := client.GetOrCreatePeerConnection(sender)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var offer webrtc.SessionDescription
|
|
if err := json.NewDecoder(strings.NewReader(sdp)).Decode(&offer); err != nil {
|
|
client.Program.Send(systemMsg(fmt.Sprint("Failed to decode offer for peer"+sender+": ", err)))
|
|
return
|
|
}
|
|
peerConnection.SetRemoteDescription(offer)
|
|
|
|
answer, err := peerConnection.CreateAnswer(nil)
|
|
if err != nil {
|
|
client.Program.Send(systemMsg(fmt.Sprint("Failed to create answer for peer "+sender+": ", err)))
|
|
return
|
|
}
|
|
peerConnection.SetLocalDescription(answer)
|
|
|
|
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
|
<-gatherComplete
|
|
|
|
buffer := &bytes.Buffer{}
|
|
if err := json.NewEncoder(buffer).Encode(peerConnection.LocalDescription()); err != nil {
|
|
client.Program.Send(systemMsg(fmt.Sprint("Failed to encode answer for peer"+sender+": ", err)))
|
|
return
|
|
}
|
|
|
|
stream.Send(&proto.SignalingMessage{
|
|
Room: room,
|
|
Sender: client.Name,
|
|
Receiver: &sender,
|
|
Message: &proto.SignalingMessage_SessionAnswer{
|
|
SessionAnswer: &proto.SDPMessage{
|
|
SDP: buffer.String(),
|
|
Type: proto.SDPMessageType_Data,
|
|
Sender: client.Name,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (client *SignalClient) OnAnswer(ctx context.Context, sender, sdp string) {
|
|
client.Program.Send(systemMsg("Client " + sender + " has answered the offer"))
|
|
|
|
peerConnection, ok := client.PeerConns[sender]
|
|
if !ok {
|
|
return
|
|
}
|
|
var answer webrtc.SessionDescription
|
|
if err := json.NewDecoder(strings.NewReader(sdp)).Decode(&answer); err != nil {
|
|
client.Program.Send(systemMsg(fmt.Sprint("Failed to decode answer for peer"+sender+": ", err)))
|
|
return
|
|
}
|
|
peerConnection.SetRemoteDescription(answer)
|
|
|
|
}
|
|
|
|
func (client SignalClient) GetOrCreatePeerConnection(sender string) (*webrtc.PeerConnection, error) {
|
|
client.Lock.Lock()
|
|
defer client.Lock.Unlock()
|
|
|
|
peerConnection, ok := client.PeerConns[sender]
|
|
if ok {
|
|
return peerConnection, nil
|
|
}
|
|
|
|
peerConnection, err := webrtc.NewPeerConnection(client.webrtcConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client.PeerConns[sender] = peerConnection
|
|
|
|
closeOnceAndNoMore := &sync.Once{}
|
|
|
|
peerConnection.OnConnectionStateChange(func(pcs webrtc.PeerConnectionState) {
|
|
if pcs == webrtc.PeerConnectionStateDisconnected || pcs == webrtc.PeerConnectionStateClosed || pcs == webrtc.PeerConnectionStateFailed {
|
|
|
|
closeOnceAndNoMore.Do(func() {
|
|
if peerConnection != nil {
|
|
client.Program.Send(systemMsg(fmt.Sprint("Closing connection with ", sender, " for state changed to: ", pcs.String())))
|
|
peerConnection.Close()
|
|
delete(client.Channels, sender)
|
|
delete(client.PeerConns, sender)
|
|
|
|
peerConnection = nil
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
peerConnection.OnDataChannel(func(dc *webrtc.DataChannel) {
|
|
client.Program.Send(systemMsg(fmt.Sprint("Connected to client " + sender + ": " + dc.Label())))
|
|
if dc.Label() != dataChannelName {
|
|
client.Program.Send(fmt.Sprintf("ignored data channel: %v", dc.Label()))
|
|
}
|
|
client.Channels[sender] = dc
|
|
|
|
client.SetupDataChannel(peerConnection, dc, sender)
|
|
})
|
|
|
|
return peerConnection, nil
|
|
}
|
|
|
|
func (client *SignalClient) SetupDataChannel(pc *webrtc.PeerConnection, dc *webrtc.DataChannel, sender string) {
|
|
dc.OnOpen(func() {
|
|
client.Program.Send(systemMsg(fmt.Sprint("Channel opened: ", sender)))
|
|
})
|
|
|
|
dc.OnMessage(func(dcMsg webrtc.DataChannelMessage) {
|
|
if dcMsg.IsString {
|
|
client.Program.Send(peerMsg{
|
|
Peer: sender,
|
|
Message: string(dcMsg.Data),
|
|
})
|
|
}
|
|
})
|
|
|
|
dc.OnError(func(err error) {
|
|
dc.Close()
|
|
})
|
|
}
|
|
|
|
var (
|
|
serverUrl string
|
|
serverRoom string
|
|
iceServers []string
|
|
|
|
cmd = &cobra.Command{
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
|
|
signalClient := New(serverUrl, serverRoom, iceServers)
|
|
|
|
signalClient.Program = tea.NewProgram(signalClient)
|
|
|
|
if _, err := signalClient.Program.Run(); err != nil {
|
|
panic("err")
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
func main() {
|
|
cmd.Flags().StringVar(&serverUrl, "server", "https://chat.jeffthecoder.xyz", "")
|
|
cmd.Flags().StringVar(&serverRoom, "room", "public", "")
|
|
cmd.Flags().StringSliceVar(&iceServers, "ice-servers", []string{"stun:nhz.jeffthecoder.xyz:3478", "stun:nhz.jeffthecoder.xyz:3479"}, "")
|
|
|
|
cmd.Execute()
|
|
}
|