From cc483685ef0d059f31846f0f6961b4bf92fc93ae Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 19 May 2026 01:06:01 -0400 Subject: [PATCH] Add actions menu admin features --- README.md | 20 + admin.go | 1090 ++++++++++++++++++++++++++++++++++++ admin_test.go | 65 +++ barnard.go | 8 + client.go | 92 ++- config/hotkey_config.go | 1 + config/user_config.go | 5 + config/user_config_test.go | 6 + ui.go | 29 +- uiterm/tree.go | 9 +- 10 files changed, 1320 insertions(+), 5 deletions(-) create mode 100644 admin.go create mode 100644 admin_test.go diff --git a/README.md b/README.md index 7eaabe7..b210db4 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,25 @@ When in the treeview, pressing: * enter on de-selected user selects that user for PM mode. * enter on selected user de-selects the user * enter on a channel de-selects any selected users (if any) and moves you to the specified channel. +* f11 opens the actions menu for the focused user or channel. + +The actions menu is a plain tree view for screen reader accessibility. It starts with non-privileged information actions, such as requesting user comments, user stats, channel descriptions, and channel permissions. If Barnard has permission information showing that you cannot perform an admin action, that privileged action is hidden. The server still remains the final authority and will reject actions if permissions are missing or stale. Destructive actions such as kick, ban, channel deletion, deregister, and raw ACL edits prompt in the input line before sending. Press Escape to close the menu or cancel an active prompt. + +Admin actions are also available through `/admin` commands and the FIFO command interface. Examples: + +* `/admin menu` +* `/admin kick Username reason for kick` +* `/admin ban Username reason for ban` +* `/admin mute Username on` +* `/admin deafen Username off` +* `/admin suppress Username toggle` +* `/admin move Username ChannelName` +* `/admin banlist` +* `/admin unban 1` +* `/admin users` +* `/admin acl request ChannelName` +* `/admin acl grant mute_deafen user 123` +* `/admin context action_name user Username` ## Volume @@ -248,6 +267,7 @@ After running the command above, `barnard` will be compiled as `$(go env GOPATH) - F1: toggle voice transmission - F9: toggle noise suppression +- F11: open actions menu for the focused tree item - F12: cycle through voice effects - Ctrl+R: toggle recording - Ctrl+L: clear chat log diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..ae8c1e2 --- /dev/null +++ b/admin.go @@ -0,0 +1,1090 @@ +package main + +import ( + "fmt" + "net" + "strconv" + "strings" + "time" + + "git.stormux.org/storm/barnard/gumble/gumble" + "git.stormux.org/storm/barnard/uiterm" + "github.com/nsf/termbox-go" +) + +type adminPrompt struct { + label string + action func(string) +} + +type adminItem struct { + label string + children []uiterm.TreeItem + action func() +} + +func (i adminItem) String() string { + return i.label +} + +func (i adminItem) TreeItemStyle(fg, bg uiterm.Attribute, active bool) (uiterm.Attribute, uiterm.Attribute) { + if len(i.children) > 0 { + fg |= uiterm.AttrBold + } + if active { + fg, bg = bg, fg + } + return fg, bg +} + +func (b *Barnard) OnAdminMenuPress(ui *uiterm.Ui, key uiterm.Key) { + b.OpenAdminMenu() +} + +func (b *Barnard) OnAdminEscape(ui *uiterm.Ui, key uiterm.Key) { + if b.pendingAdminPrompt != nil { + b.pendingAdminPrompt = nil + b.UiInput.Text = "" + b.AddOutputLine("Admin: prompt canceled") + b.UpdateInputStatus(fmt.Sprintf("[%s]", b.Client.Self.Channel.Name)) + return + } + if b.Ui.Active() == uiViewAdmin { + b.Ui.SetActive(uiViewTree) + width, height := termboxSize() + b.OnUiResize(ui, width, height) + b.AddOutputLine("Admin: closed") + ui.Refresh() + } +} + +func (b *Barnard) OpenAdminMenu() { + if b.Client == nil || b.Client.Self == nil { + b.AddOutputLine("Admin: not connected") + return + } + b.adminTargetUser = b.selectedUser + b.adminTargetChan = b.Client.Self.Channel + if b.Ui.Active() == uiViewTree { + switch item := b.UiTree.ActiveItem().(type) { + case TreeItem: + if item.User != nil { + b.adminTargetUser = item.User + b.adminTargetChan = item.User.Channel + } + if item.Channel != nil { + b.adminTargetChan = item.Channel + b.adminTargetUser = nil + } + } + } + if b.adminTargetChan != nil { + b.adminTargetChan.RequestPermission() + } + if root := b.Client.Channels[0]; root != nil && root != b.adminTargetChan { + root.RequestPermission() + } + b.UiAdmin.Rebuild() + b.Ui.SetActive(uiViewAdmin) + width, height := termboxSize() + b.OnUiResize(b.Ui, width, height) + if b.adminTargetUser != nil { + b.AddOutputLine(fmt.Sprintf("Admin: opened for user %s", b.adminTargetUser.Name)) + } else if b.adminTargetChan != nil { + b.AddOutputLine(fmt.Sprintf("Admin: opened for channel %s", b.adminTargetChan.Name)) + } else { + b.AddOutputLine("Admin: opened") + } + b.Ui.Refresh() +} + +func termboxSize() (int, int) { + width, height := termbox.Size() + return width, height +} + +func (b *Barnard) AdminItemBuild(item uiterm.TreeItem) []uiterm.TreeItem { + if item != nil { + if admin, ok := item.(adminItem); ok { + return admin.children + } + return nil + } + + target := "server" + if b.adminTargetUser != nil { + target = "user " + b.adminTargetUser.Name + } else if b.adminTargetChan != nil { + target = "channel " + b.adminTargetChan.Name + } + + items := []uiterm.TreeItem{ + adminItem{label: "Action target: " + target}, + adminItem{label: "Information actions", children: b.informationItems()}, + } + if children := b.adminUserItems(); len(children) > 0 { + items = append(items, adminItem{label: "Selected user admin actions", children: children}) + } + if children := b.adminChannelItems(); len(children) > 0 { + items = append(items, adminItem{label: "Channel admin actions", children: children}) + } + if children := b.adminBanItems(); len(children) > 0 { + items = append(items, adminItem{label: "Ban list admin actions", children: children}) + } + if children := b.adminRegisteredUserItems(); len(children) > 0 { + items = append(items, adminItem{label: "Registered user admin actions", children: children}) + } + if children := b.adminACLItems(); len(children) > 0 { + items = append(items, adminItem{label: "ACL admin actions", children: children}) + } + if children := b.adminContextActionItems(); len(children) > 0 { + items = append(items, adminItem{label: "Context actions", children: children}) + } + items = append(items, adminItem{label: "Close actions menu", action: func() { b.OnAdminEscape(b.Ui, uiterm.KeyEsc) }}) + return items +} + +func (b *Barnard) AdminItemKeyPress(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, key uiterm.Key) { + if key != uiterm.KeyEnter { + return + } + admin, ok := item.(adminItem) + if !ok || admin.action == nil { + return + } + admin.action() + b.UiAdmin.Rebuild() + b.Ui.Refresh() +} + +func (b *Barnard) AdminItemCharacter(ui *uiterm.Ui, tree *uiterm.Tree, item uiterm.TreeItem, ch rune) { +} + +func (b *Barnard) informationItems() []uiterm.TreeItem { + items := []uiterm.TreeItem{} + if u := b.adminTargetUser; u != nil { + items = append(items, + adminItem{label: "Request user stats for " + u.Name, action: func() { + u.RequestStats() + b.AddOutputLine(fmt.Sprintf("Actions: requested stats for %s", u.Name)) + b.showOutputAfterInfoRequest() + }}, + adminItem{label: "Request user comment for " + u.Name, action: func() { + u.RequestComment() + b.AddOutputLine(fmt.Sprintf("Actions: requested comment for %s", u.Name)) + b.showOutputAfterInfoRequest() + }}, + ) + } + if ch := b.adminTargetChan; ch != nil { + items = append(items, + adminItem{label: "Move self to " + ch.Name, action: func() { + b.Client.Self.Move(ch) + b.AddOutputLine(fmt.Sprintf("Actions: moving to %s", ch.Name)) + }}, + adminItem{label: "Request channel description for " + ch.Name, action: func() { + ch.RequestDescription() + b.AddOutputLine(fmt.Sprintf("Actions: requested description for %s", ch.Name)) + b.showOutputAfterInfoRequest() + }}, + adminItem{label: "Request channel permissions for " + ch.Name, action: func() { + ch.RequestPermission() + b.AddOutputLine(fmt.Sprintf("Actions: requested permissions for %s", ch.Name)) + b.showOutputAfterInfoRequest() + }}, + ) + } + if len(items) == 0 { + return []uiterm.TreeItem{adminItem{label: "No information actions available"}} + } + return items +} + +func (b *Barnard) showOutputAfterInfoRequest() { + b.Ui.SetActive(uiViewInput) + width, height := termboxSize() + b.OnUiResize(b.Ui, width, height) +} + +func (b *Barnard) adminUserItems() []uiterm.TreeItem { + u := b.adminTargetUser + if u == nil { + return nil + } + items := []uiterm.TreeItem{} + if b.adminCanRoot(gumble.PermissionKick) { + items = append(items, adminItem{label: "Kick " + u.Name, action: func() { + b.promptAdmin("Kick reason for "+u.Name, func(reason string) { + u.Kick(reason) + b.AddOutputLine(fmt.Sprintf("Admin: kick sent for %s", u.Name)) + }) + }}) + } + if b.adminCanRoot(gumble.PermissionBan) { + items = append(items, adminItem{label: "Ban " + u.Name, action: func() { + b.promptAdmin("Ban reason for "+u.Name, func(reason string) { + u.Ban(reason) + b.AddOutputLine(fmt.Sprintf("Admin: ban sent for %s", u.Name)) + }) + }}) + } + if b.adminCanChannel(u.Channel, gumble.PermissionMuteDeafen) { + items = append(items, adminItem{label: fmt.Sprintf("Server mute: %s", onOff(u.Muted)), action: func() { + u.SetMuted(!u.Muted) + b.AddOutputLine(fmt.Sprintf("Admin: server mute toggled for %s", u.Name)) + }}) + items = append(items, adminItem{label: fmt.Sprintf("Server deafen: %s", onOff(u.Deafened)), action: func() { + u.SetDeafened(!u.Deafened) + b.AddOutputLine(fmt.Sprintf("Admin: server deafen toggled for %s", u.Name)) + }}) + items = append(items, adminItem{label: fmt.Sprintf("Suppress in channel: %s", onOff(u.Suppressed)), action: func() { + u.SetSuppressed(!u.Suppressed) + b.AddOutputLine(fmt.Sprintf("Admin: suppress toggled for %s", u.Name)) + }}) + items = append(items, adminItem{label: fmt.Sprintf("Priority speaker: %s", onOff(u.PrioritySpeaker)), action: func() { + u.SetPrioritySpeaker(!u.PrioritySpeaker) + b.AddOutputLine(fmt.Sprintf("Admin: priority speaker toggled for %s", u.Name)) + }}) + } + if b.adminCanChannel(b.Client.Self.Channel, gumble.PermissionMove) { + items = append(items, adminItem{label: "Move user to current channel", action: func() { + u.Move(b.Client.Self.Channel) + b.AddOutputLine(fmt.Sprintf("Admin: move sent for %s to %s", u.Name, b.Client.Self.Channel.Name)) + }}) + } + if b.adminCanRoot(gumble.PermissionRegister) { + items = append(items, adminItem{label: "Register user", action: func() { + u.Register() + b.AddOutputLine(fmt.Sprintf("Admin: register sent for %s", u.Name)) + }}) + } + return items +} + +func (b *Barnard) adminChannelItems() []uiterm.TreeItem { + ch := b.adminTargetChan + if ch == nil { + return nil + } + items := []uiterm.TreeItem{} + if b.adminCanChannel(ch, gumble.PermissionMakeChannel) { + items = append(items, adminItem{label: "Create child channel", action: func() { + b.promptAdmin("New channel name under "+ch.Name, func(name string) { + name = strings.TrimSpace(name) + if name == "" { + b.AddOutputLine("Admin: channel name required") + return + } + ch.Add(name, false) + b.AddOutputLine(fmt.Sprintf("Admin: create channel sent for %s", name)) + }) + }}) + } + if b.adminCanChannel(ch, gumble.PermissionMakeTemporaryChannel) { + items = append(items, adminItem{label: "Create temporary child channel", action: func() { + b.promptAdmin("Temporary channel name under "+ch.Name, func(name string) { + name = strings.TrimSpace(name) + if name == "" { + b.AddOutputLine("Admin: channel name required") + return + } + ch.Add(name, true) + b.AddOutputLine(fmt.Sprintf("Admin: create temporary channel sent for %s", name)) + }) + }}) + } + if b.adminCanChannel(ch, gumble.PermissionWrite) { + items = append(items, adminItem{label: "Rename channel", action: func() { + b.promptAdmin("New name for "+ch.Name, func(name string) { + name = strings.TrimSpace(name) + if name == "" { + b.AddOutputLine("Admin: channel name required") + return + } + ch.SetName(name) + b.AddOutputLine(fmt.Sprintf("Admin: rename channel sent for %s", ch.Name)) + }) + }}) + items = append(items, adminItem{label: "Set channel description", action: func() { + b.promptAdmin("Description for "+ch.Name, func(description string) { + ch.SetDescription(description) + b.AddOutputLine(fmt.Sprintf("Admin: channel description sent for %s", ch.Name)) + }) + }}) + items = append(items, adminItem{label: "Set max users", action: func() { + b.promptAdmin("Max users for "+ch.Name+" (0 for server default)", func(text string) { + maxUsers, err := strconv.ParseUint(strings.TrimSpace(text), 10, 32) + if err != nil { + b.AddOutputLine("Admin: max users must be a number") + return + } + ch.SetMaxUsers(uint32(maxUsers)) + b.AddOutputLine(fmt.Sprintf("Admin: max users sent for %s", ch.Name)) + }) + }}) + items = append(items, adminItem{label: "Delete channel", action: func() { + if ch.IsRoot() { + b.AddOutputLine("Admin: cannot delete the root channel") + return + } + b.promptAdmin("Type delete to remove channel "+ch.Name, func(text string) { + if strings.TrimSpace(strings.ToLower(text)) != "delete" { + b.AddOutputLine("Admin: delete channel canceled") + return + } + ch.Remove() + b.AddOutputLine(fmt.Sprintf("Admin: delete channel sent for %s", ch.Name)) + }) + }}) + } + if len(ch.Links) > 0 && b.adminCanChannel(ch, gumble.PermissionLinkChannel) { + items = append(items, adminItem{label: "Unlink all channels", action: func() { + ch.Unlink() + b.AddOutputLine(fmt.Sprintf("Admin: unlink all sent for %s", ch.Name)) + }}) + } + return items +} + +func (b *Barnard) adminBanItems() []uiterm.TreeItem { + if !b.adminCanRoot(gumble.PermissionBan) { + return nil + } + items := []uiterm.TreeItem{ + adminItem{label: "Request ban list", action: func() { + b.Client.RequestBanList() + b.AddOutputLine("Admin: requested ban list") + }}, + adminItem{label: "Add manual ban", action: func() { + b.promptAdmin("Ban as ip/mask minutes reason", b.addManualBan) + }}, + } + for index, ban := range b.adminBanList { + label := fmt.Sprintf("Ban %d: %s %s", index+1, ban.Address.String(), ban.Reason) + i := index + items = append(items, adminItem{label: label, children: []uiterm.TreeItem{ + adminItem{label: "Unban this entry", action: func() { + b.unbanIndex(i) + }}, + }}) + } + return items +} + +func (b *Barnard) adminRegisteredUserItems() []uiterm.TreeItem { + if !b.adminCanRoot(gumble.PermissionRegister) { + return nil + } + items := []uiterm.TreeItem{ + adminItem{label: "Request registered user list", action: func() { + b.Client.RequestUserList() + b.AddOutputLine("Admin: requested registered user list") + }}, + } + for _, user := range b.adminUserList { + u := user + label := fmt.Sprintf("Registered user %d: %s", u.UserID, u.Name) + items = append(items, adminItem{label: label, children: []uiterm.TreeItem{ + adminItem{label: "Rename " + u.Name, action: func() { + b.promptAdmin("New name for "+u.Name, func(name string) { + name = strings.TrimSpace(name) + if name == "" { + b.AddOutputLine("Admin: registered user name required") + return + } + u.SetName(name) + b.Client.Send(b.adminUserList) + b.AddOutputLine(fmt.Sprintf("Admin: rename registered user sent for %d", u.UserID)) + }) + }}, + adminItem{label: "Deregister " + u.Name, action: func() { + b.promptAdmin("Type deregister to remove "+u.Name, func(text string) { + if strings.TrimSpace(strings.ToLower(text)) != "deregister" { + b.AddOutputLine("Admin: deregister canceled") + return + } + u.Deregister() + b.Client.Send(b.adminUserList) + b.AddOutputLine(fmt.Sprintf("Admin: deregister sent for %s", u.Name)) + }) + }}, + }}) + } + return items +} + +func (b *Barnard) adminACLItems() []uiterm.TreeItem { + ch := b.adminTargetChan + if ch == nil { + return nil + } + if !b.adminCanChannel(ch, gumble.PermissionWrite) { + return nil + } + items := []uiterm.TreeItem{ + adminItem{label: "Request ACLs for " + ch.Name, action: func() { + ch.RequestACL() + b.AddOutputLine(fmt.Sprintf("Admin: requested ACLs for %s", ch.Name)) + }}, + } + if b.adminACL == nil || b.adminACL.Channel != ch { + return append(items, adminItem{label: "No ACL loaded for this channel"}) + } + if b.adminTargetUser != nil && b.adminTargetUser.IsRegistered() { + for _, permission := range commonAdminPermissions() { + perm := permission + items = append(items, adminItem{label: "Toggle user grant " + perm.name, action: func() { + b.toggleACLUserGrant(b.adminTargetUser, perm.permission) + }}) + } + } else if b.adminTargetUser != nil { + items = append(items, adminItem{label: "Selected user is not registered; user ACL toggles unavailable"}) + } + items = append(items, adminItem{label: fmt.Sprintf("Inherit ACLs: %s", onOff(b.adminACL.Inherits)), action: func() { + b.adminACL.Inherits = !b.adminACL.Inherits + b.Client.Send(b.adminACL) + b.AddOutputLine(fmt.Sprintf("Admin: ACL inheritance toggled for %s", b.adminACL.Channel.Name)) + }}) + for index, rule := range b.adminACL.Rules { + items = append(items, adminItem{label: fmt.Sprintf("Rule %d: %s grant=%s deny=%s", index+1, aclSubject(rule), permissionList(rule.Granted), permissionList(rule.Denied))}) + } + items = append(items, adminItem{label: "Advanced raw ACL command", action: func() { + b.promptAdmin("acl grant|deny permission user ID or group NAME", func(text string) { + b.executeAdminCommand("acl " + text) + }) + }}) + return items +} + +func (b *Barnard) adminContextActionItems() []uiterm.TreeItem { + if b.Client == nil || len(b.Client.ContextActions) == 0 { + return []uiterm.TreeItem{adminItem{label: "No context actions available"}} + } + items := []uiterm.TreeItem{} + for _, action := range b.Client.ContextActions { + ca := action + label := ca.Label + if label == "" { + label = ca.Name + } + children := []uiterm.TreeItem{} + if ca.Type&gumble.ContextActionServer != 0 { + children = append(children, adminItem{label: "Trigger on server", action: func() { + ca.Trigger() + b.AddOutputLine("Admin: context action sent: " + ca.Name) + }}) + } + if ca.Type&gumble.ContextActionUser != 0 && b.adminTargetUser != nil { + children = append(children, adminItem{label: "Trigger on user " + b.adminTargetUser.Name, action: func() { + ca.TriggerUser(b.adminTargetUser) + b.AddOutputLine("Admin: context action sent: " + ca.Name) + }}) + } + if ca.Type&gumble.ContextActionChannel != 0 && b.adminTargetChan != nil { + children = append(children, adminItem{label: "Trigger on channel " + b.adminTargetChan.Name, action: func() { + ca.TriggerChannel(b.adminTargetChan) + b.AddOutputLine("Admin: context action sent: " + ca.Name) + }}) + } + items = append(items, adminItem{label: label, children: children}) + } + return items +} + +func (b *Barnard) promptAdmin(label string, action func(string)) { + b.pendingAdminPrompt = &adminPrompt{label: label, action: action} + b.UpdateInputStatus("[admin]") + b.Ui.SetActive(uiViewInput) + width, height := termboxSize() + b.OnUiResize(b.Ui, width, height) + b.AddOutputLine("Admin: " + label) +} + +func (b *Barnard) handleAdminPrompt(text string) bool { + if b.pendingAdminPrompt == nil { + return false + } + prompt := b.pendingAdminPrompt + b.pendingAdminPrompt = nil + prompt.action(strings.TrimSpace(text)) + if b.Client != nil && b.Client.Self != nil { + b.UpdateInputStatus(fmt.Sprintf("[%s]", b.Client.Self.Channel.Name)) + } + return true +} + +func (b *Barnard) CommandAdmin(ui *uiterm.Ui, cmd string) { + b.executeAdminCommand(cmd) +} + +func (b *Barnard) executeAdminCommand(cmd string) { + fields := strings.Fields(cmd) + if len(fields) == 0 || fields[0] == "menu" { + b.OpenAdminMenu() + return + } + if b.Client == nil || b.Client.Self == nil { + b.AddOutputLine("Admin: not connected") + return + } + switch fields[0] { + case "kick": + user, reason := b.adminCommandUserAndReason(fields[1:]) + if user == nil { + return + } + user.Kick(reason) + b.AddOutputLine(fmt.Sprintf("Admin: kick sent for %s", user.Name)) + case "ban": + user, reason := b.adminCommandUserAndReason(fields[1:]) + if user == nil { + return + } + user.Ban(reason) + b.AddOutputLine(fmt.Sprintf("Admin: ban sent for %s", user.Name)) + case "mute", "deafen", "suppress", "priority": + b.executeUserToggle(fields) + case "move": + b.executeMoveCommand(fields) + case "channel": + b.executeChannelCommand(fields) + case "banlist": + b.Client.RequestBanList() + b.AddOutputLine("Admin: requested ban list") + case "unban": + if len(fields) < 2 { + b.AddOutputLine("Admin: usage /admin unban ") + return + } + index, err := strconv.Atoi(fields[1]) + if err != nil { + b.AddOutputLine("Admin: unban index must be a number") + return + } + b.unbanIndex(index - 1) + case "users": + b.Client.RequestUserList() + b.AddOutputLine("Admin: requested registered user list") + case "renameuser": + b.executeRenameRegisteredUser(fields) + case "deregister": + b.executeDeregisterRegisteredUser(fields) + case "acl": + b.executeACLCommand(fields[1:]) + case "context": + b.executeContextCommand(fields) + default: + b.AddOutputLine(fmt.Sprintf("Admin: unknown command %s", fields[0])) + } +} + +func (b *Barnard) adminCommandUserAndReason(args []string) (*gumble.User, string) { + if len(args) == 0 { + b.AddOutputLine("Admin: user required") + return nil, "" + } + user := b.findUser(args[0]) + if user == nil { + b.AddOutputLine("Admin: user not found: " + args[0]) + return nil, "" + } + return user, strings.Join(args[1:], " ") +} + +func (b *Barnard) executeUserToggle(fields []string) { + if len(fields) < 2 { + b.AddOutputLine(fmt.Sprintf("Admin: usage /admin %s [on|off|toggle]", fields[0])) + return + } + user := b.findUser(fields[1]) + if user == nil { + b.AddOutputLine("Admin: user not found: " + fields[1]) + return + } + stateArg := "toggle" + if len(fields) > 2 { + stateArg = fields[2] + } + var current bool + var set func(bool) + switch fields[0] { + case "mute": + current, set = user.Muted, user.SetMuted + case "deafen": + current, set = user.Deafened, user.SetDeafened + case "suppress": + current, set = user.Suppressed, user.SetSuppressed + case "priority": + current, set = user.PrioritySpeaker, user.SetPrioritySpeaker + } + state, ok := parseToggleState(stateArg, current) + if !ok { + b.AddOutputLine("Admin: state must be on, off, or toggle") + return + } + set(state) + b.AddOutputLine(fmt.Sprintf("Admin: %s set to %s for %s", fields[0], onOff(state), user.Name)) +} + +func (b *Barnard) executeMoveCommand(fields []string) { + if len(fields) < 3 { + b.AddOutputLine("Admin: usage /admin move ") + return + } + user := b.findUser(fields[1]) + channel := b.findChannel(strings.Join(fields[2:], " ")) + if user == nil { + b.AddOutputLine("Admin: user not found: " + fields[1]) + return + } + if channel == nil { + b.AddOutputLine("Admin: channel not found") + return + } + user.Move(channel) + b.AddOutputLine(fmt.Sprintf("Admin: move sent for %s to %s", user.Name, channel.Name)) +} + +func (b *Barnard) executeChannelCommand(fields []string) { + if len(fields) < 3 { + b.AddOutputLine("Admin: usage /admin channel ...") + return + } + channel := b.findChannel(fields[2]) + switch fields[1] { + case "add", "temp": + if len(fields) < 4 { + b.AddOutputLine("Admin: child channel name required") + return + } + if channel == nil { + b.AddOutputLine("Admin: parent channel not found") + return + } + name := strings.Join(fields[3:], " ") + channel.Add(name, fields[1] == "temp") + b.AddOutputLine(fmt.Sprintf("Admin: create channel sent for %s", name)) + case "rename": + if len(fields) < 4 { + b.AddOutputLine("Admin: new channel name required") + return + } + if channel == nil { + b.AddOutputLine("Admin: channel not found") + return + } + channel.SetName(strings.Join(fields[3:], " ")) + b.AddOutputLine("Admin: rename channel sent") + case "describe": + if channel == nil { + b.AddOutputLine("Admin: channel not found") + return + } + channel.SetDescription(strings.Join(fields[3:], " ")) + b.AddOutputLine("Admin: channel description sent") + case "delete": + if channel == nil { + b.AddOutputLine("Admin: channel not found") + return + } + if channel.IsRoot() { + b.AddOutputLine("Admin: cannot delete the root channel") + return + } + channel.Remove() + b.AddOutputLine("Admin: delete channel sent") + case "maxusers": + if len(fields) < 4 { + b.AddOutputLine("Admin: max users value required") + return + } + if channel == nil { + b.AddOutputLine("Admin: channel not found") + return + } + maxUsers, err := strconv.ParseUint(fields[3], 10, 32) + if err != nil { + b.AddOutputLine("Admin: max users must be a number") + return + } + channel.SetMaxUsers(uint32(maxUsers)) + b.AddOutputLine("Admin: max users sent") + default: + b.AddOutputLine("Admin: unknown channel command " + fields[1]) + } +} + +func (b *Barnard) executeRenameRegisteredUser(fields []string) { + if len(fields) < 3 { + b.AddOutputLine("Admin: usage /admin renameuser ") + return + } + user := b.findRegisteredUser(fields[1]) + if user == nil { + b.AddOutputLine("Admin: registered user not found") + return + } + user.SetName(strings.Join(fields[2:], " ")) + b.Client.Send(b.adminUserList) + b.AddOutputLine(fmt.Sprintf("Admin: rename registered user sent for %d", user.UserID)) +} + +func (b *Barnard) executeDeregisterRegisteredUser(fields []string) { + if len(fields) < 2 { + b.AddOutputLine("Admin: usage /admin deregister ") + return + } + user := b.findRegisteredUser(fields[1]) + if user == nil { + b.AddOutputLine("Admin: registered user not found") + return + } + user.Deregister() + b.Client.Send(b.adminUserList) + b.AddOutputLine(fmt.Sprintf("Admin: deregister sent for %s", user.Name)) +} + +func (b *Barnard) executeACLCommand(fields []string) { + if len(fields) == 0 || fields[0] == "request" { + ch := b.adminTargetChan + if len(fields) > 1 { + ch = b.findChannel(strings.Join(fields[1:], " ")) + } + if ch == nil { + b.AddOutputLine("Admin: channel not found") + return + } + ch.RequestACL() + b.AddOutputLine(fmt.Sprintf("Admin: requested ACLs for %s", ch.Name)) + return + } + if b.adminACL == nil { + b.AddOutputLine("Admin: request ACLs before editing") + return + } + if len(fields) < 4 { + b.AddOutputLine("Admin: usage /admin acl grant|deny user | group ") + return + } + permission, ok := parsePermission(fields[1]) + if !ok { + b.AddOutputLine("Admin: unknown permission " + fields[1]) + return + } + switch fields[0] { + case "grant", "deny": + default: + b.AddOutputLine("Admin: ACL command must be grant or deny") + return + } + rule := b.findOrCreateACLRule(fields[2], strings.Join(fields[3:], " ")) + if rule == nil { + return + } + if fields[0] == "grant" { + rule.Granted ^= permission + rule.Denied &^= permission + } else { + rule.Denied ^= permission + rule.Granted &^= permission + } + b.Client.Send(b.adminACL) + b.AddOutputLine("Admin: ACL update sent") +} + +func (b *Barnard) executeContextCommand(fields []string) { + if len(fields) < 2 { + b.AddOutputLine("Admin: usage /admin context [server|user|channel] [target]") + return + } + action := b.Client.ContextActions[fields[1]] + if action == nil { + b.AddOutputLine("Admin: context action not found") + return + } + scope := "server" + if len(fields) > 2 { + scope = fields[2] + } + switch scope { + case "server": + action.Trigger() + case "user": + if len(fields) < 4 { + b.AddOutputLine("Admin: context user target required") + return + } + user := b.findUser(fields[3]) + if user == nil { + b.AddOutputLine("Admin: user not found") + return + } + action.TriggerUser(user) + case "channel": + if len(fields) < 4 { + b.AddOutputLine("Admin: context channel target required") + return + } + channel := b.findChannel(strings.Join(fields[3:], " ")) + if channel == nil { + b.AddOutputLine("Admin: channel not found") + return + } + action.TriggerChannel(channel) + default: + b.AddOutputLine("Admin: context scope must be server, user, or channel") + return + } + b.AddOutputLine("Admin: context action sent: " + action.Name) +} + +func (b *Barnard) addManualBan(text string) { + parts := strings.Fields(text) + if len(parts) < 3 { + b.AddOutputLine("Admin: usage ip/mask minutes reason") + return + } + ip, network, err := net.ParseCIDR(parts[0]) + if err != nil { + b.AddOutputLine("Admin: ban address must be CIDR, for example 192.0.2.1/32") + return + } + minutes, err := strconv.Atoi(parts[1]) + if err != nil { + b.AddOutputLine("Admin: ban minutes must be a number") + return + } + reason := strings.Join(parts[2:], " ") + b.adminBanList.Add(ip, network.Mask, reason, time.Duration(minutes)*time.Minute) + b.Client.Send(b.adminBanList) + b.AddOutputLine("Admin: manual ban sent") +} + +func (b *Barnard) unbanIndex(index int) { + if index < 0 || index >= len(b.adminBanList) { + b.AddOutputLine("Admin: ban index out of range") + return + } + b.adminBanList[index].Unban() + b.Client.Send(b.adminBanList) + b.AddOutputLine(fmt.Sprintf("Admin: unban sent for entry %d", index+1)) +} + +func (b *Barnard) toggleACLUserGrant(user *gumble.User, permission gumble.Permission) { + rule := b.findOrCreateACLRule("user", strconv.FormatUint(uint64(user.UserID), 10)) + if rule == nil { + return + } + rule.Granted ^= permission + rule.Denied &^= permission + b.Client.Send(b.adminACL) + b.AddOutputLine(fmt.Sprintf("Admin: ACL grant toggled for %s", user.Name)) +} + +func (b *Barnard) findOrCreateACLRule(subjectType, subject string) *gumble.ACLRule { + switch subjectType { + case "user": + userID64, err := strconv.ParseUint(subject, 10, 32) + if err != nil { + b.AddOutputLine("Admin: ACL user id must be numeric") + return nil + } + userID := uint32(userID64) + for _, rule := range b.adminACL.Rules { + if rule.User != nil && rule.User.UserID == userID { + return rule + } + } + rule := &gumble.ACLRule{ + AppliesCurrent: true, + User: &gumble.ACLUser{UserID: userID}, + } + b.adminACL.Rules = append(b.adminACL.Rules, rule) + return rule + case "group": + for _, rule := range b.adminACL.Rules { + if rule.Group != nil && rule.Group.Name == subject { + return rule + } + } + group := &gumble.ACLGroup{Name: subject} + rule := &gumble.ACLRule{ + AppliesCurrent: true, + Group: group, + } + b.adminACL.Rules = append(b.adminACL.Rules, rule) + return rule + default: + b.AddOutputLine("Admin: ACL subject must be user or group") + return nil + } +} + +func (b *Barnard) findUser(token string) *gumble.User { + if b.Client == nil { + return nil + } + if session, err := strconv.ParseUint(token, 10, 32); err == nil { + if user := b.Client.Users[uint32(session)]; user != nil { + return user + } + } + for _, user := range b.Client.Users { + if strings.EqualFold(user.Name, token) { + return user + } + } + return nil +} + +func (b *Barnard) findChannel(token string) *gumble.Channel { + if b.Client == nil { + return nil + } + token = strings.TrimSpace(token) + if id, err := strconv.ParseUint(token, 10, 32); err == nil { + if channel := b.Client.Channels[uint32(id)]; channel != nil { + return channel + } + } + for _, channel := range b.Client.Channels { + if strings.EqualFold(channel.Name, token) { + return channel + } + } + return nil +} + +func (b *Barnard) findRegisteredUser(token string) *gumble.RegisteredUser { + if id, err := strconv.ParseUint(token, 10, 32); err == nil { + for _, user := range b.adminUserList { + if user.UserID == uint32(id) { + return user + } + } + } + for _, user := range b.adminUserList { + if strings.EqualFold(user.Name, token) { + return user + } + } + return nil +} + +func (b *Barnard) adminCanRoot(permission gumble.Permission) bool { + if b.Client == nil { + return true + } + return b.adminCanChannel(b.Client.Channels[0], permission) +} + +func (b *Barnard) adminCanChannel(channel *gumble.Channel, permission gumble.Permission) bool { + if channel == nil { + return true + } + current := channel.Permission() + if current == nil { + return true + } + return current.Has(permission) +} + +func parseToggleState(text string, current bool) (bool, bool) { + switch strings.ToLower(strings.TrimSpace(text)) { + case "", "toggle": + return !current, true + case "on", "true", "yes", "1": + return true, true + case "off", "false", "no", "0": + return false, true + default: + return false, false + } +} + +type permissionOption struct { + name string + permission gumble.Permission +} + +func commonAdminPermissions() []permissionOption { + return []permissionOption{ + {"write", gumble.PermissionWrite}, + {"enter", gumble.PermissionEnter}, + {"speak", gumble.PermissionSpeak}, + {"mute_deafen", gumble.PermissionMuteDeafen}, + {"move", gumble.PermissionMove}, + {"kick", gumble.PermissionKick}, + {"ban", gumble.PermissionBan}, + } +} + +func parsePermission(text string) (gumble.Permission, bool) { + for _, option := range commonAdminPermissions() { + if option.name == text { + return option.permission, true + } + } + switch text { + case "traverse": + return gumble.PermissionTraverse, true + case "make_channel": + return gumble.PermissionMakeChannel, true + case "link_channel": + return gumble.PermissionLinkChannel, true + case "whisper": + return gumble.PermissionWhisper, true + case "text_message": + return gumble.PermissionTextMessage, true + case "make_temp_channel": + return gumble.PermissionMakeTemporaryChannel, true + case "register": + return gumble.PermissionRegister, true + case "register_self": + return gumble.PermissionRegisterSelf, true + default: + return 0, false + } +} + +func permissionList(permission gumble.Permission) string { + if permission == 0 { + return "none" + } + names := []string{} + for _, option := range append(commonAdminPermissions(), []permissionOption{ + {"traverse", gumble.PermissionTraverse}, + {"make_channel", gumble.PermissionMakeChannel}, + {"link_channel", gumble.PermissionLinkChannel}, + {"whisper", gumble.PermissionWhisper}, + {"text_message", gumble.PermissionTextMessage}, + {"make_temp_channel", gumble.PermissionMakeTemporaryChannel}, + {"register", gumble.PermissionRegister}, + {"register_self", gumble.PermissionRegisterSelf}, + }...) { + if permission.Has(option.permission) { + names = append(names, option.name) + } + } + return strings.Join(names, ",") +} + +func aclSubject(rule *gumble.ACLRule) string { + if rule.User != nil { + if rule.User.Name != "" { + return "user " + rule.User.Name + } + return fmt.Sprintf("user %d", rule.User.UserID) + } + if rule.Group != nil { + return "group " + rule.Group.Name + } + return "all" +} + +func onOff(value bool) string { + if value { + return "on" + } + return "off" +} diff --git a/admin_test.go b/admin_test.go new file mode 100644 index 0000000..f78d200 --- /dev/null +++ b/admin_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "testing" + + "git.stormux.org/storm/barnard/gumble/gumble" +) + +func TestParseToggleState(t *testing.T) { + tests := []struct { + name string + text string + current bool + want bool + wantOK bool + }{ + {name: "toggle true", text: "toggle", current: true, want: false, wantOK: true}, + {name: "empty toggles", text: "", current: false, want: true, wantOK: true}, + {name: "on", text: "on", current: false, want: true, wantOK: true}, + {name: "off", text: "off", current: true, want: false, wantOK: true}, + {name: "bad", text: "maybe", current: true, wantOK: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseToggleState(tt.text, tt.current) + if ok != tt.wantOK { + t.Fatalf("expected ok %v, got %v", tt.wantOK, ok) + } + if ok && got != tt.want { + t.Fatalf("expected %v, got %v", tt.want, got) + } + }) + } +} + +func TestParsePermission(t *testing.T) { + tests := []struct { + text string + want gumble.Permission + }{ + {text: "write", want: gumble.PermissionWrite}, + {text: "mute_deafen", want: gumble.PermissionMuteDeafen}, + {text: "kick", want: gumble.PermissionKick}, + {text: "register_self", want: gumble.PermissionRegisterSelf}, + } + for _, tt := range tests { + got, ok := parsePermission(tt.text) + if !ok { + t.Fatalf("expected %s to parse", tt.text) + } + if got != tt.want { + t.Fatalf("expected %v, got %v", tt.want, got) + } + } + if _, ok := parsePermission("bogus"); ok { + t.Fatal("expected bogus permission to fail") + } +} + +func TestPermissionList(t *testing.T) { + got := permissionList(gumble.PermissionWrite | gumble.PermissionBan) + if got != "write,ban" { + t.Fatalf("expected write,ban, got %q", got) + } +} diff --git a/barnard.go b/barnard.go index f1f65dc..518a604 100644 --- a/barnard.go +++ b/barnard.go @@ -37,9 +37,12 @@ type Barnard struct { UiInput uiterm.Textbox UiStatus uiterm.Label UiTree uiterm.Tree + UiAdmin uiterm.Tree UiInputStatus uiterm.Label SelectedChannel *gumble.Channel selectedUser *gumble.User + adminTargetUser *gumble.User + adminTargetChan *gumble.Channel statusText string statusNotice bool @@ -66,6 +69,11 @@ type Barnard struct { Recorder *recording.Recorder recordingStarting bool recordingAllowed *bool + + pendingAdminPrompt *adminPrompt + adminBanList gumble.BanList + adminUserList gumble.RegisteredUsers + adminACL *gumble.ACL } func (b *Barnard) StopTransmission() { diff --git a/client.go b/client.go index 7e28510..7c11d55 100644 --- a/client.go +++ b/client.go @@ -3,6 +3,7 @@ package main import ( "fmt" "net" + "strings" "time" "git.stormux.org/storm/barnard/fileplayback" @@ -215,16 +216,85 @@ func (b *Barnard) OnUserChange(e *gumble.UserChangeEvent) { 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.UiTree.Rebuild() b.Ui.Refresh() } 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.UiTree.Rebuild() 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 { @@ -253,18 +323,34 @@ func (b *Barnard) OnPermissionDenied(e *gumble.PermissionDeniedEvent) { } func (b *Barnard) OnUserList(e *gumble.UserListEvent) { - //for _,u := range e.UserList { - //b.UserConfig.UpdateUser(u) - //} + b.adminUserList = e.UserList + b.AddOutputLine(fmt.Sprintf("Admin: received %d registered users", len(e.UserList))) + b.UiAdmin.Rebuild() + b.Ui.Refresh() } func (b *Barnard) OnACL(e *gumble.ACLEvent) { + b.adminACL = e.ACL + if e.ACL != nil && e.ACL.Channel != nil { + b.AddOutputLine(fmt.Sprintf("Admin: received ACLs for %s", e.ACL.Channel.Name)) + } + b.UiAdmin.Rebuild() + b.Ui.Refresh() } func (b *Barnard) OnBanList(e *gumble.BanListEvent) { + b.adminBanList = e.BanList + b.AddOutputLine(fmt.Sprintf("Admin: received %d bans", len(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.UiAdmin.Rebuild() + b.Ui.Refresh() } func (b *Barnard) OnServerConfig(e *gumble.ServerConfigEvent) { diff --git a/config/hotkey_config.go b/config/hotkey_config.go index b4c7713..120dafc 100644 --- a/config/hotkey_config.go +++ b/config/hotkey_config.go @@ -19,6 +19,7 @@ type Hotkeys struct { ScrollDown *uiterm.Key ScrollToTop *uiterm.Key ScrollToBottom *uiterm.Key + AdminMenu *uiterm.Key NoiseSuppressionToggle *uiterm.Key CycleVoiceEffect *uiterm.Key } diff --git a/config/user_config.go b/config/user_config.go index a4e6e7e..c4f04e0 100644 --- a/config/user_config.go +++ b/config/user_config.go @@ -83,6 +83,7 @@ func (c *Config) LoadConfig() { SwitchViews: key(uiterm.KeyTab), ScrollUp: key(uiterm.KeyPgup), ScrollDown: key(uiterm.KeyPgdn), + AdminMenu: key(uiterm.KeyF11), NoiseSuppressionToggle: key(uiterm.KeyF9), CycleVoiceEffect: key(uiterm.KeyF12), } @@ -169,6 +170,7 @@ func (c *Config) ensureHotkeys() { SwitchViews: key(uiterm.KeyTab), ScrollUp: key(uiterm.KeyPgup), ScrollDown: key(uiterm.KeyPgdn), + AdminMenu: key(uiterm.KeyF11), NoiseSuppressionToggle: key(uiterm.KeyF9), CycleVoiceEffect: key(uiterm.KeyF12), } @@ -206,6 +208,9 @@ func (c *Config) ensureHotkeys() { if hotkeys.ScrollDown == nil { hotkeys.ScrollDown = defaults.ScrollDown } + if hotkeys.AdminMenu == nil { + hotkeys.AdminMenu = defaults.AdminMenu + } if hotkeys.NoiseSuppressionToggle == nil { hotkeys.NoiseSuppressionToggle = defaults.NoiseSuppressionToggle } diff --git a/config/user_config_test.go b/config/user_config_test.go index 1be6721..f56279f 100644 --- a/config/user_config_test.go +++ b/config/user_config_test.go @@ -29,4 +29,10 @@ func TestConfigBackfillsRecordingDefaults(t *testing.T) { if got := *cfg.GetHotkeys().RecordToggle; got != uiterm.KeyCtrlR { t.Fatalf("expected record toggle ctrl_r, got %s", got) } + if cfg.GetHotkeys().AdminMenu == nil { + t.Fatal("expected admin menu hotkey to be backfilled") + } + if got := *cfg.GetHotkeys().AdminMenu; got != uiterm.KeyF11 { + t.Fatalf("expected admin menu f11, got %s", got) + } } diff --git a/ui.go b/ui.go index a179f02..4e5acd8 100644 --- a/ui.go +++ b/ui.go @@ -21,6 +21,7 @@ const ( uiViewInputStatus = "inputstatus" uiViewOutput = "output" uiViewTree = "tree" + uiViewAdmin = "admin" ) func Beep() { @@ -359,7 +360,7 @@ func (b *Barnard) OnFocusPress(ui *uiterm.Ui, key uiterm.Key) { active := b.Ui.Active() if active == uiViewInput { b.Ui.SetActive(uiViewTree) - } else if active == uiViewTree { + } else if active == uiViewTree || active == uiViewAdmin { b.Ui.SetActive(uiViewInput) } width, height := termbox.Size() @@ -368,6 +369,9 @@ func (b *Barnard) OnFocusPress(ui *uiterm.Ui, key uiterm.Key) { } func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text string) { + if b.handleAdminPrompt(text) { + return + } if text == "" { return } @@ -397,6 +401,8 @@ func (b *Barnard) OnTextInput(ui *uiterm.Ui, textbox *uiterm.Textbox, text strin b.CommandNoiseSuppressionToggle(ui, cmdArgs) case "record": b.CommandRecord(ui, cmdArgs) + case "admin": + b.CommandAdmin(ui, cmdArgs) case "micup": b.CommandMicUp(ui, cmdArgs) case "micdown": @@ -473,6 +479,15 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) { } ui.Add(uiViewTree, &b.UiTree) + b.UiAdmin = uiterm.Tree{ + Generator: b.AdminItemBuild, + KeyListener: b.AdminItemKeyPress, + CharacterListener: b.AdminItemCharacter, + Fg: uiterm.ColorWhite, + Bg: uiterm.ColorBlack, + } + ui.Add(uiViewAdmin, &b.UiAdmin) + b.Ui.AddCommandListener(b.CommandMicUp, "micup") b.Ui.AddCommandListener(b.CommandMicDown, "micdown") b.Ui.AddCommandListener(b.CommandTalk, "toggle") @@ -483,7 +498,9 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) { b.Ui.AddCommandListener(b.CommandPlayFile, "file") b.Ui.AddCommandListener(b.CommandStopFile, "stop") b.Ui.AddCommandListener(b.CommandRecord, "record") + b.Ui.AddCommandListener(b.CommandAdmin, "admin") b.Ui.AddKeyListener(b.OnFocusPress, b.Hotkeys.SwitchViews) + b.Ui.AddKeyListener(b.OnAdminMenuPress, b.Hotkeys.AdminMenu) b.Ui.AddKeyListener(b.OnVoiceToggle, b.Hotkeys.Talk) b.Ui.AddKeyListener(b.OnTimestampToggle, b.Hotkeys.ToggleTimestamps) b.Ui.AddKeyListener(b.OnNoiseSuppressionToggle, b.Hotkeys.NoiseSuppressionToggle) @@ -494,6 +511,8 @@ func (b *Barnard) OnUiInitialize(ui *uiterm.Ui) { b.Ui.AddKeyListener(b.OnScrollOutputDown, b.Hotkeys.ScrollDown) b.Ui.AddKeyListener(b.OnScrollOutputTop, b.Hotkeys.ScrollToTop) b.Ui.AddKeyListener(b.OnScrollOutputBottom, b.Hotkeys.ScrollToBottom) + esc := uiterm.KeyEsc + b.Ui.AddKeyListener(b.OnAdminEscape, &esc) b.Ui.SetActive(uiViewInput) b.UiTree.Rebuild() b.Ui.Refresh() @@ -506,12 +525,20 @@ func (b *Barnard) OnUiResize(ui *uiterm.Ui, width, height int) { if active == uiViewTree { treeHeight = height - 4 outputHeight = 0 + } else if active == uiViewAdmin { + treeHeight = 0 + outputHeight = 0 } else { treeHeight = 0 outputHeight = height - 4 } ui.SetBounds(uiViewOutput, 0, 1, width, outputHeight+1) ui.SetBounds(uiViewTree, 0, 1, width, treeHeight+1) + if active == uiViewAdmin { + ui.SetBounds(uiViewAdmin, 0, 1, width, height-3) + } else { + ui.SetBounds(uiViewAdmin, 0, 1, width, 1) + } ui.SetBounds(uiViewStatus, 0, height-2, width, height-1) ui.SetBounds(uiViewInputStatus, 0, height-1, len(b.GetInputStatus()), height) ui.SetBounds(uiViewInput, len(b.GetInputStatus())+1, height-1, width, height) diff --git a/uiterm/tree.go b/uiterm/tree.go index b6ac6e1..8bb0141 100644 --- a/uiterm/tree.go +++ b/uiterm/tree.go @@ -72,7 +72,7 @@ func (t *Tree) Rebuild() { } } t.lines = lines - t.SetActiveLine(0, true) + t.SetActiveLine(0, false) t.uiDraw() } @@ -162,6 +162,13 @@ func (t *Tree) SetActiveLine(num int, relative bool) { } } +func (t *Tree) ActiveItem() TreeItem { + if len(t.lines) == 0 { + return nil + } + return t.lines[bounded(t.activeLine, 0, len(t.lines)-1)].Item +} + func (t *Tree) uiKeyEvent(key Key) { var runHandler = true switch key {