Files
barnard/client.go
T

649 lines
19 KiB
Go

package main
import (
"fmt"
"net"
"strings"
"time"
"git.stormux.org/storm/barnard/fileplayback"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/gumble/gumbleopenal"
"git.stormux.org/storm/barnard/gumble/gumbleutil"
"git.stormux.org/storm/barnard/gumble/opus"
)
func (b *Barnard) start() {
b.reconnectStop = make(chan struct{})
b.Config.Attach(gumbleutil.AutoBitrate)
b.Config.Attach(b)
b.Config.Address = b.Address
if b.ToneTest {
// Tone test mode: skip all OpenAL/soundcard initialization.
// We just need a network connection — the tone generator and
// file saver are set up in connect().
} else {
// test Audio
_, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), true)
if err != nil {
b.exitWithError(err)
return
}
}
//connect, not reconnect
b.connect(false)
}
func (b *Barnard) toneTestAutoTransmit() bool {
return b.ToneTest && b.AutoTransmit
}
func (b *Barnard) exitWithError(err error) {
b.Ui.Close()
b.exitStatus = 1
b.exitMessage = err.Error()
}
func (b *Barnard) connect(reconnect bool) bool {
var err error
_, err = gumble.DialWithDialer(&net.Dialer{Timeout: 15 * time.Second}, b.Config, &b.TLSConfig)
if err != nil {
if reconnect {
b.Log(err.Error())
} else {
b.exitWithError(err)
}
return false
}
if b.ToneTest {
// --- Tone test mode: skip all OpenAL; generate 440 Hz tone
// --- and save incoming audio to a file.
// The output is reserved exclusively, so it can only be opened once.
// A reconnect keeps writing to the saver opened for the first
// connection instead of failing on the file that already exists.
if b.toneTestSaver == nil {
// Open the output first. Starting transmission before this
// succeeds leaves an orphaned tone goroutine when the path is
// unusable.
saver, err := NewAudioFileSaver(b.ToneTestOutput)
if err != nil {
b.exitWithError(err)
return false
}
b.toneTestSaver = saver
}
// Detach any registration left over from the previous connection so
// the shared audio listener list does not grow once per reconnect.
b.detachToneTestAudio()
b.toneTestSaverDetach = b.Client.Config.AttachAudio(b.toneTestSaver)
b.setConnected(true)
if b.shouldStartTransmission() {
b.toneTestStop = make(chan struct{})
go StartToneGenerator(b.Client, b.toneTestStop)
b.setTransmitting(true)
b.UpdateGeneralStatus(" Tx ", true)
b.AddOutputLine("Tone test transmission started")
}
return true
}
stream, err := gumbleopenal.New(b.Client, b.UserConfig.GetInputDevice(), b.UserConfig.GetOutputDevice(), false)
if err != nil {
b.exitWithError(err)
return false
}
stream.SetMicVolume(b.UserConfig.GetMicVolume(), false)
stream.AttachStream(b.Client)
stream.SetNoiseProcessor(b.NoiseSuppressor)
stream.SetAGCEnabled(b.UserConfig.GetAGCEnabled())
stream.SetErrorFunc(func(err error) {
if err != nil {
b.AddOutputLine(fmt.Sprintf("Microphone: %s", err.Error()))
} else {
b.AddOutputLine("Microphone: recovered")
}
})
// Initialize stereo encoder for file playback, reusing the one built for
// the previous connection rather than allocating another.
if b.stereoEncoder == nil {
b.stereoEncoder = opus.NewStereoEncoder()
}
b.Client.SetStereoEncoder(b.stereoEncoder)
// Initialize file player
b.FileStreamMutex.Lock()
previousFile := b.FileStream
b.FileStream = fileplayback.New(b.Client)
b.FileStream.SetErrorFunc(func(err error) {
// Disable stereo when file finishes or errors
b.Client.DisableStereoEncoder()
b.AddOutputLine(fmt.Sprintf("File playback: %s", err.Error()))
})
stream.SetFilePlayer(b.FileStream)
b.FileStreamMutex.Unlock()
b.connectionMutex.Lock()
previousStream := b.Stream
b.Stream = stream
b.connectionMutex.Unlock()
// A disconnect that lands while the OpenAL devices are opening starts a
// second reconnect, so two connects can race to install a stream. The one
// that loses must be released here: an orphaned stream keeps its OpenAL
// device, its render thread and — because only Destroy detaches it — its
// entry in the shared audio listener list, so every later audio packet
// from every user is dispatched to it as well, for the life of the
// process. Release outside the locks, since Destroy waits on the
// per-user audio goroutines.
if previousFile != nil {
_ = previousFile.Stop()
}
if previousStream != nil {
previousStream.Destroy()
}
b.setConnected(true)
// Dial delivers OnConnect before connect creates the OpenAL stream, so
// start auto-transmit here as well for initial connections and reconnects.
b.startConfiguredTransmission()
return true
}
func (b *Barnard) OnConnect(e *gumble.ConnectEvent) {
b.Client = e.Client
// Reset muted channels state on connect
b.MutedChannelsMutex.Lock()
b.MutedChannels = make(map[uint32]bool)
b.MutedChannelsMutex.Unlock()
b.userChannels = make(map[uint32]*gumble.Channel)
b.RecordingMutex.Lock()
b.recordingAllowed = nil
b.recordingStarting = false
b.RecordingMutex.Unlock()
b.postUI(func() {
b.Ui.SetActive(uiViewInput)
b.UiTree.Rebuild()
b.Ui.Refresh()
})
var users []*gumble.User
b.Client.Do(func() {
users = make([]*gumble.User, 0, len(b.Client.Users))
for _, u := range b.Client.Users {
users = append(users, u)
}
})
for _, u := range users {
b.UserConfig.UpdateUser(u)
b.rememberUserChannel(u)
}
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Client.Self.Channel.Name))
b.AddOutputLine(fmt.Sprintf("Connected to %s", b.Client.Conn.RemoteAddr()))
wmsg := ""
if e.WelcomeMessage != nil {
wmsg = esc(*e.WelcomeMessage)
}
b.Notify("connect", "me", wmsg)
if wmsg != "" {
b.AddOutputLine(fmt.Sprintf("Welcome message: %s", wmsg))
}
b.startConfiguredTransmission()
}
func (b *Barnard) shouldStartTransmission() bool {
return b.AutoTransmit || (b.UserConfig.GetRememberTransmissionState() && b.UserConfig.GetTransmissionActive())
}
func (b *Barnard) startConfiguredTransmission() {
if !b.shouldStartTransmission() || b.isTransmitting() {
return
}
started := b.withStream(func(stream *gumbleopenal.Stream) {
if err := stream.StartSource(b.UserConfig.GetInputDevice()); err != nil {
b.AddOutputLine(fmt.Sprintf("auto-transmit failed: %s", err.Error()))
return
}
b.setTransmitting(true)
if b.AutoTransmit {
b.UpdateGeneralStatus(" AutoTx ", true)
b.AddOutputLine("Auto-transmit started")
} else {
b.UpdateGeneralStatus(" Tx ", true)
b.AddOutputLine("Transmission state restored")
}
})
if !started {
return
}
}
func (b *Barnard) OnDisconnect(e *gumble.DisconnectEvent) {
var reason string
switch e.Type {
case gumble.DisconnectError:
reason = "connection error"
case gumble.DisconnectKicked:
reason = "kicked"
case gumble.DisconnectBanned:
reason = "banned"
}
if e.String != "" {
reason = e.String
}
b.stopRecordingForDisconnect()
b.cleanupConnectionAudio()
// Tone test cleanup
if b.ToneTest {
if b.toneTestStop != nil {
close(b.toneTestStop)
b.toneTestStop = nil
}
// Keep the saver's output open: it was reserved exclusively and a
// reconnect re-attaches the same file. It is closed on exit.
b.detachToneTestAudio()
}
b.Notify("disconnect", "me", reason)
if reason == "" {
b.AddOutputLine("Disconnected")
} else {
b.AddOutputLine("Disconnected: " + reason)
}
b.clearTransmittingForDisconnect()
b.setConnected(false)
b.postUI(func() {
b.UiTree.Rebuild()
b.Ui.Refresh()
})
b.startReconnect()
}
// startReconnect launches the reconnect loop unless one is already running.
// Disconnect notifications can arrive more than once for a connection, and
// every extra loop is another connect racing to install its own audio stream.
func (b *Barnard) startReconnect() {
b.reconnectMutex.Lock()
defer b.reconnectMutex.Unlock()
if b.reconnecting {
return
}
b.reconnecting = true
go func() {
defer func() {
b.reconnectMutex.Lock()
b.reconnecting = false
b.reconnectMutex.Unlock()
}()
b.reconnectGoroutine()
}()
}
func (b *Barnard) reconnectGoroutine() {
for !b.reconnectCanceled() {
if b.connect(true) {
return
}
select {
case <-b.reconnectStop:
return
case <-time.After(15 * time.Second):
}
}
}
func (b *Barnard) Log(s string) {
b.AddOutputMessage(nil, s)
}
func (b *Barnard) OnTextMessage(e *gumble.TextMessageEvent) {
if b.isPublicTextMessage(e) {
sender := "Server"
if e.Sender != nil {
sender = e.Sender.Name
}
b.Notify("msg", sender, e.Message)
b.AddOutputMessage(e.Sender, e.Message)
} else {
var sender string
if e.Sender == nil {
sender = "Server"
} else {
sender = e.Sender.Name
}
b.Notify("pm", sender, e.Message)
b.AddOutputPrivateMessage(e.Sender, b.Client.Self, e.Message)
}
}
// isPublicTextMessage reports whether a message targets the current channel,
// either directly or through a recursive channel-tree recipient.
func (b *Barnard) isPublicTextMessage(e *gumble.TextMessageEvent) bool {
if e == nil || b.Client == nil || b.Client.Self == nil || b.Client.Self.Channel == nil {
return false
}
current := b.Client.Self.Channel
for _, channel := range e.Channels {
if sameChannel(channel, current) {
return true
}
}
for _, root := range e.Trees {
for channel := current; channel != nil; channel = channel.Parent {
if sameChannel(channel, root) {
return true
}
}
}
return false
}
func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) {
notification, hasNotification := b.userChangeNotification(e)
audioNotification, hasAudioNotification := b.userAudioChangeNotification(e)
if e.User != nil {
b.UserConfig.UpdateUser(e.User)
// Check if user is joining a muted channel
if e.Type.Has(gumble.UserChangeConnected) || e.Type.Has(gumble.UserChangeChannel) {
// If the channel is muted, ensure the user is muted
if b.isChannelMuted(e.User.Channel.ID) {
// Only mute if not already muted
if !e.User.LocallyMuted() {
if err := b.UserConfig.ToggleMute(e.User); err != nil {
b.AddOutputLine("Mute: could not save setting: " + err.Error())
}
}
b.updateUserGain(e.User)
}
}
}
if e.Type.Has(gumble.UserChangeDisconnected) {
if e.User == b.selectedUserValue() {
b.SetSelectedUser(nil)
}
}
if hasNotification {
b.Notify(notification.event, notification.who, notification.what)
b.AddOutputLine(notification.line)
}
if hasAudioNotification {
b.Notify(audioNotification.event, audioNotification.who, audioNotification.what)
b.AddOutputLine(audioNotification.line)
}
b.restoreTransmissionAfterUndeafen(e)
if e.Type.Has(gumble.UserChangeChannel) && e.User == b.Client.Self {
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.User.Channel.Name))
}
if e.Type.Has(gumble.UserChangeRecording) {
b.HandleRecordingChange(e)
}
if e.Type.Has(gumble.UserChangeComment) {
comment := strings.TrimSpace(esc(e.User.Comment))
if comment == "" {
comment = "empty"
}
b.AddOutputLine(fmt.Sprintf("User comment for %s: %s", e.User.Name, comment))
}
if e.Type.Has(gumble.UserChangeStats) && e.User.Stats != nil {
b.AddOutputLine(formatUserStats(e.User))
}
b.updateUserChannel(e)
b.postUI(func() {
b.RebuildUserChannelTreePreservingSelection()
b.Ui.Refresh()
})
}
type userChangeNotification struct {
event string
who string
what string
line string
}
func (b *Barnard) userChangeNotification(e *gumble.UserChangeEvent) (userChangeNotification, bool) {
if e == nil || e.User == nil || b.Client == nil || b.Client.Self == nil || b.Client.Self.Channel == nil {
return userChangeNotification{}, false
}
currentChannel := b.Client.Self.Channel
previousChannel := b.previousUserChannel(e.User)
userChannel := e.User.Channel
if e.Type.Has(gumble.UserChangeConnected) {
return buildUserChangeNotification("join", "joined", e.User, userChannel, currentChannel)
}
if e.Type.Has(gumble.UserChangeDisconnected) {
if previousChannel == nil {
previousChannel = userChannel
}
return buildUserChangeNotification("leave", "left", e.User, previousChannel, currentChannel)
}
if e.Type.Has(gumble.UserChangeChannel) && e.User != b.Client.Self {
if sameChannel(userChannel, currentChannel) && !sameChannel(previousChannel, currentChannel) {
return buildUserChangeNotification("join", "joined", e.User, userChannel, currentChannel)
}
if sameChannel(previousChannel, currentChannel) && !sameChannel(userChannel, currentChannel) {
return buildUserChangeNotification("leave", "left", e.User, previousChannel, currentChannel)
}
}
return userChangeNotification{}, false
}
func (b *Barnard) userAudioChangeNotification(e *gumble.UserChangeEvent) (userChangeNotification, bool) {
if e == nil || e.User == nil || (e.SelfMute == nil && e.SelfDeaf == nil) || b.Client == nil || b.Client.Self == nil || b.Client.Self.Channel == nil {
return userChangeNotification{}, false
}
// Barnard announces its own hotkey actions immediately. The protocol echo
// is only needed to announce state changes made by other users.
if e.User.Session == b.Client.Self.Session {
return userChangeNotification{}, false
}
if !sameChannel(e.User.Channel, b.Client.Self.Channel) {
return userChangeNotification{}, false
}
event, state := "unmute", "unmuted"
if e.User.SelfDeafened {
event, state = "mutedeafen", "muted and deafened"
} else if e.User.SelfMuted {
event, state = "mute", "muted"
}
return userChangeNotification{
event: event,
who: e.User.Name,
what: e.User.Channel.Name,
line: fmt.Sprintf("%s is now %s", e.User.Name, state),
}, true
}
func (b *Barnard) restoreTransmissionAfterUndeafen(e *gumble.UserChangeEvent) {
if e == nil || e.User == nil || e.SelfDeaf == nil || b.Client == nil || b.Client.Self == nil {
return
}
if e.User.Session != b.Client.Self.Session || *e.SelfDeaf || !b.shouldTransmitOnUndeafen() {
return
}
b.setTransmitOnUndeafen(false)
b.postUI(func() { b.setTransmit(nil, 1) })
}
func buildUserChangeNotification(event string, verb string, user *gumble.User, eventChannel *gumble.Channel, currentChannel *gumble.Channel) (userChangeNotification, bool) {
if !sameChannel(eventChannel, currentChannel) {
return userChangeNotification{}, false
}
return userChangeNotification{
event: event,
who: user.Name,
what: eventChannel.Name,
line: fmt.Sprintf("%s %s %s", user.Name, verb, eventChannel.Name),
}, true
}
func sameChannel(a *gumble.Channel, b *gumble.Channel) bool {
return a != nil && b != nil && a.ID == b.ID
}
func (b *Barnard) previousUserChannel(user *gumble.User) *gumble.Channel {
if b.userChannels == nil || user == nil {
return nil
}
return b.userChannels[user.Session]
}
func (b *Barnard) rememberUserChannel(user *gumble.User) {
if user == nil || user.Channel == nil {
return
}
if b.userChannels == nil {
b.userChannels = make(map[uint32]*gumble.Channel)
}
b.userChannels[user.Session] = user.Channel
}
func (b *Barnard) updateUserChannel(e *gumble.UserChangeEvent) {
if e == nil || e.User == nil {
return
}
if e.Type.Has(gumble.UserChangeDisconnected) {
if b.userChannels != nil {
delete(b.userChannels, e.User.Session)
}
return
}
b.rememberUserChannel(e.User)
}
func (b *Barnard) OnChannelChange(e *gumble.ChannelChangeEvent) {
b.UpdateInputStatus(fmt.Sprintf("[%s]", e.Channel.Name))
if e.Type.Has(gumble.ChannelChangeDescription) {
description := strings.TrimSpace(esc(e.Channel.Description))
if description == "" {
description = "empty"
}
b.AddOutputLine(fmt.Sprintf("Channel description for %s: %s", e.Channel.Name, description))
}
if e.Type.Has(gumble.ChannelChangePermission) {
if permission := e.Channel.Permission(); permission != nil {
b.AddOutputLine(fmt.Sprintf("Channel permissions for %s: %s", e.Channel.Name, permissionList(*permission)))
}
}
b.postUI(func() {
b.RebuildUserChannelTreePreservingSelection()
b.Ui.Refresh()
})
}
func formatUserStats(user *gumble.User) string {
stats := user.Stats
connected := "unknown"
if !stats.Connected.IsZero() {
connected = time.Since(stats.Connected).Round(time.Second).String()
}
ip := "unknown"
if stats.IP != nil {
ip = stats.IP.String()
}
version := formatUserVersion(stats.Version)
return fmt.Sprintf(
"User stats for %s: version %s, connected %s, idle %s, bandwidth %d, UDP ping %.1f ms, TCP ping %.1f ms, IP %s, Opus %s",
user.Name,
version,
connected,
stats.Idle.Round(time.Second),
stats.Bandwidth,
stats.UDPPingAverage,
stats.TCPPingAverage,
ip,
onOff(stats.Opus),
)
}
func formatUserVersion(version gumble.Version) string {
major, minor, patch := (&version).SemanticVersion()
semantic := fmt.Sprintf("%d.%d.%d", major, minor, patch)
parts := []string{}
if version.Release != "" {
parts = append(parts, version.Release)
}
if version.OS != "" {
parts = append(parts, version.OS)
}
if version.OSVersion != "" {
parts = append(parts, version.OSVersion)
}
if len(parts) == 0 && version.Version == 0 {
return "unknown"
}
if len(parts) == 0 {
return semantic
}
return semantic + " " + strings.Join(parts, " ")
}
func (b *Barnard) OnPermissionDenied(e *gumble.PermissionDeniedEvent) {
var info string
switch e.Type {
case gumble.PermissionDeniedOther:
info = e.String
case gumble.PermissionDeniedPermission:
info = "insufficient permissions"
case gumble.PermissionDeniedSuperUser:
info = "cannot modify SuperUser"
case gumble.PermissionDeniedInvalidChannelName:
info = "invalid channel name"
case gumble.PermissionDeniedTextTooLong:
info = "text too long"
case gumble.PermissionDeniedTemporaryChannel:
info = "temporary channel"
case gumble.PermissionDeniedMissingCertificate:
info = "missing certificate"
case gumble.PermissionDeniedInvalidUserName:
info = "invalid user name"
case gumble.PermissionDeniedChannelFull:
info = "channel full"
case gumble.PermissionDeniedNestingLimit:
info = "nesting limit"
}
b.AddOutputLine(fmt.Sprintf("Permission denied: %s", info))
}
func (b *Barnard) OnUserList(e *gumble.UserListEvent) {
b.AddOutputLine(fmt.Sprintf("Admin: received %d registered users", len(e.UserList)))
b.postUI(func() { b.adminUserList = e.UserList; b.UiAdmin.Rebuild(); b.Ui.Refresh() })
}
func (b *Barnard) OnACL(e *gumble.ACLEvent) {
if e.ACL != nil && e.ACL.Channel != nil {
b.AddOutputLine(fmt.Sprintf("Admin: received ACLs for %s", e.ACL.Channel.Name))
}
b.postUI(func() { b.adminACL = e.ACL; b.UiAdmin.Rebuild(); b.Ui.Refresh() })
}
func (b *Barnard) OnBanList(e *gumble.BanListEvent) {
b.AddOutputLine(fmt.Sprintf("Admin: received %d bans", len(e.BanList)))
b.postUI(func() { b.adminBanList = e.BanList; b.UiAdmin.Rebuild(); b.Ui.Refresh() })
}
func (b *Barnard) OnContextActionChange(e *gumble.ContextActionChangeEvent) {
if e.ContextAction != nil {
b.AddOutputLine(fmt.Sprintf("Admin: context action updated: %s", e.ContextAction.Name))
}
b.postUI(func() { b.UiAdmin.Rebuild(); b.Ui.Refresh() })
}
func (b *Barnard) OnServerConfig(e *gumble.ServerConfigEvent) {
b.HandleRecordingAllowed(e.RecordingAllowed)
}