wip: 0.2.5

还未实现 rtc 握手
This commit is contained in:
loveuer 2025-05-23 18:01:16 +08:00
parent 013670b78f
commit b8645a68ed
4 changed files with 158 additions and 226 deletions

View File

@ -72,18 +72,18 @@ interface Bubble {
interface WSMessage { interface WSMessage {
body: Client; body: Client;
time: number; time: number;
type: "enter" | "leave" | "offer" | "answer" type: "register" | "enter" | "leave" | "offer" | "answer"
} }
interface Client { interface Client {
client_type: 'desktop' | 'mobile' | 'tablet'; client_type: 'desktop' | 'mobile' | 'tablet';
app_type: 'web'; app_type: 'web';
room: string;
ip: number; ip: number;
name: string; name: string;
id: string; id: string;
register_at: string; register_at: string;
offer: RTCSessionDescription; offer: RTCSessionDescription;
candidate: RTCIceCandidateInit;
} }
interface Store { interface Store {
@ -184,19 +184,26 @@ export const LocalSharing: React.FC = () => {
return bubbles; return bubbles;
}; };
const updateClients = async (room?: string) => { const updateClients = async () => {
const res = await fetch(`/api/ulocal/clients?room=${room ? room : rtcStore.client?.room}`) setTimeout(async () => {
const jes = await res.json() as Resp<Client[]> const res = await fetch(`/api/ulocal/clients`)
console.log('[D] update clients called, resp =', jes) const jes = await res.json() as Resp<Client[]>
setRTCStore(val => { setRTCStore(val => {
return {...val, clients: jes.data} return {...val, clients: jes.data}
}) })
}, 500)
} }
const handleWSEvent = async (e: MessageEvent) => { const handleWSEvent = async (e: MessageEvent) => {
const msg = JSON.parse(e.data) as WSMessage const msg = JSON.parse(e.data) as WSMessage
console.log('[D] ws event msg =', msg) console.log('[D] ws event msg =', msg)
switch (msg.type) { switch (msg.type) {
case "register":
const reg_resp = JSON.parse(e.data) as { body: Client }
setRTCStore(val => {
return {...val, client: reg_resp.body}
})
break
case "enter": case "enter":
await updateClients() await updateClients()
break break
@ -204,89 +211,119 @@ export const LocalSharing: React.FC = () => {
await updateClients() await updateClients()
break break
case "offer": case "offer":
console.log('[D] rtc =', rtcStore.rtc) const res_offer = JSON.parse(e.data) as {
const offer = JSON.parse(e.data) as { offer: RTCSessionDescriptionInit, id: number, room: string } offer: RTCSessionDescriptionInit,
console.log('[D] offer =', offer) id: number,
await rtcStore.rtc?.setRemoteDescription(offer.offer) candidate: RTCIceCandidateInit
}
console.log('[D] offer res =', res_offer)
await rtcStore.rtc?.setRemoteDescription(res_offer.offer)
const answer = await rtcStore.rtc?.createAnswer() const answer = await rtcStore.rtc?.createAnswer()
await rtcStore.rtc?.setLocalDescription(answer) await rtcStore.rtc?.setLocalDescription(answer)
console.log('[D] answer =', answer) await fetch('/api/ulocal/answer', {
await fetch("/api/ulocal/answer", { method: 'POST',
method: "POST",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify({id: offer.id, room: offer.room, answer: answer}) body: JSON.stringify({id: rtcStore.client?.id, answer: answer})
}) })
break break
case "answer": case "answer":
// const _answer = JSON.parse(e.data) as { answer: RTCSessionDescriptionInit, id: number, room: string } const res_answer = JSON.parse(e.data) as { answer: RTCSessionDescriptionInit, id: number }
// await rtcStore.rtc?.setRemoteDescription(_answer.answer) await rtcStore.rtc?.setRemoteDescription(res_answer.answer)
// break break
} }
} }
// useEffect(() => {
// const fn = async () => {
// // const rtc = new RTCPeerConnection({iceServers: [{urls: "stun:stun.qq.com:3478"}]})
// const rtc = new RTCPeerConnection({})
// rtc.onconnectionstatechange = () => {
// console.log('rtc connection state =', rtc.connectionState)
// }
// const dataChannel = rtc.createDataChannel('fileTransfer', {ordered: true});
// setupDataChannel(dataChannel);
// const waitCandidate = (): Promise<RTCIceCandidate | null> => {
// return new Promise<RTCIceCandidate | null>(resolve => {
// rtc.onicecandidate = (e) => {
// resolve(e.candidate)
// }
// })
// }
//
// const waitNegotiation = (): Promise<void> => {
// return new Promise<void>(resolve => {
// rtc.onnegotiationneeded = async () => {
// const _offer = await rtc.createOffer()
// await rtc.setLocalDescription(_offer)
// resolve()
// }
// })
// }
//
// await waitNegotiation()
// const candidate: RTCIceCandidate | null = await waitCandidate();
//
// const res = await fetch("/api/ulocal/register", {
// method: "POST",
// headers: {"Content-Type": "application/json"},
// body: JSON.stringify({candidate: candidate})
// })
// const jes = await res.json() as Resp<Client>;
//
// console.log('[D] register resp =', jes)
//
// if (!jes.data.id) {
// message.error("注册失败")
// throw new Error("register failed")
// }
//
// setRTCStore(val => {
// return {
// ...val,
// client: jes.data,
// candidate: candidate,
// offer: rtc.localDescription,
// rtc: rtc,
// }
// })
//
// const api = `${window.location.protocol === 'https' ? 'wss' : 'ws'}://${window.location.host}/api/ulocal/ws?id=${jes.data.id}`
// console.log('[D] websocket url =', api)
// connect(api, {fn: handleWSEvent})
//
// await updateClients()
// }
//
// fn()
//
// return () => close();
// }, []
// );
// 气泡点击处理
const handleBubbleClick = async (bubble: Bubble) => {
const offer = await rtcStore.rtc?.createOffer()
await rtcStore.rtc?.setLocalDescription(offer)
// await fetch('/api/ulocal/offer', {
// method: 'POST',
// headers: {"Content-Type": "application/json"},
// body: JSON.stringify({
// id: bubble.id,
// offer: rtcStore.offer,
// candidate: rtcStore.candidate,
// })
// })
};
useEffect(() => { useEffect(() => {
const fn = async () => { const fn = async () => {
connect("/api/ulocal/ws", {fn: handleWSEvent})
await updateClients()
const rtc = new RTCPeerConnection({iceServers: [{urls: "stun:stun.qq.com:3478"}]}) const rtc = new RTCPeerConnection({iceServers: [{urls: "stun:stun.qq.com:3478"}]})
const dataChannel = rtc.createDataChannel('fileTransfer', {ordered: true}); setRTCStore(val => {return {...val, rtc: rtc}})
setupDataChannel(dataChannel); };
const waitCandidate = new Promise<RTCIceCandidate | null>(resolve => {
rtc.onicecandidate = (e) => {
resolve(e.candidate)
}
})
const waitNegotiationneeded = new Promise<void>(resolve => {
rtc.onnegotiationneeded = async () => {
const _offer = await rtc.createOffer()
await rtc.setLocalDescription(_offer)
resolve()
}
})
await waitNegotiationneeded
const candidate: RTCIceCandidate | null = await waitCandidate;
if (!candidate) throw new Error("candidate is null")
const res = await fetch("/api/ulocal/register", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({candidate: candidate, offer: rtc.localDescription})
})
const jes = await res.json() as Resp<Client>;
if (!jes.data.id) {
message.error("注册失败")
throw new Error("register failed")
}
setRTCStore(val => {
return {...val, client: jes.data, candidate: candidate, offer: rtc.localDescription, rtc: rtc, ch: dataChannel}
})
const api = `${window.location.protocol === 'https' ? 'wss' : 'ws'}://${window.location.host}/api/ulocal/ws?id=${jes.data.id}`
//console.log('[D] websocket url =', api)
connect(api, {fn: handleWSEvent})
await updateClients(jes.data.room)
}
fn() fn()
}, [])
return () => close();
}, []);
// 气泡点击处理
const handleBubbleClick = async (id: string) => {
await fetch('/api/ulocal/offer', {
method: 'POST',
headers: {"Content-Type": "application/json"},
body: JSON.stringify({room: rtcStore.client?.room, id: id, offer: rtcStore.offer})
})
setTimeout(() => {
rtcStore.ch?.send("hello, world")
}, 1000)
};
return <div className={classes.container}> return <div className={classes.container}>
<CloudBackground/> <CloudBackground/>
@ -305,7 +342,7 @@ export const LocalSharing: React.FC = () => {
`${Math.random() * 0.5}s, `${Math.random() * 0.5}s,
${0.5 + Math.random() * 2}s` ${0.5 + Math.random() * 2}s`
}} }}
onClick={() => handleBubbleClick(bubble.id)} onClick={() => handleBubbleClick(bubble)}
> >
{bubble.name} {bubble.name}
</div> </div>

View File

@ -25,7 +25,6 @@ func Start(ctx context.Context) <-chan struct{} {
{ {
api := app.Group("/api/ulocal") api := app.Group("/api/ulocal")
api.Post("/register", handler.LocalRegister())
api.Post("/offer", handler.LocalOffer()) api.Post("/offer", handler.LocalOffer())
api.Post("/answer", handler.LocalAnswer()) api.Post("/answer", handler.LocalAnswer())
api.Get("/clients", handler.LocalClients()) api.Get("/clients", handler.LocalClients())

View File

@ -55,12 +55,9 @@ type roomClient struct {
ClientType RoomClientType `json:"client_type"` ClientType RoomClientType `json:"client_type"`
AppType RoomAppType `json:"app_type"` AppType RoomAppType `json:"app_type"`
IP string `json:"ip"` IP string `json:"ip"`
Room string `json:"room"`
Name string `json:"name"` Name string `json:"name"`
Id string `json:"id"` Id string `json:"id"`
RegisterAt time.Time `json:"register_at"` RegisterAt time.Time `json:"register_at"`
Offer *RoomOffer `json:"offer"`
Candidate *RoomCandidate `json:"candidate"`
msgChan chan any msgChan chan any
} }
@ -130,42 +127,25 @@ func (rc *roomClient) start(ctx context.Context) {
type roomController struct { type roomController struct {
sync.Mutex sync.Mutex
ctx context.Context ctx context.Context
rooms map[string]map[string]*roomClient // map[room_id(remote-IP)][Id] //rooms map[string]map[string]*roomClient // map[room_id(remote-IP)][Id]
notReadies map[string]*roomClient clients map[string]*roomClient
} }
var ( var (
RoomController = &roomController{ RoomController = &roomController{
rooms: make(map[string]map[string]*roomClient), clients: make(map[string]*roomClient),
notReadies: make(map[string]*roomClient),
} }
) )
func (rc *roomController) Start(ctx context.Context) { func (rc *roomController) Start(ctx context.Context) {
rc.ctx = ctx rc.ctx = ctx
go func() {
ticker := time.NewTicker(1 * time.Minute)
for {
select {
case <-rc.ctx.Done():
return
case now := <-ticker.C:
for _, nrc := range rc.notReadies {
if now.Sub(nrc.RegisterAt).Minutes() > 1 {
rc.Lock()
delete(rc.notReadies, nrc.Id)
rc.Unlock()
}
}
}
}
}()
} }
func (rc *roomController) Register(ip, userAgent string, candidate *RoomCandidate, offer *RoomOffer) *roomClient { func (rc *roomController) Register(conn *websocket.Conn, ip, userAgent string) *roomClient {
nrc := &roomClient{ nrc := &roomClient{
controller: rc, controller: rc,
conn: conn,
ClientType: ClientTypeDesktop, ClientType: ClientTypeDesktop,
AppType: RoomAppTypeWeb, AppType: RoomAppTypeWeb,
IP: ip, IP: ip,
@ -173,8 +153,6 @@ func (rc *roomController) Register(ip, userAgent string, candidate *RoomCandidat
Name: tool.RandomName(), Name: tool.RandomName(),
msgChan: make(chan any, 1), msgChan: make(chan any, 1),
RegisterAt: time.Now(), RegisterAt: time.Now(),
Candidate: candidate,
Offer: offer,
} }
ua := useragent.Parse(userAgent) ua := useragent.Parse(userAgent)
@ -185,121 +163,71 @@ func (rc *roomController) Register(ip, userAgent string, candidate *RoomCandidat
nrc.ClientType = ClientTypeTablet nrc.ClientType = ClientTypeTablet
} }
key := "local" log.Debug("controller.room: registry client, IP = %s, Id = %s, Name = %s", nrc.IP, nrc.Id, nrc.Name)
if !tool.IsPrivateIP(ip) {
key = ip
}
nrc.Room = key nrc.start(rc.ctx)
nrc.msgChan <- map[string]any{"type": "register", "time": time.Now().UnixMilli(), "body": nrc}
rc.Broadcast(map[string]any{"type": "enter", "time": time.Now().UnixMilli(), "body": nrc})
rc.Lock() rc.Lock()
rc.clients[nrc.Id] = nrc
log.Debug("controller.room: registry client, IP = %s(%s), Id = %s, Name = %s", key, nrc.IP, nrc.Id, nrc.Name)
rc.notReadies[nrc.Id] = nrc
if _, ok := rc.rooms[nrc.Room]; !ok {
rc.rooms[nrc.Room] = make(map[string]*roomClient)
}
rc.Unlock() rc.Unlock()
return nrc return nrc
} }
func (rc *roomController) Enter(conn *websocket.Conn, id string) { func (rc *roomController) List() []*roomClient {
client, ok := rc.notReadies[id]
if !ok {
log.Warn("controller.room: entry room id not exist, id = %s", id)
return
}
rc.Lock()
if _, ok = rc.rooms[client.Room]; !ok {
log.Warn("controller.room: entry room not exist, room = %s, id = %s, name = %s", client.Room, id, client.Name)
return
}
rc.rooms[client.Room][id] = client
client.conn = conn
rc.Unlock()
client.start(rc.ctx)
rc.Broadcast(client.Room, map[string]any{"type": RoomMessageTypeEnter, "time": time.Now().UnixMilli(), "body": client})
}
func (rc *roomController) List(room string) []*roomClient {
log.Debug("controller.room: list room = %s", room)
clientList := make([]*roomClient, 0) clientList := make([]*roomClient, 0)
clients, ok := rc.rooms[room] for _, client := range rc.clients {
if !ok {
return clientList
}
for _, client := range clients {
clientList = append(clientList, client) clientList = append(clientList, client)
} }
return clientList return clientList
} }
func (rc *roomController) Broadcast(room string, msg any) { func (rc *roomController) Broadcast(msg any) {
for _, client := range rc.rooms[room] { for _, client := range rc.clients {
select { select {
case client.msgChan <- msg: case client.msgChan <- msg:
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
log.Warn("RoomController: broadcast timeout, room = %s, client Id = %s, IP = %s", room, client.Id, client.IP) log.Warn("RoomController: broadcast timeout, client Id = %s, IP = %s", client.Id, client.IP)
} }
} }
} }
func (rc *roomController) Unregister(client *roomClient) { func (rc *roomController) Unregister(client *roomClient) {
key := "local" log.Debug("controller.room: unregister client, IP = %s, Id = %s, Name = %s", client.IP, client.Id, client.Name)
if !tool.IsPrivateIP(client.IP) {
key = client.IP
}
log.Debug("controller.room: unregister client, IP = %s(%s), Id = %s, Name = %s", client.IP, key, client.Id, client.Name)
rc.Lock() rc.Lock()
delete(rc.rooms[key], client.Id) delete(rc.clients, client.Id)
rc.Unlock() rc.Unlock()
rc.Broadcast(key, map[string]any{"type": RoomMessageTypeLeave, "time": time.Now().UnixMilli(), "body": client}) rc.Broadcast(map[string]any{"type": RoomMessageTypeLeave, "time": time.Now().UnixMilli(), "body": client})
} }
func (rc *roomController) Offer(room, id string, offer *RoomOffer) { func (rc *roomController) Offer(id string, offer *RoomOffer, candidate *RoomCandidate) {
if _, ok := rc.rooms[room]; !ok { if _, ok := rc.clients[id]; !ok {
return return
} }
if _, ok := rc.rooms[room][id]; !ok { rc.clients[id].msgChan <- map[string]any{
return "type": "offer",
} "id": id,
"offer": offer,
rc.rooms[room][id].msgChan <- map[string]any{ "candidate": candidate,
"type": "offer",
"id": id,
"room": room,
"offer": offer,
} }
} }
func (rc *roomController) Answer(room, id string, answer *RoomOffer) { func (rc *roomController) Answer(id string, answer *RoomOffer) {
if _, ok := rc.rooms[room]; !ok { if _, ok := rc.clients[id]; !ok {
return return
} }
if _, ok := rc.rooms[room][id]; !ok { rc.clients[id].msgChan <- map[string]any{
return
}
rc.rooms[room][id].msgChan <- map[string]any{
"type": "answer", "type": "answer",
"id": id, "id": id,
"room": room,
"answer": answer, "answer": answer,
} }
} }

View File

@ -9,38 +9,9 @@ import (
"net/http" "net/http"
) )
func LocalRegister() nf.HandlerFunc {
return func(c *nf.Ctx) error {
type Req struct {
Candidate *controller.RoomCandidate `json:"candidate"`
Offer *controller.RoomOffer `json:"offer"`
}
var (
err error
req = new(Req)
ip = c.IP(true)
ua = c.Get("User-Agent")
)
if err = c.BodyParser(req); err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]interface{}{"msg": err.Error()})
}
client := controller.RoomController.Register(ip, ua, req.Candidate, req.Offer)
return resp.Resp200(c, client)
}
}
func LocalClients() nf.HandlerFunc { func LocalClients() nf.HandlerFunc {
return func(c *nf.Ctx) error { return func(c *nf.Ctx) error {
room := c.Query("room") list := controller.RoomController.List()
if room == "" {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": "room can't be empty"})
}
list := controller.RoomController.List(room)
return resp.Resp200(c, list) return resp.Resp200(c, list)
} }
@ -56,12 +27,10 @@ func LocalWS() nf.HandlerFunc {
} }
return func(c *nf.Ctx) error { return func(c *nf.Ctx) error {
var (
id := c.Query("id") ip = c.IP(true)
ua = c.Get("User-Agent")
if id == "" { )
return c.Status(http.StatusBadRequest).JSON(map[string]string{"error": "id is empty"})
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
@ -69,7 +38,7 @@ func LocalWS() nf.HandlerFunc {
return err return err
} }
controller.RoomController.Enter(conn, id) controller.RoomController.Register(conn, ip, ua)
return nil return nil
} }
@ -78,9 +47,9 @@ func LocalWS() nf.HandlerFunc {
func LocalOffer() nf.HandlerFunc { func LocalOffer() nf.HandlerFunc {
return func(c *nf.Ctx) error { return func(c *nf.Ctx) error {
type Req struct { type Req struct {
Room string `json:"room"` Id string `json:"id"`
Id string `json:"id"` Offer *controller.RoomOffer `json:"offer"`
Offer *controller.RoomOffer `json:"offer"` Candidate *controller.RoomCandidate `json:"candidate"`
} }
var ( var (
@ -92,7 +61,7 @@ func LocalOffer() nf.HandlerFunc {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()}) return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()})
} }
controller.RoomController.Offer(req.Room, req.Id, req.Offer) controller.RoomController.Offer(req.Id, req.Offer, req.Candidate)
return resp.Resp200(c, req.Offer) return resp.Resp200(c, req.Offer)
} }
@ -101,7 +70,6 @@ func LocalOffer() nf.HandlerFunc {
func LocalAnswer() nf.HandlerFunc { func LocalAnswer() nf.HandlerFunc {
return func(c *nf.Ctx) error { return func(c *nf.Ctx) error {
type Req struct { type Req struct {
Room string `json:"room"`
Id string `json:"id"` Id string `json:"id"`
Answer *controller.RoomOffer `json:"answer"` Answer *controller.RoomOffer `json:"answer"`
} }
@ -115,7 +83,7 @@ func LocalAnswer() nf.HandlerFunc {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()}) return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()})
} }
controller.RoomController.Answer(req.Room, req.Id, req.Answer) controller.RoomController.Answer(req.Id, req.Answer)
return resp.Resp200(c, req) return resp.Resp200(c, req)
} }