Initial WebRTC Code

* WebRTC pees exchange local/remote descriptions ("offer" and "answer")
* They don't seem to exchange ICE candidates yet
* Some back and forth happens but the final WebRTC stream connection
  isn't established yet.
This commit is contained in:
Noah 2023-01-26 22:54:02 -08:00
parent 4f93c27651
commit 5dbe938780
6 changed files with 333 additions and 47 deletions

View File

@ -11,7 +11,7 @@ import (
func (s *Server) OnLogin(sub *Subscriber, msg Message) { func (s *Server) OnLogin(sub *Subscriber, msg Message) {
// Ensure the username is unique, or rename it. // Ensure the username is unique, or rename it.
var duplicate bool var duplicate bool
for other := range s.IterSubscribers() { for _, other := range s.IterSubscribers() {
if other.ID != sub.ID && other.Username == msg.Username { if other.ID != sub.ID && other.Username == msg.Username {
duplicate = true duplicate = true
break break
@ -48,11 +48,7 @@ func (s *Server) OnLogin(sub *Subscriber, msg Message) {
func (s *Server) OnMessage(sub *Subscriber, msg Message) { func (s *Server) OnMessage(sub *Subscriber, msg Message) {
log.Info("[%s] %s", sub.Username, msg.Message) log.Info("[%s] %s", sub.Username, msg.Message)
if sub.Username == "" { if sub.Username == "" {
sub.SendJSON(Message{ sub.ChatServer("You must log in first.")
Action: ActionMessage,
Username: "ChatServer",
Message: "You must log in first.",
})
return return
} }
@ -75,3 +71,63 @@ func (s *Server) OnMe(sub *Subscriber, msg Message) {
// Sync the WhoList to everybody. // Sync the WhoList to everybody.
s.SendWhoList() s.SendWhoList()
} }
// OnOpen is a client wanting to start WebRTC with another, e.g. to see their camera.
func (s *Server) OnOpen(sub *Subscriber, msg Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
sub.ChatServer(err.Error())
return
}
// Make up a WebRTC shared secret and send it to both of them.
secret := RandomString(16)
log.Info("WebRTC: %s opens %s with secret %s", sub.Username, other.Username, secret)
// Ring the target of this request and give them the secret.
other.SendJSON(Message{
Action: ActionRing,
Username: sub.Username,
OpenSecret: secret,
})
// To the caller, echo back the Open along with the secret.
sub.SendJSON(Message{
Action: ActionOpen,
Username: other.Username,
OpenSecret: secret,
})
}
// OnCandidate handles WebRTC candidate signaling.
func (s *Server) OnCandidate(sub *Subscriber, msg Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
sub.ChatServer(err.Error())
return
}
other.SendJSON(Message{
Action: ActionCandidate,
Username: sub.Username,
Candidate: msg.Candidate,
})
}
// OnSDP handles WebRTC sdp signaling.
func (s *Server) OnSDP(sub *Subscriber, msg Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
sub.ChatServer(err.Error())
return
}
other.SendJSON(Message{
Action: ActionSDP,
Username: sub.Username,
Description: msg.Description,
})
}

View File

@ -2,14 +2,21 @@ package barertc
type Message struct { type Message struct {
Action string `json:"action,omitempty"` Action string `json:"action,omitempty"`
Username string `json:"username"` Username string `json:"username,omitempty"`
Message string `json:"message"` Message string `json:"message",omitempty`
// WhoList for `who` actions // WhoList for `who` actions
WhoList []WhoList `json:"whoList"` WhoList []WhoList `json:"whoList,omitempty"`
// Sent on `me` actions along with Username // Sent on `me` actions along with Username
VideoActive bool `json:"videoActive"` // user tells us their cam status VideoActive bool `json:"videoActive,omitempty"` // user tells us their cam status
// Sent on `open` actions along with the (other) Username.
OpenSecret string `json:"openSecret,omitempty"`
// Parameters sent on WebRTC signaling messages.
Candidate string `json:"candidate,omitempty"` // candidate
Description string `json:"description,omitempty"` // sdp
} }
const ( const (
@ -19,10 +26,17 @@ const (
// Actions sent by server or client // Actions sent by server or client
ActionMessage = "message" // post a message to the room ActionMessage = "message" // post a message to the room
ActionMe = "me" // user self-info sent by FE or BE ActionMe = "me" // user self-info sent by FE or BE
ActionOpen = "open" // user wants to view a webcam (open WebRTC)
ActionRing = "ring" // receiver of a WebRTC open request
// Actions sent by server only // Actions sent by server only
ActionWhoList = "who" // server pushes the Who List ActionWhoList = "who" // server pushes the Who List
ActionPresence = "presence" // a user joined or left the room ActionPresence = "presence" // a user joined or left the room
ActionError = "error" // ChatServer errors
// WebRTC signaling messages.
ActionCandidate = "candidate"
ActionSDP = "sdp"
) )
// WhoList is a member entry in the chat room. // WhoList is a member entry in the chat room.

View File

@ -16,12 +16,7 @@ func IndexPage() http.HandlerFunc {
tmpl.Funcs(template.FuncMap{ tmpl.Funcs(template.FuncMap{
// Cache busting random string for JS and CSS dependency. // Cache busting random string for JS and CSS dependency.
"CacheHash": func() string { "CacheHash": func() string {
const charset = "abcdefghijklmnopqrstuvwxyz" return RandomString(8)
var result = make([]byte, 8)
for i := 0; i < 8; i++ {
result[i] = charset[rand.Intn(len(charset))]
}
return string(result)
}, },
}) })
tmpl, err := tmpl.ParseFiles("web/templates/chat.html") tmpl, err := tmpl.ParseFiles("web/templates/chat.html")
@ -34,3 +29,13 @@ func IndexPage() http.HandlerFunc {
tmpl.ExecuteTemplate(w, "index", nil) tmpl.ExecuteTemplate(w, "index", nil)
}) })
} }
// RandomString returns a random string of any length.
func RandomString(n int) string {
const charset = "abcdefghijklmnopqrstuvwxyz"
var result = make([]byte, n)
for i := 0; i < n; i++ {
result[i] = charset[rand.Intn(len(charset))]
}
return string(result)
}

View File

@ -3,6 +3,7 @@ package barertc
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
@ -61,12 +62,14 @@ func (sub *Subscriber) ReadLoop(s *Server) {
s.OnMessage(sub, msg) s.OnMessage(sub, msg)
case ActionMe: case ActionMe:
s.OnMe(sub, msg) s.OnMe(sub, msg)
case ActionOpen:
s.OnOpen(sub, msg)
case ActionCandidate:
s.OnCandidate(sub, msg)
case ActionSDP:
s.OnSDP(sub, msg)
default: default:
sub.SendJSON(Message{ sub.ChatServer("Unsupported message type.")
Action: ActionMessage,
Username: "ChatServer",
Message: "Unsupported message type.",
})
} }
} }
}() }()
@ -91,6 +94,15 @@ func (sub *Subscriber) SendMe() {
}) })
} }
// ChatServer is a convenience function to deliver a ChatServer error to the client.
func (sub *Subscriber) ChatServer(message string, v ...interface{}) {
sub.SendJSON(Message{
Action: ActionError,
Username: "ChatServer",
Message: fmt.Sprintf(message, v...),
})
}
// WebSocket handles the /ws websocket connection. // WebSocket handles the /ws websocket connection.
func (s *Server) WebSocket() http.HandlerFunc { func (s *Server) WebSocket() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -152,6 +164,18 @@ func (s *Server) AddSubscriber(sub *Subscriber) {
s.subscribersMu.Unlock() s.subscribersMu.Unlock()
} }
// GetSubscriber by username.
func (s *Server) GetSubscriber(username string) (*Subscriber, error) {
s.subscribersMu.RLock()
defer s.subscribersMu.RUnlock()
for _, sub := range s.IterSubscribers(true) {
if sub.Username == username {
return sub, nil
}
}
return nil, errors.New("not found")
}
// DeleteSubscriber removes a subscriber from the server. // DeleteSubscriber removes a subscriber from the server.
func (s *Server) DeleteSubscriber(sub *Subscriber) { func (s *Server) DeleteSubscriber(sub *Subscriber) {
log.Error("DeleteSubscriber: %s", sub.Username) log.Error("DeleteSubscriber: %s", sub.Username)
@ -162,9 +186,7 @@ func (s *Server) DeleteSubscriber(sub *Subscriber) {
// IterSubscribers loops over the subscriber list with a read lock. If the // IterSubscribers loops over the subscriber list with a read lock. If the
// caller already holds a lock, pass the optional `true` parameter for isLocked. // caller already holds a lock, pass the optional `true` parameter for isLocked.
func (s *Server) IterSubscribers(isLocked ...bool) chan *Subscriber { func (s *Server) IterSubscribers(isLocked ...bool) []*Subscriber {
var out = make(chan *Subscriber)
go func() {
log.Debug("IterSubscribers START..") log.Debug("IterSubscribers START..")
var result = []*Subscriber{} var result = []*Subscriber{}
@ -180,13 +202,8 @@ func (s *Server) IterSubscribers(isLocked ...bool) chan *Subscriber {
result = append(result, sub) result = append(result, sub)
} }
for _, r := range result { log.Debug("IterSubscribers STOP..")
out <- r return result
}
close(out)
log.Debug("IterSubscribers STOP!")
}()
return out
} }
// Broadcast a message to the chat room. // Broadcast a message to the chat room.
@ -194,7 +211,7 @@ func (s *Server) Broadcast(msg Message) {
log.Debug("Broadcast: %+v", msg) log.Debug("Broadcast: %+v", msg)
s.subscribersMu.RLock() s.subscribersMu.RLock()
defer s.subscribersMu.RUnlock() defer s.subscribersMu.RUnlock()
for sub := range s.IterSubscribers(true) { for _, sub := range s.IterSubscribers(true) {
sub.SendJSON(Message{ sub.SendJSON(Message{
Action: msg.Action, Action: msg.Action,
Username: msg.Username, Username: msg.Username,
@ -205,15 +222,19 @@ func (s *Server) Broadcast(msg Message) {
// SendWhoList broadcasts the connected members to everybody in the room. // SendWhoList broadcasts the connected members to everybody in the room.
func (s *Server) SendWhoList() { func (s *Server) SendWhoList() {
var users = []WhoList{} var (
for sub := range s.IterSubscribers() { users = []WhoList{}
subscribers = s.IterSubscribers()
)
for _, sub := range subscribers {
users = append(users, WhoList{ users = append(users, WhoList{
Username: sub.Username, Username: sub.Username,
VideoActive: sub.VideoActive, VideoActive: sub.VideoActive,
}) })
} }
for sub := range s.IterSubscribers() { for _, sub := range subscribers {
sub.SendJSON(Message{ sub.SendJSON(Message{
Action: ActionWhoList, Action: ActionWhoList,
WhoList: users, WhoList: users,

View File

@ -1,5 +1,13 @@
console.log("BareRTC!"); console.log("BareRTC!");
// WebRTC configuration.
const configuration = {
iceServers: [{
urls: 'stun:stun.l.google.com:19302'
}]
};
const app = Vue.createApp({ const app = Vue.createApp({
delimiters: ['[[', ']]'], delimiters: ['[[', ']]'],
data() { data() {
@ -27,6 +35,15 @@ const app = Vue.createApp({
stream: null, // MediaStream object stream: null, // MediaStream object
}, },
// WebRTC sessions with other users.
WebRTC: {
// Streams per username.
streams: {},
// RTCPeerConnections per username.
pc: {},
},
// Chat history. // Chat history.
history: [], history: [],
historyScrollbox: null, historyScrollbox: null,
@ -55,6 +72,10 @@ const app = Vue.createApp({
this.dial(); this.dial();
}, },
/**
* Chat API Methods (WebSocket packets sent/received)
*/
sendMessage() { sendMessage() {
if (!this.message) { if (!this.message) {
return; return;
@ -87,11 +108,36 @@ const app = Vue.createApp({
// in our choice of username. // in our choice of username.
if (this.username != msg.username) { if (this.username != msg.username) {
this.ChatServer(`Your username has been changed to ${msg.username}.`); this.ChatServer(`Your username has been changed to ${msg.username}.`);
this.username = msg.username;
} }
this.ChatClient(`User sync from backend: ${JSON.stringify(msg)}`); this.ChatClient(`User sync from backend: ${JSON.stringify(msg)}`);
}, },
// Send a video request to access a user's camera.
sendOpen(username) {
this.ws.conn.send(JSON.stringify({
action: "open",
username: username,
}));
},
onOpen(msg) {
// Response for the opener to begin WebRTC connection.
const secret = msg.openSecret;
console.log("OPEN: connect to %s with secret %s", msg.username, secret);
this.ChatClient(`Connecting to stream for ${msg.username}.`);
this.startWebRTC(msg.username, true);
},
onRing(msg) {
// Message for the receiver to begin WebRTC connection.
const secret = msg.openSecret;
console.log("RING: connection from %s with secret %s", msg.username, secret);
this.ChatServer(`${msg.username} has opened your camera.`);
this.startWebRTC(msg.username, false);
},
// Handle messages sent in chat. // Handle messages sent in chat.
onMessage(msg) { onMessage(msg) {
this.pushHistory({ this.pushHistory({
@ -102,6 +148,7 @@ const app = Vue.createApp({
// Dial the WebSocket connection. // Dial the WebSocket connection.
dial() { dial() {
console.log("Dialing WebSocket...");
const conn = new WebSocket(`ws://${location.host}/ws`); const conn = new WebSocket(`ws://${location.host}/ws`);
conn.addEventListener("close", ev => { conn.addEventListener("close", ev => {
@ -152,6 +199,24 @@ const app = Vue.createApp({
message: msg.message, message: msg.message,
}); });
break; break;
case "ring":
this.onRing(msg);
break;
case "open":
this.onOpen(msg);
break;
case "candidate":
this.onCandidate(msg);
break;
case "sdp":
this.onSDP(msg);
break;
case "error":
this.pushHistory({
username: msg.username || 'Internal Server Error',
message: msg.message,
isChatServer: true,
});
default: default:
console.error("Unexpected action: %s", JSON.stringify(msg)); console.error("Unexpected action: %s", JSON.stringify(msg));
} }
@ -160,6 +225,120 @@ const app = Vue.createApp({
this.ws.conn = conn; this.ws.conn = conn;
}, },
/**
* WebRTC concerns.
*/
// Start WebRTC with the other username.
startWebRTC(username, isOfferer) {
this.ChatClient(`Begin WebRTC with ${username}.`);
let pc = new RTCPeerConnection(configuration);
this.WebRTC.pc[username] = pc;
// 'onicecandidate' notifies us whenever an ICE agent needs to deliver a
// message to the other peer through the signaling server.
pc.onicecandidate = event => {
this.ChatClient("On ICE Candidate called");
console.log(event);
console.log(event.candidate);
if (event.candidate) {
this.ChatClient(`Send ICE candidate: ${event.candidate}`);
this.ws.conn.send(JSON.stringify({
action: "candidate",
username: username,
candidate: event.candidate,
}));
}
};
// If the user is offerer let the 'negotiationneeded' event create the offer.
if (isOfferer) {
this.ChatClient("Sending offer:");
pc.onnegotiationneeded = () => {
this.ChatClient("Negotiation Needed, creating WebRTC offer.");
pc.createOffer().then(this.localDescCreated(pc, username)).catch(this.ChatClient);
};
}
// When a remote stream arrives.
pc.ontrack = event => {
const stream = event.streams[0];
// Do we already have it?
this.ChatClient(`Received a video stream from ${username}.`);
if (this.WebRTC.streams[username] == undefined ||
this.WebRTC.streams[username].id !== stream.id) {
this.WebRTC.streams[username] = stream;
}
};
// If we were already broadcasting video, send our stream to
// the connecting user.
if (!isOfferer && this.webcam.active) {
this.ChatClient(`Sharing our video stream to ${username}.`);
this.webcam.stream.getTracks().forEach(track => pc.addTrack(track, this.webcam.stream));
}
// If we are the offerer, begin the connection.
if (isOfferer) {
pc.createOffer().then(this.localDescCreated(pc, username)).catch(this.ChatClient);
}
},
// Common handler function for
localDescCreated(pc, username) {
return (desc) => {
this.ChatClient(`setLocalDescription ${JSON.stringify(desc)}`);
pc.setLocalDescription(
new RTCSessionDescription(desc),
() => {
this.ws.conn.send(JSON.stringify({
action: "sdp",
username: username,
description: JSON.stringify(pc.localDescription),
}));
},
console.error,
)
};
},
// Handle inbound WebRTC signaling messages proxied by the websocket.
onCandidate(msg) {
if (this.WebRTC.pc[msg.username] == undefined) return;
let pc = this.WebRTC.pc[msg.username];
// Add the new ICE candidate.
console.log("Add ICE candidate: %s", msg.candidate);
this.ChatClient(`Received an ICE candidate from ${username}: ${msg.candidate}`);
pc.addIceCandidate(
new RTCIceCandidate(
msg.candidate,
() => {},
console.error,
)
);
},
onSDP(msg) {
if (this.WebRTC.pc[msg.username] == undefined) return;
let pc = this.WebRTC.pc[msg.username];
let description = JSON.parse(msg.description);
// Add the new ICE candidate.
console.log("Set description: %s", description);
this.ChatClient(`Received a Remote Description from ${msg.username}: ${msg.description}.`);
pc.setRemoteDescription(new RTCSessionDescription(description), () => {
// When receiving an offer let's answer it.
if (pc.remoteDescription.type === 'offer') {
pc.createAnswer().then(this.localDescCreated(pc, msg.username)).catch(this.ChatClient);
}
}, console.error);
},
/**
* Front-end web app concerns.
*/
// Start broadcasting my webcam. // Start broadcasting my webcam.
startVideo() { startVideo() {
if (this.webcam.busy) return; if (this.webcam.busy) return;
@ -182,6 +361,16 @@ const app = Vue.createApp({
}) })
}, },
// Begin connecting to someone else's webcam.
openVideo(user) {
if (user.username === this.username) {
this.ChatClient("You can already see your own webcam.");
return;
}
this.sendOpen(user.username);
},
// Stop broadcasting. // Stop broadcasting.
stopVideo() { stopVideo() {
this.webcam.elem.srcObject = null; this.webcam.elem.srcObject = null;

View File

@ -223,7 +223,8 @@
<div class="column">[[ u.username ]]</div> <div class="column">[[ u.username ]]</div>
<div class="column is-narrow"> <div class="column is-narrow">
<button type="button" class="button is-small" <button type="button" class="button is-small"
:disabled="!u.videoActive"> :disabled="!u.videoActive"
@click="openVideo(u)">
<i class="fa fa-video"></i> <i class="fa fa-video"></i>
</button> </button>
</div> </div>