First pass at adding a waiting room.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
# shellcheck shell=bash disable=SC2034,SC2154
|
||||
|
||||
pkgname=skald-git
|
||||
pkgver=2026.07.08.r2.g6a94f89
|
||||
pkgver=2026.07.08.r3.g9125e76
|
||||
pkgrel=1
|
||||
pkgdesc='Audio-only hall-based conferencing server'
|
||||
arch=('x86_64' 'aarch64')
|
||||
|
||||
@@ -133,6 +133,12 @@ type ClientCredentials struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
// AuthenticatedClient is the credential-free result of authentication.
|
||||
type AuthenticatedClient struct {
|
||||
Username string
|
||||
Permissions []string
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
Hall() *Hall
|
||||
Addr() net.Addr
|
||||
|
||||
+142
-83
@@ -105,6 +105,7 @@ type Hall struct {
|
||||
description *Description
|
||||
locked *string
|
||||
clients map[string]Client
|
||||
reserved map[string]Client
|
||||
history []ChatHistoryEntry
|
||||
timestamp time.Time
|
||||
data map[string]interface{}
|
||||
@@ -232,6 +233,37 @@ func (g *Hall) ClientCount() int {
|
||||
return g.clientCountUnlocked()
|
||||
}
|
||||
|
||||
// ReserveUsernameWhileWaiting atomically checks whether waiting is available
|
||||
// and, if so, reserves a username for an authenticated non-member.
|
||||
func (g *Hall) ReserveUsernameWhileWaiting(username string, client Client) (bool, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.locked == nil || !g.hasOperatorUnlocked() {
|
||||
return false, nil
|
||||
}
|
||||
for _, c := range g.clients {
|
||||
if !isSystemClient(c) && c.Username() == username {
|
||||
return true, UserError("username already in use")
|
||||
}
|
||||
}
|
||||
if holder := g.reserved[username]; holder != nil && holder != client {
|
||||
return true, UserError("username already in use")
|
||||
}
|
||||
if g.reserved == nil {
|
||||
g.reserved = make(map[string]Client)
|
||||
}
|
||||
g.reserved[username] = client
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (g *Hall) ReleaseUsername(username string, client Client) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.reserved[username] == client {
|
||||
delete(g.reserved, username)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Hall) clientCountUnlocked() int {
|
||||
count := 0
|
||||
for _, c := range g.clients {
|
||||
@@ -252,6 +284,9 @@ func (g *Hall) mayExpire() bool {
|
||||
if len(g.clients) > 0 {
|
||||
return false
|
||||
}
|
||||
if len(g.reserved) > 0 {
|
||||
return false
|
||||
}
|
||||
return time.Since(g.timestamp) > maxHistoryAge(g.description)
|
||||
}
|
||||
|
||||
@@ -578,7 +613,7 @@ func Delete(name string) bool {
|
||||
|
||||
// Called with both halls.mu and g.mu taken.
|
||||
func deleteUnlocked(g *Hall) bool {
|
||||
if len(g.clients) != 0 {
|
||||
if len(g.clients) != 0 || len(g.reserved) != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -599,112 +634,136 @@ func isSystemClient(c Client) bool {
|
||||
return member("system", c.Permissions())
|
||||
}
|
||||
|
||||
func AddClient(hall string, c Client, creds ClientCredentials) (*Hall, error) {
|
||||
g, err := Add(hall, nil)
|
||||
func AuthenticateClient(name string, creds ClientCredentials) (*Hall, AuthenticatedClient, error) {
|
||||
g, err := Add(name, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, AuthenticatedClient{}, err
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
username, perms, err := g.getPermission(creds)
|
||||
if err != nil {
|
||||
return nil, AuthenticatedClient{}, err
|
||||
}
|
||||
return g, AuthenticatedClient{username, append([]string(nil), perms...)}, nil
|
||||
}
|
||||
|
||||
// AddAuthenticatedClient admits a previously authenticated non-system client.
|
||||
// A lock bypass does not bypass time restrictions or capacity.
|
||||
func AddAuthenticatedClient(g *Hall, c Client, auth AuthenticatedClient, bypassLock bool) error {
|
||||
if g == nil {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
clients := g.getClientsUnlocked(nil)
|
||||
systemClient := isSystemClient(c)
|
||||
|
||||
if !systemClient {
|
||||
username, perms, err := g.getPermission(creds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if holder := g.reserved[auth.Username]; holder != nil && holder != c {
|
||||
return UserError("username already in use")
|
||||
}
|
||||
c.SetUsername(auth.Username)
|
||||
c.SetPermissions(auth.Permissions)
|
||||
for _, cc := range clients {
|
||||
if !isSystemClient(cc) && cc.Username() == auth.Username {
|
||||
return UserError("username already in use")
|
||||
}
|
||||
|
||||
c.SetUsername(username)
|
||||
c.SetPermissions(perms)
|
||||
|
||||
for _, cc := range clients {
|
||||
if !isSystemClient(cc) && cc.Username() == username {
|
||||
return nil, UserError("username already in use")
|
||||
}
|
||||
if !member("op", auth.Permissions) {
|
||||
if !bypassLock && g.locked != nil {
|
||||
m := *g.locked
|
||||
if m == "" {
|
||||
m = "Hall is locked."
|
||||
}
|
||||
return UserError(m)
|
||||
}
|
||||
now := time.Now()
|
||||
if g.description.NotBefore != nil && g.description.NotBefore.After(now) {
|
||||
return UserError("this hall is not open yet")
|
||||
}
|
||||
if g.description.Expires != nil && g.description.Expires.Before(now) {
|
||||
return UserError("this hall is closed")
|
||||
}
|
||||
if g.description.Autokick {
|
||||
ops := false
|
||||
for _, existing := range clients {
|
||||
if member("op", existing.Permissions()) {
|
||||
ops = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ops {
|
||||
return UserError("there are no operators in this hall")
|
||||
}
|
||||
}
|
||||
|
||||
if !member("op", perms) {
|
||||
if g.locked != nil {
|
||||
m := *g.locked
|
||||
if m == "" {
|
||||
m = "Hall is locked."
|
||||
}
|
||||
return nil, UserError(m)
|
||||
}
|
||||
if g.description.NotBefore != nil ||
|
||||
g.description.Expires != nil {
|
||||
now := time.Now()
|
||||
if g.description.NotBefore != nil &&
|
||||
g.description.NotBefore.After(now) {
|
||||
return nil, UserError(
|
||||
"this hall is not open yet",
|
||||
)
|
||||
}
|
||||
if g.description.Expires != nil &&
|
||||
g.description.Expires.Before(now) {
|
||||
return nil, UserError(
|
||||
"this hall is closed",
|
||||
)
|
||||
}
|
||||
}
|
||||
if g.description.Autokick {
|
||||
ops := false
|
||||
for _, c := range clients {
|
||||
if member("op", c.Permissions()) {
|
||||
ops = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ops {
|
||||
return nil, UserError(
|
||||
"there are no operators " +
|
||||
"in this hall",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !member("op", perms) && g.description.MaxClients > 0 {
|
||||
if g.clientCountUnlocked() >= g.description.MaxClients {
|
||||
return nil, UserError("too many users")
|
||||
}
|
||||
if g.description.MaxClients > 0 && g.clientCountUnlocked() >= g.description.MaxClients {
|
||||
return UserError("too many users")
|
||||
}
|
||||
}
|
||||
id := c.Id()
|
||||
if id == "" {
|
||||
return nil, errors.New("client has empty id")
|
||||
return errors.New("client has empty id")
|
||||
}
|
||||
if g.clients[id] != nil {
|
||||
return nil, ProtocolError("duplicate client id")
|
||||
return ProtocolError("duplicate client id")
|
||||
}
|
||||
g.clients[id] = c
|
||||
delete(g.reserved, auth.Username)
|
||||
g.timestamp = time.Now()
|
||||
|
||||
c.Joined(g.Name(), "join")
|
||||
|
||||
u := c.Username()
|
||||
p := c.Permissions()
|
||||
s := c.Data()
|
||||
if !systemClient {
|
||||
c.PushClient(g.Name(), "add", c.Id(), u, p, s)
|
||||
}
|
||||
u, p, s := c.Username(), c.Permissions(), c.Data()
|
||||
c.PushClient(g.Name(), "add", id, u, p, s)
|
||||
for _, cc := range clients {
|
||||
pp := cc.Permissions()
|
||||
uu := cc.Username()
|
||||
if !isSystemClient(cc) {
|
||||
c.PushClient(
|
||||
g.Name(), "add", cc.Id(), uu, pp, cc.Data(),
|
||||
)
|
||||
c.PushClient(g.Name(), "add", cc.Id(), cc.Username(), cc.Permissions(), cc.Data())
|
||||
}
|
||||
if !systemClient {
|
||||
cc.PushClient(g.Name(), "add", id, u, p, s)
|
||||
cc.PushClient(g.Name(), "add", id, u, p, s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddClient(name string, c Client, creds ClientCredentials) (*Hall, error) {
|
||||
if creds.System || isSystemClient(c) {
|
||||
g, err := Add(name, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if c.Id() == "" {
|
||||
return nil, errors.New("client has empty id")
|
||||
}
|
||||
if g.clients[c.Id()] != nil {
|
||||
return nil, ProtocolError("duplicate client id")
|
||||
}
|
||||
g.clients[c.Id()] = c
|
||||
g.timestamp = time.Now()
|
||||
c.Joined(g.Name(), "join")
|
||||
return g, nil
|
||||
}
|
||||
g, auth, err := AuthenticateClient(name, creds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := AddAuthenticatedClient(g, c, auth, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// WaitingStatus atomically reports the lock and active-operator state.
|
||||
func (g *Hall) WaitingStatus() (bool, bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.locked != nil, g.hasOperatorUnlocked()
|
||||
}
|
||||
|
||||
// called locked
|
||||
func (g *Hall) hasOperatorUnlocked() bool {
|
||||
for _, c := range g.clients {
|
||||
if !isSystemClient(c) && member("op", c.Permissions()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return g, nil
|
||||
return false
|
||||
}
|
||||
|
||||
// called locked
|
||||
|
||||
@@ -42,6 +42,20 @@ var hallUnlockSchedules = struct {
|
||||
entries: make(map[*hall.Hall]*scheduledHallUnlock),
|
||||
}
|
||||
|
||||
func scheduledUnlockDeadline(g *hall.Hall) (time.Time, bool) {
|
||||
hallUnlockSchedules.Lock()
|
||||
entry := hallUnlockSchedules.entries[g]
|
||||
if entry == nil {
|
||||
hallUnlockSchedules.Unlock()
|
||||
return time.Time{}, false
|
||||
}
|
||||
entry.mu.Lock()
|
||||
deadline, active := entry.deadline, entry.active
|
||||
entry.mu.Unlock()
|
||||
hallUnlockSchedules.Unlock()
|
||||
return deadline, active
|
||||
}
|
||||
|
||||
func scheduledUnlockRequestFromValue(value interface{}) (scheduledUnlockRequest, error) {
|
||||
v, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -265,6 +279,7 @@ func scheduleHallUnlock(g *hall.Hall, issuer *webClient, deadline, now time.Time
|
||||
broadcastHallInfo(g, message, true)
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
waitingHallChanged(g)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -310,6 +325,7 @@ func finishScheduledUnlock(entry *scheduledHallUnlock) {
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
hallUnlockSchedules.Unlock()
|
||||
waitingHallChanged(entry.hall)
|
||||
}
|
||||
|
||||
func setHallLockState(g *hall.Hall, locked bool, message string) bool {
|
||||
@@ -329,6 +345,7 @@ func setHallLockState(g *hall.Hall, locked bool, message string) bool {
|
||||
broadcastHallInfo(g, "Hall unlocked", false)
|
||||
}
|
||||
hallUnlockSchedules.Unlock()
|
||||
waitingHallChanged(g)
|
||||
return entry != nil
|
||||
}
|
||||
|
||||
@@ -343,6 +360,21 @@ func cancelScheduledHallUnlock(g *hall.Hall) bool {
|
||||
return entry != nil
|
||||
}
|
||||
|
||||
func cancelScheduledUnlockByIssuer(g *hall.Hall, issuer *webClient) bool {
|
||||
hallUnlockSchedules.Lock()
|
||||
entry := hallUnlockSchedules.entries[g]
|
||||
if entry == nil || entry.issuer != issuer {
|
||||
hallUnlockSchedules.Unlock()
|
||||
return false
|
||||
}
|
||||
delete(hallUnlockSchedules.entries, g)
|
||||
deactivateScheduledUnlock(entry)
|
||||
hallUnlockSchedules.Unlock()
|
||||
broadcastHallInfo(g, "Scheduled hall unlock cancelled because the issuing operator is no longer present as an operator.", true)
|
||||
waitingHallChanged(g)
|
||||
return true
|
||||
}
|
||||
|
||||
func deactivateScheduledUnlock(entry *scheduledHallUnlock) {
|
||||
entry.mu.Lock()
|
||||
entry.active = false
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package rtpconn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/skald/hall"
|
||||
)
|
||||
|
||||
type waitingEntry struct {
|
||||
client *webClient
|
||||
hall *hall.Hall
|
||||
auth hall.AuthenticatedClient
|
||||
autoJoin bool
|
||||
approved bool
|
||||
manualJoin bool
|
||||
}
|
||||
|
||||
type waitingEventAction struct {
|
||||
message clientMessage
|
||||
}
|
||||
|
||||
type waitingState struct {
|
||||
Hall string `json:"hall"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Username string `json:"username"`
|
||||
Locked bool `json:"locked"`
|
||||
Deadline string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
type waitingUser struct {
|
||||
Username string `json:"username"`
|
||||
Approved bool `json:"approved,omitempty"`
|
||||
}
|
||||
|
||||
var waitingRooms = struct {
|
||||
sync.Mutex
|
||||
entries map[*hall.Hall][]*waitingEntry
|
||||
}{
|
||||
entries: make(map[*hall.Hall][]*waitingEntry),
|
||||
}
|
||||
|
||||
var errWaitingUnavailable = errors.New("waiting room unavailable")
|
||||
|
||||
func waitingDisplayName(g *hall.Hall) string {
|
||||
name := g.Status(true, nil).DisplayName
|
||||
if name == "" {
|
||||
name = g.Name()
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func currentWaitingState(g *hall.Hall, username string) waitingState {
|
||||
locked, _ := g.Locked()
|
||||
state := waitingState{
|
||||
Hall: g.Name(), DisplayName: waitingDisplayName(g),
|
||||
Username: username, Locked: locked,
|
||||
}
|
||||
if deadline, ok := scheduledUnlockDeadline(g); ok {
|
||||
state.Deadline = deadline.Format(time.RFC3339)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func sendWaiting(c *webClient, kind string, value interface{}) {
|
||||
c.action(waitingEventAction{clientMessage{
|
||||
Type: "waiting", Kind: kind, Value: value,
|
||||
}})
|
||||
}
|
||||
|
||||
func sendWaitingList(c *webClient, kind string, value interface{}) {
|
||||
c.action(waitingEventAction{clientMessage{
|
||||
Type: "waiting-list", Kind: kind, Value: value,
|
||||
}})
|
||||
}
|
||||
|
||||
func waitingSnapshotLocked(g *hall.Hall) []waitingUser {
|
||||
entries := waitingRooms.entries[g]
|
||||
users := make([]waitingUser, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
users = append(users, waitingUser{entry.auth.Username, entry.approved})
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func waitingClientHall(c *webClient) *hall.Hall {
|
||||
waitingRooms.Lock()
|
||||
defer waitingRooms.Unlock()
|
||||
if c.waiting == nil {
|
||||
return nil
|
||||
}
|
||||
return c.waiting.hall
|
||||
}
|
||||
|
||||
func notifyWaitingOperators(g *hall.Hall, kind string, value interface{}) {
|
||||
for _, client := range g.GetClients(nil) {
|
||||
c, ok := client.(*webClient)
|
||||
if ok && member("op", c.Permissions()) {
|
||||
sendWaitingList(c, kind, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addWaitingClient(c *webClient, g *hall.Hall, auth hall.AuthenticatedClient) error {
|
||||
waitingRooms.Lock()
|
||||
offered, err := g.ReserveUsernameWhileWaiting(auth.Username, c)
|
||||
if err != nil {
|
||||
waitingRooms.Unlock()
|
||||
return err
|
||||
}
|
||||
if !offered {
|
||||
waitingRooms.Unlock()
|
||||
return errWaitingUnavailable
|
||||
}
|
||||
entry := &waitingEntry{client: c, hall: g, auth: auth}
|
||||
waitingRooms.entries[g] = append(waitingRooms.entries[g], entry)
|
||||
c.waiting = entry
|
||||
c.SetUsername(auth.Username)
|
||||
sendWaiting(c, "enter", currentWaitingState(g, auth.Username))
|
||||
notifyWaitingOperators(g, "add", waitingUser{Username: auth.Username})
|
||||
waitingRooms.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeWaitingClient(c *webClient, notify bool) bool {
|
||||
waitingRooms.Lock()
|
||||
entry := c.waiting
|
||||
if entry == nil {
|
||||
waitingRooms.Unlock()
|
||||
return false
|
||||
}
|
||||
removeWaitingEntryLocked(entry)
|
||||
if notify {
|
||||
notifyWaitingOperators(entry.hall, "remove", waitingUser{Username: entry.auth.Username})
|
||||
}
|
||||
waitingRooms.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func removeWaitingEntryLocked(entry *waitingEntry) {
|
||||
entries := waitingRooms.entries[entry.hall]
|
||||
for i, candidate := range entries {
|
||||
if candidate == entry {
|
||||
entries = append(entries[:i], entries[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
delete(waitingRooms.entries, entry.hall)
|
||||
} else {
|
||||
waitingRooms.entries[entry.hall] = entries
|
||||
}
|
||||
entry.client.waiting = nil
|
||||
entry.hall.ReleaseUsername(entry.auth.Username, entry.client)
|
||||
}
|
||||
|
||||
func cancelWaiting(g *hall.Hall, message string) {
|
||||
waitingRooms.Lock()
|
||||
cancelWaitingLocked(g, message)
|
||||
waitingRooms.Unlock()
|
||||
}
|
||||
|
||||
func cancelWaitingLocked(g *hall.Hall, message string) {
|
||||
entries := append([]*waitingEntry(nil), waitingRooms.entries[g]...)
|
||||
delete(waitingRooms.entries, g)
|
||||
for _, entry := range entries {
|
||||
entry.client.waiting = nil
|
||||
entry.hall.ReleaseUsername(entry.auth.Username, entry.client)
|
||||
entry.client.SetPermissions(nil)
|
||||
entry.client.SetUsername("")
|
||||
sendWaiting(entry.client, "cancel", message)
|
||||
}
|
||||
}
|
||||
|
||||
func waitingOperatorJoined(g *hall.Hall, c *webClient) {
|
||||
if !member("op", c.Permissions()) {
|
||||
return
|
||||
}
|
||||
waitingRooms.Lock()
|
||||
snapshot := waitingSnapshotLocked(g)
|
||||
sendWaitingList(c, "snapshot", snapshot)
|
||||
waitingRooms.Unlock()
|
||||
}
|
||||
|
||||
func waitingHallChanged(g *hall.Hall) {
|
||||
waitingRooms.Lock()
|
||||
locked, hasOperator := g.WaitingStatus()
|
||||
if locked && !hasOperator {
|
||||
cancelWaitingLocked(g, "Waiting ended because the last operator left the hall.")
|
||||
waitingRooms.Unlock()
|
||||
return
|
||||
}
|
||||
entries := waitingRooms.entries[g]
|
||||
if locked {
|
||||
for _, entry := range entries {
|
||||
entry.manualJoin = false
|
||||
}
|
||||
}
|
||||
kind := "update"
|
||||
if !locked {
|
||||
kind = "open"
|
||||
}
|
||||
for _, entry := range entries {
|
||||
sendWaiting(entry.client, kind, currentWaitingState(g, entry.auth.Username))
|
||||
}
|
||||
waitingRooms.Unlock()
|
||||
processWaiting(g)
|
||||
}
|
||||
|
||||
func processWaiting(g *hall.Hall) {
|
||||
for {
|
||||
waitingRooms.Lock()
|
||||
entries := waitingRooms.entries[g]
|
||||
locked, hasOperator := g.WaitingStatus()
|
||||
if locked && !hasOperator {
|
||||
cancelWaitingLocked(g, "Waiting ended because the last operator left the hall.")
|
||||
waitingRooms.Unlock()
|
||||
return
|
||||
}
|
||||
var entry *waitingEntry
|
||||
for _, candidate := range entries {
|
||||
if candidate.approved || (!locked && (candidate.autoJoin || candidate.manualJoin)) {
|
||||
entry = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if entry == nil {
|
||||
waitingRooms.Unlock()
|
||||
return
|
||||
}
|
||||
// Publish the target hall before AddAuthenticatedClient enqueues its
|
||||
// joined callback; admission may run on a different client's goroutine.
|
||||
entry.client.hall = g
|
||||
err := hall.AddAuthenticatedClient(g, entry.client, entry.auth, entry.approved)
|
||||
if err != nil {
|
||||
entry.client.hall = nil
|
||||
if lockedNow, _ := g.Locked(); lockedNow && !entry.approved {
|
||||
entry.manualJoin = false
|
||||
waitingRooms.Unlock()
|
||||
sendWaiting(entry.client, "update", currentWaitingState(g, entry.auth.Username))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, hall.UserError("too many users")) || err.Error() == "too many users" {
|
||||
waitingRooms.Unlock()
|
||||
return
|
||||
}
|
||||
removeWaitingEntryLocked(entry)
|
||||
entry.client.SetPermissions(nil)
|
||||
entry.client.SetUsername("")
|
||||
notifyWaitingOperators(g, "remove", waitingUser{Username: entry.auth.Username})
|
||||
waitingRooms.Unlock()
|
||||
sendWaiting(entry.client, "cancel", "Admission cannot continue: "+err.Error())
|
||||
continue
|
||||
}
|
||||
removeWaitingEntryLocked(entry)
|
||||
notifyWaitingOperators(g, "remove", waitingUser{Username: entry.auth.Username})
|
||||
waitingRooms.Unlock()
|
||||
waitingOperatorJoined(g, entry.client)
|
||||
}
|
||||
}
|
||||
|
||||
// WaitingCapacityChanged retries pending admissions after a non-WebSocket
|
||||
// client leaves the hall.
|
||||
func WaitingCapacityChanged(g *hall.Hall) {
|
||||
processWaiting(g)
|
||||
}
|
||||
|
||||
func updateWaitingPreference(c *webClient, autoJoin bool) error {
|
||||
waitingRooms.Lock()
|
||||
if c.waiting == nil {
|
||||
waitingRooms.Unlock()
|
||||
return hall.UserError("you are not in a waiting room")
|
||||
}
|
||||
c.waiting.autoJoin = autoJoin
|
||||
g := c.waiting.hall
|
||||
waitingRooms.Unlock()
|
||||
if autoJoin {
|
||||
processWaiting(g)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestWaitingAdmission(c *webClient) error {
|
||||
waitingRooms.Lock()
|
||||
if c.waiting == nil {
|
||||
waitingRooms.Unlock()
|
||||
return hall.UserError("you are not in a waiting room")
|
||||
}
|
||||
g := c.waiting.hall
|
||||
waitingRooms.Unlock()
|
||||
locked, _ := g.Locked()
|
||||
if locked {
|
||||
return hall.UserError("the hall is still locked")
|
||||
}
|
||||
waitingRooms.Lock()
|
||||
if c.waiting != nil {
|
||||
c.waiting.manualJoin = true
|
||||
}
|
||||
waitingRooms.Unlock()
|
||||
processWaiting(g)
|
||||
return nil
|
||||
}
|
||||
|
||||
func admitWaitingClient(operator *webClient, username string) error {
|
||||
if operator.hall == nil || !member("op", operator.Permissions()) {
|
||||
return hall.UserError("not authorised")
|
||||
}
|
||||
g := operator.hall
|
||||
waitingRooms.Lock()
|
||||
var found *waitingEntry
|
||||
for _, entry := range waitingRooms.entries[g] {
|
||||
if entry.auth.Username == username {
|
||||
entry.approved = true
|
||||
found = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
snapshot := waitingSnapshotLocked(g)
|
||||
if found != nil {
|
||||
notifyWaitingOperators(g, "snapshot", snapshot)
|
||||
}
|
||||
waitingRooms.Unlock()
|
||||
if found == nil {
|
||||
return hall.UserError("no such waiting user")
|
||||
}
|
||||
sendWaiting(found.client, "approved", currentWaitingState(g, username))
|
||||
processWaiting(g)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package rtpconn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/skald/hall"
|
||||
)
|
||||
|
||||
func newWaitingTestHall(t *testing.T, maxClients int) (*hall.Hall, string) {
|
||||
t.Helper()
|
||||
name := "waiting-" + strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
|
||||
oldDirectory := hall.Directory
|
||||
hall.Directory = t.TempDir()
|
||||
t.Cleanup(func() { hall.Directory = oldDirectory })
|
||||
password := testPassword()
|
||||
desc := &hall.Description{
|
||||
DisplayName: "Stormux", MaxClients: maxClients,
|
||||
Users: map[string]hall.UserDescription{
|
||||
"Operator": {Password: password, Permissions: makePermission(t, "op")},
|
||||
"Operator2": {Password: password, Permissions: makePermission(t, "op")},
|
||||
"Alice": {Password: password, Permissions: makePermission(t, "present")},
|
||||
"Bob": {Password: password, Permissions: makePermission(t, "present")},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(desc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(hall.Directory, name+".json"), data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := hall.Add(name, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancelScheduledHallUnlock(g)
|
||||
cancelWaiting(g, "test cleanup")
|
||||
for _, client := range g.GetClients(nil) {
|
||||
hall.DelClient(client)
|
||||
}
|
||||
hall.Delete(name)
|
||||
})
|
||||
return g, name
|
||||
}
|
||||
|
||||
func joinWaitingTestClient(t *testing.T, name, username string) *webClient {
|
||||
t.Helper()
|
||||
c := testWebClient(username + "-waiting-id")
|
||||
err := handleClientMessage(c, clientMessage{
|
||||
Type: "join", Kind: "join", Hall: name,
|
||||
Username: &username, Password: "pw",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waiting join: %v", err)
|
||||
}
|
||||
drainActions(t, c)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestWaitingClientAuthenticatedWithoutMembership(t *testing.T) {
|
||||
g, name := newWaitingTestHall(t, 0)
|
||||
operator := addTestWebClient(t, name, "Operator", "op")
|
||||
g.SetLocked(true, "")
|
||||
drainActions(t, operator)
|
||||
|
||||
waiter := joinWaitingTestClient(t, name, "Alice")
|
||||
if waiter.hall != nil || waiter.waiting == nil {
|
||||
t.Fatalf("waiting client membership: hall=%v waiting=%v", waiter.hall, waiter.waiting)
|
||||
}
|
||||
if waiter.Username() != "Alice" || waiter.waiting.auth.Username != "Alice" {
|
||||
t.Fatalf("authenticated identity was not retained: %#v", waiter.waiting.auth)
|
||||
}
|
||||
if g.GetClient(waiter.Id()) != nil {
|
||||
t.Fatal("waiting client was visible as a hall member")
|
||||
}
|
||||
if err := handleClientMessage(waiter, clientMessage{Type: "chat", Value: "private"}); err != nil {
|
||||
t.Fatalf("isolated chat attempt returned fatal error: %v", err)
|
||||
}
|
||||
if len(g.GetChatHistory()) != 0 {
|
||||
t.Fatal("waiting client wrote to hall chat")
|
||||
}
|
||||
duplicate := testWebClient("direct-duplicate-id")
|
||||
username := "Alice"
|
||||
if _, err := hall.AddClient(name, duplicate, hall.ClientCredentials{
|
||||
Username: &username, Password: "pw",
|
||||
}); err == nil {
|
||||
t.Fatal("waiting username was not reserved against direct admission")
|
||||
}
|
||||
messages := drainMessages(waiter)
|
||||
foundEnter := false
|
||||
for _, message := range messages {
|
||||
if message.Type == "waiting" && message.Kind == "enter" {
|
||||
foundEnter = true
|
||||
}
|
||||
if message.Type == "user" || message.Type == "chat" || message.Type == "chathistory" || message.Type == "offer" ||
|
||||
(message.Type == "usermessage" && (message.Kind == "recording" || message.Kind == "chalkboard")) {
|
||||
t.Fatalf("waiting client received hall-only message: %#v", message)
|
||||
}
|
||||
}
|
||||
if !foundEnter {
|
||||
t.Fatalf("missing waiting enter event: %#v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingManualAdmissionAndLastOperatorCancellation(t *testing.T) {
|
||||
g, name := newWaitingTestHall(t, 0)
|
||||
operator := addTestWebClient(t, name, "Operator", "op")
|
||||
g.SetLocked(true, "")
|
||||
drainActions(t, operator)
|
||||
waiter := joinWaitingTestClient(t, name, "Alice")
|
||||
|
||||
if err := admitWaitingClient(operator, "Alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drainActions(t, waiter)
|
||||
if waiter.hall != g || waiter.waiting != nil {
|
||||
t.Fatalf("operator admission did not join: hall=%v waiting=%v", waiter.hall, waiter.waiting)
|
||||
}
|
||||
|
||||
second := joinWaitingTestClient(t, name, "Bob")
|
||||
leaveHall(operator)
|
||||
drainActions(t, second)
|
||||
if second.waiting != nil {
|
||||
t.Fatal("waiting entry remained after last operator left")
|
||||
}
|
||||
if second.Username() != "" || len(second.Permissions()) != 0 {
|
||||
t.Fatal("cancelled waiting client retained its authenticated identity")
|
||||
}
|
||||
foundCancel := false
|
||||
for _, message := range drainMessages(second) {
|
||||
if message.Type == "waiting" && message.Kind == "cancel" {
|
||||
foundCancel = true
|
||||
}
|
||||
}
|
||||
if !foundCancel {
|
||||
t.Fatal("waiting client did not receive cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingAutomaticAdmissionRetriesInOrderAtCapacity(t *testing.T) {
|
||||
g, name := newWaitingTestHall(t, 2)
|
||||
operator := addTestWebClient(t, name, "Operator", "op")
|
||||
g.SetLocked(true, "")
|
||||
drainActions(t, operator)
|
||||
alice := joinWaitingTestClient(t, name, "Alice")
|
||||
bob := joinWaitingTestClient(t, name, "Bob")
|
||||
if err := updateWaitingPreference(alice, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := updateWaitingPreference(bob, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setHallLockState(g, false, "")
|
||||
drainActions(t, alice)
|
||||
drainActions(t, bob)
|
||||
if alice.hall != g || bob.hall != nil || bob.waiting == nil {
|
||||
t.Fatalf("unexpected first admission: alice=%v bob=%v bobWaiting=%v", alice.hall, bob.hall, bob.waiting)
|
||||
}
|
||||
leaveHall(alice)
|
||||
drainActions(t, bob)
|
||||
if bob.hall != g || bob.waiting != nil {
|
||||
t.Fatalf("capacity retry did not admit Bob: hall=%v waiting=%v", bob.hall, bob.waiting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingAdmissionRequiresOperator(t *testing.T) {
|
||||
g, name := newWaitingTestHall(t, 0)
|
||||
operator := addTestWebClient(t, name, "Operator", "op")
|
||||
g.SetLocked(true, "")
|
||||
drainActions(t, operator)
|
||||
alice := joinWaitingTestClient(t, name, "Alice")
|
||||
if err := admitWaitingClient(alice, "Alice"); err == nil {
|
||||
t.Fatal("waiting user admitted themself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingRequiresOperatorAndReservesUsername(t *testing.T) {
|
||||
g, name := newWaitingTestHall(t, 0)
|
||||
g.SetLocked(true, "")
|
||||
alice := joinWaitingTestClient(t, name, "Alice")
|
||||
if alice.waiting != nil {
|
||||
t.Fatal("client waited without an active operator")
|
||||
}
|
||||
foundFailure := false
|
||||
for _, message := range drainMessages(alice) {
|
||||
if message.Type == "joined" && message.Kind == "fail" {
|
||||
foundFailure = true
|
||||
}
|
||||
}
|
||||
if !foundFailure {
|
||||
t.Fatal("locked hall without an operator did not reject the join")
|
||||
}
|
||||
|
||||
operator := addTestWebClient(t, name, "Operator", "op")
|
||||
drainActions(t, operator)
|
||||
first := joinWaitingTestClient(t, name, "Alice")
|
||||
second := joinWaitingTestClient(t, name, "Alice")
|
||||
if first.waiting == nil || second.waiting != nil {
|
||||
t.Fatalf("username reservation failed: first=%v second=%v", first.waiting, second.waiting)
|
||||
}
|
||||
removeWaitingClient(first, true)
|
||||
third := joinWaitingTestClient(t, name, "Alice")
|
||||
if third.waiting == nil {
|
||||
t.Fatal("username was not released after waiting client departed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingRelockDisablesManualAdmission(t *testing.T) {
|
||||
g, name := newWaitingTestHall(t, 1)
|
||||
operator := addTestWebClient(t, name, "Operator", "op")
|
||||
g.SetLocked(true, "")
|
||||
drainActions(t, operator)
|
||||
alice := joinWaitingTestClient(t, name, "Alice")
|
||||
setHallLockState(g, false, "")
|
||||
setHallLockState(g, true, "")
|
||||
if err := requestWaitingAdmission(alice); err == nil {
|
||||
t.Fatal("manual admission succeeded after the hall was relocked")
|
||||
}
|
||||
if alice.hall != nil || alice.waiting == nil {
|
||||
t.Fatalf("relocked client left waiting state: hall=%v waiting=%v", alice.hall, alice.waiting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingManualJoinAfterOpening(t *testing.T) {
|
||||
g, name := newWaitingTestHall(t, 0)
|
||||
operator := addTestWebClient(t, name, "Operator", "op")
|
||||
g.SetLocked(true, "")
|
||||
drainActions(t, operator)
|
||||
alice := joinWaitingTestClient(t, name, "Alice")
|
||||
setHallLockState(g, false, "")
|
||||
if err := requestWaitingAdmission(alice); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drainActions(t, alice)
|
||||
if alice.hall != g || alice.waiting != nil {
|
||||
t.Fatalf("manual join did not complete: hall=%v waiting=%v", alice.hall, alice.waiting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingApprovalSurvivesCapacityAndRelock(t *testing.T) {
|
||||
g, name := newWaitingTestHall(t, 2)
|
||||
operator := addTestWebClient(t, name, "Operator", "op")
|
||||
g.SetLocked(true, "")
|
||||
drainActions(t, operator)
|
||||
alice := joinWaitingTestClient(t, name, "Alice")
|
||||
bob := joinWaitingTestClient(t, name, "Bob")
|
||||
if err := admitWaitingClient(operator, "Alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drainActions(t, alice)
|
||||
if alice.hall != g {
|
||||
t.Fatal("first approved user did not fill available capacity")
|
||||
}
|
||||
if err := admitWaitingClient(operator, "Bob"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bob.waiting == nil || !bob.waiting.approved {
|
||||
t.Fatal("approval was not retained while the hall was full")
|
||||
}
|
||||
setHallLockState(g, false, "")
|
||||
setHallLockState(g, true, "")
|
||||
leaveHall(alice)
|
||||
drainActions(t, bob)
|
||||
if bob.hall != g || bob.waiting != nil {
|
||||
t.Fatalf("approved user was not admitted after capacity opened: hall=%v waiting=%v", bob.hall, bob.waiting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingCancelledWhenLastOperatorLosesPermission(t *testing.T) {
|
||||
g, name := newWaitingTestHall(t, 0)
|
||||
operator := addTestWebClient(t, name, "Operator", "op")
|
||||
g.SetLocked(true, "")
|
||||
drainActions(t, operator)
|
||||
alice := joinWaitingTestClient(t, name, "Alice")
|
||||
if err := handleAction(operator, changePermissionsAction{kind: "unop"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Exercise the window before permissionsChangedAction is processed.
|
||||
WaitingCapacityChanged(g)
|
||||
drainActions(t, operator)
|
||||
drainActions(t, alice)
|
||||
if alice.waiting != nil {
|
||||
t.Fatal("waiting continued after the last operator lost permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingScheduleRemovedWhenIssuerLeaves(t *testing.T) {
|
||||
g, name := newWaitingTestHall(t, 0)
|
||||
issuer := addTestWebClient(t, name, "Operator", "op")
|
||||
other := addTestWebClient(t, name, "Operator2", "op")
|
||||
g.SetLocked(true, "")
|
||||
drainActions(t, issuer)
|
||||
drainActions(t, other)
|
||||
waiter := joinWaitingTestClient(t, name, "Alice")
|
||||
now := unlockNow()
|
||||
if err := scheduleHallUnlock(g, issuer, now.Add(10*time.Minute), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := scheduledUnlockDeadline(g); !ok {
|
||||
t.Fatal("scheduled deadline was not recorded")
|
||||
}
|
||||
leaveHall(issuer)
|
||||
if _, ok := scheduledUnlockDeadline(g); ok {
|
||||
t.Fatal("scheduled deadline remained after issuer departure")
|
||||
}
|
||||
if waiter.waiting == nil {
|
||||
t.Fatal("waiter was cancelled even though another operator remained")
|
||||
}
|
||||
}
|
||||
+96
-8
@@ -127,6 +127,8 @@ type webClient struct {
|
||||
writerDone chan struct{}
|
||||
actions *unbounded.Channel[any]
|
||||
permissionsMu sync.RWMutex
|
||||
identityMu sync.RWMutex
|
||||
waiting *waitingEntry
|
||||
|
||||
mu sync.Mutex
|
||||
down map[string]*rtpDownConnection
|
||||
@@ -146,11 +148,15 @@ func (c *webClient) Id() string {
|
||||
}
|
||||
|
||||
func (c *webClient) Username() string {
|
||||
c.identityMu.RLock()
|
||||
defer c.identityMu.RUnlock()
|
||||
return c.username
|
||||
}
|
||||
|
||||
func (c *webClient) SetUsername(username string) {
|
||||
c.identityMu.Lock()
|
||||
c.username = username
|
||||
c.identityMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *webClient) Permissions() []string {
|
||||
@@ -857,7 +863,7 @@ func readMessage(conn *websocket.Conn, m *clientMessage) error {
|
||||
}
|
||||
|
||||
const maxWSMessageSize = 1024 * 1024
|
||||
const protocolVersion = "2"
|
||||
const protocolVersion = "3"
|
||||
|
||||
func StartClient(conn *websocket.Conn, addr net.Addr) (err error) {
|
||||
var m clientMessage
|
||||
@@ -1187,6 +1193,9 @@ func handleAction(c *webClient, a any) error {
|
||||
}
|
||||
|
||||
case pushClientAction:
|
||||
// Admission may be running on another client's goroutine. Taking
|
||||
// the waiting lock is a barrier for c.hall and registry publication.
|
||||
waitingClientHall(c)
|
||||
if a.hall != c.hall.Name() {
|
||||
log.Printf("got client for wrong hall")
|
||||
return nil
|
||||
@@ -1202,6 +1211,7 @@ func handleAction(c *webClient, a any) error {
|
||||
Data: a.data,
|
||||
})
|
||||
case joinedAction:
|
||||
waitingClientHall(c)
|
||||
var status *hall.Status
|
||||
var data map[string]interface{}
|
||||
var g *hall.Hall
|
||||
@@ -1215,7 +1225,7 @@ func handleAction(c *webClient, a any) error {
|
||||
}
|
||||
}
|
||||
perms := append([]string(nil), c.permissions...)
|
||||
username := c.username
|
||||
username := c.Username()
|
||||
err := c.write(clientMessage{
|
||||
Type: "joined",
|
||||
Kind: a.kind,
|
||||
@@ -1295,9 +1305,14 @@ func handleAction(c *webClient, a any) error {
|
||||
return errors.New("Permissions changed in no hall")
|
||||
}
|
||||
perms := c.Permissions()
|
||||
if !member("op", perms) {
|
||||
cancelScheduledUnlockByIssuer(g, c)
|
||||
} else {
|
||||
waitingOperatorJoined(g, c)
|
||||
}
|
||||
status := g.Status(true, nil)
|
||||
status.Recording = hallRecording(g)
|
||||
username := c.username
|
||||
username := c.Username()
|
||||
c.write(clientMessage{
|
||||
Type: "joined",
|
||||
Kind: "change",
|
||||
@@ -1332,12 +1347,15 @@ func handleAction(c *webClient, a any) error {
|
||||
)
|
||||
}
|
||||
}(clients)
|
||||
waitingHallChanged(g)
|
||||
case kickAction:
|
||||
return hall.KickError{
|
||||
Id: a.id,
|
||||
Username: a.username,
|
||||
Message: a.message,
|
||||
}
|
||||
case waitingEventAction:
|
||||
return c.write(a.message)
|
||||
default:
|
||||
log.Printf("unexpected action %T", a)
|
||||
return errors.New("unexpected action")
|
||||
@@ -1365,6 +1383,11 @@ func failUpConnection(c *webClient, id string, message string) error {
|
||||
}
|
||||
|
||||
func leaveHall(c *webClient) {
|
||||
if removeWaitingClient(c, true) {
|
||||
c.SetPermissions(nil)
|
||||
c.SetUsername("")
|
||||
return
|
||||
}
|
||||
if c.hall == nil {
|
||||
return
|
||||
}
|
||||
@@ -1380,11 +1403,14 @@ func leaveHall(c *webClient) {
|
||||
}
|
||||
}
|
||||
|
||||
g := c.hall
|
||||
cancelScheduledUnlockByIssuer(g, c)
|
||||
hall.DelClient(c)
|
||||
c.SetPermissions(nil)
|
||||
c.data = nil
|
||||
c.requested = make(map[string][]string)
|
||||
c.hall = nil
|
||||
waitingHallChanged(g)
|
||||
}
|
||||
|
||||
func closeDownConn(c *webClient, id string, message string) error {
|
||||
@@ -1441,10 +1467,19 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if waitingClientHall(c) != nil && m.Type != "waiting" && m.Type != "join" && m.Type != "pong" {
|
||||
return c.error(hall.UserError("you have not been admitted to the hall"))
|
||||
}
|
||||
|
||||
switch m.Type {
|
||||
case "join":
|
||||
if m.Kind == "leave" {
|
||||
if waitingHall := waitingClientHall(c); waitingHall != nil && waitingHall.Name() == m.Hall {
|
||||
removeWaitingClient(c, true)
|
||||
c.SetUsername("")
|
||||
c.SetPermissions(nil)
|
||||
return c.write(clientMessage{Type: "waiting", Kind: "leave", Hall: m.Hall})
|
||||
}
|
||||
if c.hall == nil || c.hall.Name() != m.Hall {
|
||||
return hall.UserError("you are not joined")
|
||||
}
|
||||
@@ -1456,13 +1491,13 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
||||
return hall.ProtocolError("unknown kind")
|
||||
}
|
||||
|
||||
if c.hall != nil {
|
||||
if c.hall != nil || waitingClientHall(c) != nil {
|
||||
return hall.ProtocolError(
|
||||
"cannot join multiple halls",
|
||||
)
|
||||
}
|
||||
c.data = m.Data
|
||||
g, err := hall.AddClient(m.Hall, c,
|
||||
g, auth, err := hall.AuthenticateClient(m.Hall,
|
||||
hall.ClientCredentials{
|
||||
Username: m.Username,
|
||||
Password: m.Password,
|
||||
@@ -1490,7 +1525,7 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
||||
s = "internal server error"
|
||||
log.Printf("Join hall: %v", err)
|
||||
}
|
||||
username := c.username
|
||||
username := c.Username()
|
||||
return c.write(clientMessage{
|
||||
Type: "joined",
|
||||
Kind: "fail",
|
||||
@@ -1503,7 +1538,7 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
||||
if redirect := g.Description().Redirect; redirect != "" {
|
||||
// We normally redirect at the HTTP level, but the hall
|
||||
// description could have been edited in the meantime.
|
||||
username := c.username
|
||||
username := c.Username()
|
||||
return c.write(clientMessage{
|
||||
Type: "joined",
|
||||
Kind: "redirect",
|
||||
@@ -1512,7 +1547,60 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
||||
Value: redirect,
|
||||
})
|
||||
}
|
||||
if !member("op", auth.Permissions) {
|
||||
if err := addWaitingClient(c, g, auth); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, errWaitingUnavailable) {
|
||||
return c.write(clientMessage{
|
||||
Type: "joined", Kind: "fail", Hall: m.Hall,
|
||||
Username: &auth.Username, Value: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := hall.AddAuthenticatedClient(g, c, auth, false); err != nil {
|
||||
username := auth.Username
|
||||
return c.write(clientMessage{
|
||||
Type: "joined", Kind: "fail", Hall: m.Hall,
|
||||
Username: &username, Value: err.Error(),
|
||||
})
|
||||
}
|
||||
c.hall = g
|
||||
waitingOperatorJoined(g, c)
|
||||
case "waiting":
|
||||
switch m.Kind {
|
||||
case "auto":
|
||||
autoJoin, ok := m.Value.(bool)
|
||||
if !ok {
|
||||
return c.error(hall.UserError("invalid automatic join preference"))
|
||||
}
|
||||
if err := updateWaitingPreference(c, autoJoin); err != nil {
|
||||
return c.error(err)
|
||||
}
|
||||
return nil
|
||||
case "join":
|
||||
if err := requestWaitingAdmission(c); err != nil {
|
||||
return c.error(err)
|
||||
}
|
||||
return nil
|
||||
case "leave":
|
||||
if !removeWaitingClient(c, true) {
|
||||
return c.error(hall.UserError("you are not in a waiting room"))
|
||||
}
|
||||
c.SetUsername("")
|
||||
c.SetPermissions(nil)
|
||||
return c.write(clientMessage{Type: "waiting", Kind: "leave"})
|
||||
case "admit":
|
||||
username, ok := m.Value.(string)
|
||||
if !ok || username == "" {
|
||||
return c.error(hall.UserError("invalid waiting username"))
|
||||
}
|
||||
if err := admitWaitingClient(c, username); err != nil {
|
||||
return c.error(err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return hall.ProtocolError("unknown waiting action")
|
||||
}
|
||||
case "request":
|
||||
requested, err := parseRequested(m.Request)
|
||||
if err != nil {
|
||||
@@ -1877,7 +1965,7 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
||||
}
|
||||
}
|
||||
|
||||
user := c.username
|
||||
user := c.Username()
|
||||
if user != "" {
|
||||
tok.IssuedBy = &user
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ func (c *WhipClient) Close() error {
|
||||
c.connection = nil
|
||||
}
|
||||
hall.DelClient(c)
|
||||
WaitingCapacityChanged(g)
|
||||
c.hall = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -115,6 +115,15 @@ serverConnection.onjoined = function(kind, hall, perms, status, data, error, mes
|
||||
Once you have joined a hall, you send chat messages with the `chat`
|
||||
method of the `ServerConnection` class. No permission is needed to do that.
|
||||
|
||||
If authentication succeeds but admission is delayed by a locked hall,
|
||||
`onwaiting(kind, state)` reports waiting-room entry and state changes. Use
|
||||
`waitingAction('auto', boolean)`, `waitingAction('join')`, or
|
||||
`waitingAction('leave')` to change the automatic-entry preference, request
|
||||
entry after opening, or leave. Operators receive ordered waiting-list changes
|
||||
through `onwaitinglist(kind, value)` and admit a username with
|
||||
`waitingAction('admit', username)`. Waiting connections are not hall members,
|
||||
so hall user, chat, history, and media callbacks do not run for them.
|
||||
|
||||
```javascript
|
||||
serverConnection.chat(username, '', id, 'Hi!');
|
||||
```
|
||||
|
||||
+51
-2
@@ -95,7 +95,7 @@ start pipelining messages to the server.
|
||||
```javascript
|
||||
{
|
||||
type: 'handshake',
|
||||
version: ["2"],
|
||||
version: ["3"],
|
||||
id: id
|
||||
}
|
||||
```
|
||||
@@ -105,6 +105,8 @@ decreasing preference order; the client may announce multiple versions,
|
||||
but the server will always reply with a single version. If the field `id`
|
||||
is absent, then the peer doesn't originate streams.
|
||||
|
||||
Protocol version 3 adds authenticated waiting-room messages and actions.
|
||||
|
||||
A peer may, at any time, send a `ping` message.
|
||||
|
||||
```javascript
|
||||
@@ -136,7 +138,7 @@ The `join` message requests that the sender join or leave a hall:
|
||||
}
|
||||
```
|
||||
|
||||
If token-based authorisation is beling used, then the `username` and
|
||||
If token-based authorisation is being used, then the `username` and
|
||||
`password` fields are omitted, and a `token` field is included instead.
|
||||
|
||||
When the sender has effectively joined the hall, the peer will send
|
||||
@@ -164,6 +166,53 @@ values `present`, `op` and `record`. The `status` field is a dictionary
|
||||
that contains status information about the hall, and updates the data
|
||||
obtained from the `.status` URL described above.
|
||||
|
||||
### Waiting-room authentication and admission
|
||||
|
||||
When a non-operator authenticates while the hall is locked and an operator
|
||||
is present, authentication succeeds without hall membership. The server
|
||||
sends a `waiting` message of kind `enter`; subsequent locked-state changes use
|
||||
kind `update`, and opening uses kind `open`. The value contains the internal hall name, display name,
|
||||
authenticated username, lock state, and an optional RFC 3339 deadline:
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'waiting',
|
||||
kind: 'enter' or 'update' or 'open' or 'approved',
|
||||
value: {
|
||||
hall: hall,
|
||||
displayName: displayName,
|
||||
username: username,
|
||||
locked: true,
|
||||
deadline: optionalDeadline
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`cancel` ends waiting with its reason in `value`; `leave` confirms a
|
||||
voluntary departure. A waiting client changes its automatic-join preference,
|
||||
requests entry after opening, or leaves with:
|
||||
|
||||
```javascript
|
||||
{type: 'waiting', kind: 'auto', value: trueOrFalse}
|
||||
{type: 'waiting', kind: 'join'}
|
||||
{type: 'waiting', kind: 'leave'}
|
||||
```
|
||||
|
||||
Waiting clients are authenticated but are not hall members. They receive no
|
||||
user list, chat or chat history, media, chalkboard, or recording state.
|
||||
|
||||
Operators receive `waiting-list` messages. `snapshot` has an array of
|
||||
`{username, approved}` values; `add` and `remove` contain one such value.
|
||||
An operator admits one user with:
|
||||
|
||||
```javascript
|
||||
{type: 'waiting', kind: 'admit', value: username}
|
||||
```
|
||||
|
||||
Admission approval bypasses only the hall lock. Authentication, hall time
|
||||
restrictions, and capacity still apply. An approval that cannot fit remains
|
||||
queued and is retried when capacity becomes available.
|
||||
|
||||
## Maintaining hall membership
|
||||
|
||||
Whenever a user joins or leaves a hall, the server will send all other
|
||||
|
||||
@@ -30,7 +30,26 @@ of a user list, chat, and connection controls:
|
||||
hall; every username doubles as a menu of hall and user actions;
|
||||
- the chat pane contains the messages published to the hall;
|
||||
- the controls area contains login, connection, microphone, and settings
|
||||
controls.
|
||||
controls.
|
||||
|
||||
### Waiting for a locked hall
|
||||
|
||||
If a non-operator signs in while a hall is locked and an operator is present,
|
||||
Skald opens a waiting room. The waiting page names the hall and shows either
|
||||
the scheduled unlock time remaining, rounded to minutes, or states that an
|
||||
operator is present without an unlock time. Waiting users cannot hear hall
|
||||
audio or access its participants, chat, history, or recording state.
|
||||
|
||||
The *Join hall automatically when it unlocks* checkbox starts unchecked on
|
||||
every visit. If selected, Skald joins in waiting-room arrival order when the
|
||||
hall opens and space is available. Otherwise, the *Join hall* button becomes
|
||||
available when the hall opens. *Leave waiting room* returns to sign-in.
|
||||
|
||||
While a hall is locked, operators see a *Waiting room (count)* panel beneath
|
||||
the participant list. Each waiting username has an *Admit* button. Admission
|
||||
approval remains pending if the hall is full and completes when space becomes
|
||||
available. Unlocking the hall allows all waiting users to enter according to
|
||||
their automatic or manual choice.
|
||||
|
||||
On mobile, the interface is collapsed to fit the smaller screen. The top
|
||||
controls expose the user list, chat, and settings.
|
||||
|
||||
+29
-3
@@ -201,6 +201,10 @@ function ServerConnection() {
|
||||
* @type{(this: ServerConnection, kind: string, hall: string, permissions: Array<string>, status: Object<string,any>, data: Object<string,any>, error: string, message: string) => void}
|
||||
*/
|
||||
this.onjoined = null;
|
||||
/** Called for authenticated waiting-room state changes. */
|
||||
this.onwaiting = null;
|
||||
/** Called for operator-only waiting-list changes. */
|
||||
this.onwaitinglist = null;
|
||||
/**
|
||||
* ondownstream is called whenever a new down stream is added. It
|
||||
* should set up the stream's callbacks; actually setting up the UI
|
||||
@@ -342,7 +346,7 @@ ServerConnection.prototype.connect = function(url) {
|
||||
try {
|
||||
sc.send({
|
||||
type: 'handshake',
|
||||
version: ['2'],
|
||||
version: ['3'],
|
||||
id: sc.id,
|
||||
});
|
||||
} catch(e) {
|
||||
@@ -391,8 +395,8 @@ ServerConnection.prototype.connect = function(url) {
|
||||
sc.lastServerMessage = new Date().valueOf();
|
||||
switch(m.type) {
|
||||
case 'handshake': {
|
||||
if((m.version instanceof Array) && m.version.includes('2')) {
|
||||
sc.version = '2';
|
||||
if((m.version instanceof Array) && m.version.includes('3')) {
|
||||
sc.version = '3';
|
||||
} else {
|
||||
sc.version = null;
|
||||
sc.error(new Error(`Unknown protocol version ${m.version}`));
|
||||
@@ -449,6 +453,21 @@ ServerConnection.prototype.connect = function(url) {
|
||||
m.status, m.data,
|
||||
m.error || null, m.value || null);
|
||||
break;
|
||||
case 'waiting':
|
||||
if(m.kind === 'enter' && m.value) {
|
||||
sc.username = m.value.username;
|
||||
sc.permissions = [];
|
||||
} else if(m.kind === 'leave' || m.kind === 'cancel') {
|
||||
sc.username = null;
|
||||
sc.permissions = [];
|
||||
}
|
||||
if(sc.onwaiting)
|
||||
sc.onwaiting.call(sc, m.kind, m.value || null);
|
||||
break;
|
||||
case 'waiting-list':
|
||||
if(sc.onwaitinglist)
|
||||
sc.onwaitinglist.call(sc, m.kind, m.value || null);
|
||||
break;
|
||||
case 'user':
|
||||
let user = null;
|
||||
switch(m.kind) {
|
||||
@@ -651,6 +670,13 @@ ServerConnection.prototype.leave = function(hall) {
|
||||
});
|
||||
};
|
||||
|
||||
ServerConnection.prototype.waitingAction = function(kind, value) {
|
||||
let message = {type: 'waiting', kind: kind};
|
||||
if(typeof value !== 'undefined')
|
||||
message.value = value;
|
||||
this.send(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* request sets the list of requested tracks
|
||||
*
|
||||
|
||||
+26
-1
@@ -982,6 +982,8 @@ legend {
|
||||
#left-sidebar {
|
||||
min-width: 200px;
|
||||
max-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #dcdcdc;
|
||||
@@ -1035,7 +1037,8 @@ header .collapse:hover {
|
||||
#users {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: calc(100% - 84px);
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: block;
|
||||
@@ -1044,6 +1047,28 @@ header .collapse:hover {
|
||||
border: 1px solid #f7f7f7;
|
||||
}
|
||||
|
||||
#waiting-list-panel {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid #ddd;
|
||||
background: #fff;
|
||||
max-height: 40%;
|
||||
overflow-y: auto;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
#waiting-list-panel h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.waiting-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
#users .user-p {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
</div>
|
||||
<div class="header-sep"></div>
|
||||
<div id="users"></div>
|
||||
<section id="waiting-list-panel" class="invisible" aria-labelledby="waiting-list-heading">
|
||||
<h2 id="waiting-list-heading">Waiting room (0)</h2>
|
||||
<div id="waiting-list"></div>
|
||||
</section>
|
||||
</nav>
|
||||
<div class="container">
|
||||
<header class="connected-only invisible" aria-hidden="true">
|
||||
@@ -105,6 +109,18 @@
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
</div>
|
||||
<section id="waiting-room" class="login-container invisible" aria-labelledby="waiting-heading">
|
||||
<div class="login-box">
|
||||
<h1 id="waiting-heading" tabindex="-1">Waiting room</h1>
|
||||
<p id="waiting-status"></p>
|
||||
<p>
|
||||
<input id="waiting-auto-join" type="checkbox"/>
|
||||
<label for="waiting-auto-join">Join hall automatically when it unlocks</label>
|
||||
</p>
|
||||
<p><button id="waiting-join" type="button" class="btn btn-blue" disabled>Join hall</button></p>
|
||||
<p><button id="waiting-leave" type="button" class="btn btn-default">Leave waiting room</button></p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+158
-1
@@ -71,6 +71,10 @@ let token = null;
|
||||
*/
|
||||
let probingState = null;
|
||||
|
||||
let waitingState = null;
|
||||
let waitingCountdownTimer = null;
|
||||
let waitingUsers = new Map();
|
||||
|
||||
/**
|
||||
* @typedef {Object} settings - the type of stored settings
|
||||
* @property {boolean} [localMute]
|
||||
@@ -516,12 +520,138 @@ function setConnected(connected) {
|
||||
} else {
|
||||
closeNav();
|
||||
userbox.classList.add('invisible');
|
||||
connectionbox.classList.remove('invisible');
|
||||
if(!waitingState)
|
||||
connectionbox.classList.remove('invisible');
|
||||
;
|
||||
window.onresize = null;
|
||||
}
|
||||
}
|
||||
|
||||
function waitingHallName(state = waitingState) {
|
||||
return state && state.displayName ? state.displayName :
|
||||
(hallStatus.displayName || capitalise(hall));
|
||||
}
|
||||
|
||||
function updateWaitingStatus(announceSchedule = false) {
|
||||
if(!waitingState)
|
||||
return;
|
||||
let name = waitingHallName();
|
||||
let status;
|
||||
if(!waitingState.locked) {
|
||||
status = `The ${name} hall is open. You may join now.`;
|
||||
} else if(waitingState.deadline) {
|
||||
let remaining = Math.max(1, Math.ceil(
|
||||
(new Date(waitingState.deadline).getTime() - Date.now()) / 60000,
|
||||
));
|
||||
status = `The ${name} hall is locked. It is scheduled to unlock in ${remaining} minute${remaining === 1 ? '' : 's'}.`;
|
||||
} else {
|
||||
status = `The ${name} hall is locked. An operator is present; no unlock time is set.`;
|
||||
}
|
||||
document.getElementById('waiting-status').textContent = status;
|
||||
getButtonElement('waiting-join').disabled = !!waitingState.locked;
|
||||
if(announceSchedule)
|
||||
announceChat(status);
|
||||
}
|
||||
|
||||
function showWaitingRoom(state) {
|
||||
waitingState = state;
|
||||
token = null;
|
||||
getInputElement('waiting-auto-join').checked = false;
|
||||
document.getElementById('login-container').classList.add('invisible');
|
||||
document.getElementById('waiting-room').classList.remove('invisible');
|
||||
document.getElementById('waiting-heading').textContent =
|
||||
`Waiting for the ${waitingHallName(state)} hall`;
|
||||
updateWaitingStatus(false);
|
||||
if(waitingCountdownTimer)
|
||||
clearInterval(waitingCountdownTimer);
|
||||
waitingCountdownTimer = setInterval(() => updateWaitingStatus(false), 30000);
|
||||
document.getElementById('waiting-heading').focus({preventScroll: true});
|
||||
announceChat(`You are in the waiting room for the ${waitingHallName(state)} hall.`);
|
||||
}
|
||||
|
||||
function hideWaitingRoom() {
|
||||
if(waitingCountdownTimer) {
|
||||
clearInterval(waitingCountdownTimer);
|
||||
waitingCountdownTimer = null;
|
||||
}
|
||||
waitingState = null;
|
||||
document.getElementById('waiting-room').classList.add('invisible');
|
||||
}
|
||||
|
||||
function gotWaiting(kind, state) {
|
||||
switch(kind) {
|
||||
case 'enter':
|
||||
showWaitingRoom(state);
|
||||
break;
|
||||
case 'update':
|
||||
case 'open': {
|
||||
let oldDeadline = waitingState && waitingState.deadline;
|
||||
let wasLocked = waitingState && waitingState.locked;
|
||||
waitingState = state;
|
||||
updateWaitingStatus(oldDeadline !== state.deadline || wasLocked !== state.locked);
|
||||
break;
|
||||
}
|
||||
case 'approved':
|
||||
waitingState = state;
|
||||
announceChat(`An operator admitted you to the ${waitingHallName(state)} hall.`);
|
||||
break;
|
||||
case 'cancel':
|
||||
hideWaitingRoom();
|
||||
announceUrgent(state || 'Waiting ended.');
|
||||
this.close();
|
||||
break;
|
||||
case 'leave':
|
||||
hideWaitingRoom();
|
||||
this.close();
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown waiting-room event ${kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderWaitingList() {
|
||||
let panel = document.getElementById('waiting-list-panel');
|
||||
let list = document.getElementById('waiting-list');
|
||||
let isOperator = serverConnection &&
|
||||
serverConnection.permissions.indexOf('op') >= 0;
|
||||
let visible = isOperator && !!hallStatus.locked;
|
||||
panel.classList.toggle('invisible', !visible);
|
||||
document.getElementById('waiting-list-heading').textContent =
|
||||
`Waiting room (${waitingUsers.size})`;
|
||||
list.replaceChildren();
|
||||
if(!visible)
|
||||
return;
|
||||
for(let user of waitingUsers.values()) {
|
||||
let row = document.createElement('div');
|
||||
row.className = 'waiting-user';
|
||||
let name = document.createElement('span');
|
||||
name.textContent = user.username + (user.approved ? ' (admission approved)' : '');
|
||||
let button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = user.approved ? 'Approved' : 'Admit';
|
||||
button.disabled = !!user.approved;
|
||||
button.setAttribute('aria-label', `Admit ${user.username}`);
|
||||
button.onclick = () => serverConnection.waitingAction('admit', user.username);
|
||||
row.append(name, button);
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function gotWaitingList(kind, value) {
|
||||
if(kind === 'snapshot') {
|
||||
waitingUsers.clear();
|
||||
for(let user of value || [])
|
||||
waitingUsers.set(user.username, user);
|
||||
} else if(kind === 'add' && value) {
|
||||
waitingUsers.set(value.username, value);
|
||||
announceChat(`${value.username} entered the waiting room.`);
|
||||
} else if(kind === 'remove' && value) {
|
||||
waitingUsers.delete(value.username);
|
||||
announceChat(`${value.username} left the waiting room.`);
|
||||
}
|
||||
renderWaitingList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when we connect to the server.
|
||||
*
|
||||
@@ -635,6 +765,7 @@ function onPeerConnection() {
|
||||
function gotClose(code, reason) {
|
||||
closeUpMedia();
|
||||
closeSafariStream();
|
||||
hideWaitingRoom();
|
||||
setConnected(false);
|
||||
resetChalkboard();
|
||||
if(code != 1000) {
|
||||
@@ -2211,6 +2342,9 @@ async function gotJoined(kind, hall, perms, status, data, error, message) {
|
||||
} else {
|
||||
token = null;
|
||||
}
|
||||
if(kind === 'join' && waitingState)
|
||||
announceChat(`Joining the ${waitingHallName()} hall.`);
|
||||
hideWaitingRoom();
|
||||
// don't discard endPoint and friends
|
||||
hallStatus.locked = false;
|
||||
hallStatus.recording = false;
|
||||
@@ -2225,6 +2359,7 @@ async function gotJoined(kind, hall, perms, status, data, error, message) {
|
||||
setChangePassword(pwAuth && !!hallStatus.canChangePassword &&
|
||||
serverConnection.username
|
||||
);
|
||||
renderWaitingList();
|
||||
openSafariStream();
|
||||
window.setTimeout(() => {
|
||||
userNotificationSoundsReady = true;
|
||||
@@ -2273,6 +2408,14 @@ async function gotJoined(kind, hall, perms, status, data, error, message) {
|
||||
);
|
||||
}
|
||||
}
|
||||
let displayName = hallStatus.displayName || capitalise(hall);
|
||||
let microphone = findUpMedia('audio');
|
||||
if(!microphone) {
|
||||
announceChat(`Joined the ${displayName} hall. Your microphone is not enabled.`);
|
||||
} else {
|
||||
let enabled = microphone.stream && microphone.stream.getAudioTracks().some(track => track.enabled);
|
||||
announceChat(`Joined the ${displayName} hall. Your microphone is ${enabled ? 'unmuted' : 'muted'}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4069,6 +4212,18 @@ document.getElementById('disconnectbutton').onclick = function(e) {
|
||||
closeNav();
|
||||
};
|
||||
|
||||
getInputElement('waiting-auto-join').onchange = function() {
|
||||
serverConnection.waitingAction('auto', this.checked);
|
||||
};
|
||||
|
||||
getButtonElement('waiting-join').onclick = function() {
|
||||
serverConnection.waitingAction('join');
|
||||
};
|
||||
|
||||
getButtonElement('waiting-leave').onclick = function() {
|
||||
serverConnection.waitingAction('leave');
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} elt
|
||||
* @param {boolean} hidden
|
||||
@@ -4186,6 +4341,8 @@ async function serverConnect() {
|
||||
serverConnection.ondownstream = gotDownStream;
|
||||
serverConnection.onuser = gotUser;
|
||||
serverConnection.onjoined = gotJoined;
|
||||
serverConnection.onwaiting = gotWaiting;
|
||||
serverConnection.onwaitinglist = gotWaitingList;
|
||||
serverConnection.onchat = addToChatbox;
|
||||
serverConnection.onusermessage = gotUserMessage;
|
||||
serverConnection.onfiletransfer = gotFileTransfer;
|
||||
|
||||
@@ -145,6 +145,26 @@ func TestMicrophoneHardStopLivesInSelfMenu(t *testing.T) {
|
||||
requireFunctionContains(t, js, "userMenu", "Restart audio connection")
|
||||
}
|
||||
|
||||
func TestWaitingRoomUsesAccessibleNativeControlsAndLiveRegions(t *testing.T) {
|
||||
html := readStaticFile(t, "skald.html")
|
||||
js := readStaticFile(t, "skald.js")
|
||||
|
||||
requireContains(t, html, `aria-labelledby="waiting-heading"`, "waiting room")
|
||||
requireContains(t, html, `id="waiting-heading" tabindex="-1"`, "waiting heading")
|
||||
requireContains(t, html, `label for="waiting-auto-join"`, "automatic join label")
|
||||
requireContains(t, html, `id="waiting-join" type="button"`, "join button")
|
||||
requireContains(t, html, `id="waiting-leave" type="button"`, "leave button")
|
||||
requireContains(t, html, `aria-labelledby="waiting-list-heading"`, "operator waiting list")
|
||||
requireFunctionContains(t, js, "showWaitingRoom", "waiting-heading').focus")
|
||||
requireFunctionContains(t, js, "updateWaitingStatus", "Math.ceil")
|
||||
requireFunctionContains(t, js, "updateWaitingStatus", "announceChat(status)")
|
||||
requireFunctionContains(t, js, "gotWaiting", "announceUrgent")
|
||||
requireFunctionContains(t, js, "renderWaitingList", "document.createElement('button')")
|
||||
requireFunctionContains(t, js, "renderWaitingList", "button.type = 'button'")
|
||||
requireFunctionContains(t, js, "gotJoined", "Your microphone is not enabled")
|
||||
requireFunctionContains(t, js, "gotJoined", "Your microphone is ${enabled ? 'unmuted' : 'muted'}")
|
||||
}
|
||||
|
||||
func TestRecordingControlLivesInSelfMenu(t *testing.T) {
|
||||
js := readStaticFile(t, "skald.js")
|
||||
|
||||
|
||||
@@ -230,6 +230,7 @@ func whipEndpointHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if !canPresent(c.Permissions()) {
|
||||
hall.DelClient(c)
|
||||
rtpconn.WaitingCapacityChanged(g)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -239,6 +240,7 @@ func whipEndpointHandler(w http.ResponseWriter, r *http.Request) {
|
||||
answer, err := c.NewConnection(r.Context(), body)
|
||||
if err != nil {
|
||||
hall.DelClient(c)
|
||||
rtpconn.WaitingCapacityChanged(g)
|
||||
log.Printf("WHIP offer: %v", err)
|
||||
httpError(w, err)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user