Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-04-30 17:33:42 +02:00
commit 09b054cc5c
16 changed files with 1037 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
package ui
import (
"fmt"
"io"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/anotherhadi/usbguard-tui/internal/guard"
)
// deviceDelegate renders device list items with status colors.
type deviceDelegate struct{}
func (d deviceDelegate) Height() int { return 2 }
func (d deviceDelegate) Spacing() int { return 0 }
func (d deviceDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (d deviceDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
dev, ok := item.(guard.Device)
if !ok {
return
}
selected := index == m.Index()
var color lipgloss.Color
if selected {
var ok bool
color, ok = statusColorsSelected[dev.Status]
if !ok {
color = colorMuted
}
} else {
var ok bool
color, ok = statusColors[dev.Status]
if !ok {
color = colorMuted
}
}
var nameStyle, descStyle lipgloss.Style
if selected {
nameStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(colorAccent).
Foreground(color).
Bold(true).
PaddingLeft(1)
descStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(colorAccent).
Foreground(colorMuted).
PaddingLeft(1)
} else {
nameStyle = lipgloss.NewStyle().Foreground(color).PaddingLeft(2)
descStyle = lipgloss.NewStyle().Foreground(colorMuted).PaddingLeft(2)
}
fmt.Fprintf(w, "%s\n%s",
nameStyle.Render(dev.Name),
descStyle.Render(fmt.Sprintf("id:%-3d %s %s", dev.ID, dev.VidPid, string(dev.Status))),
)
}
// actionItem represents a device policy action in the select popup.
type actionItem struct {
label string
fn func(int, bool) error
permanent bool
status guard.Status
}
func (a actionItem) Title() string { return a.label }
func (a actionItem) Description() string { return "" }
func (a actionItem) FilterValue() string { return a.label }
// actionDelegate renders single-line action items.
type actionDelegate struct{}
func (d actionDelegate) Height() int { return 1 }
func (d actionDelegate) Spacing() int { return 0 }
func (d actionDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (d actionDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
a, ok := item.(actionItem)
if !ok {
return
}
if index == m.Index() {
color, ok := statusColorsSelected[a.status]
if !ok {
color = colorAccent
}
fmt.Fprintf(w, " %s", lipgloss.NewStyle().Bold(true).Foreground(color).Render("> "+a.label))
} else {
fmt.Fprintf(w, " %s", a.label)
}
}
+45
View File
@@ -0,0 +1,45 @@
package ui
import "github.com/charmbracelet/bubbles/key"
type listKeyMap struct {
Open key.Binding
Filter key.Binding
Refresh key.Binding
Quit key.Binding
Help key.Binding
// shown only in full help
Allow key.Binding
AllowPerm key.Binding
Block key.Binding
BlockPerm key.Binding
Reject key.Binding
RejectPerm key.Binding
}
func (k listKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Open, k.Filter, k.Refresh, k.Quit, k.Help}
}
func (k listKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Open, k.Filter, k.Refresh, k.Quit},
{k.Allow, k.AllowPerm, k.Block, k.BlockPerm, k.Reject, k.RejectPerm},
}
}
var listKeys = listKeyMap{
Open: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select action")),
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "more")),
Allow: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "allow")),
AllowPerm: key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "allow (perm)")),
Block: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "block")),
BlockPerm: key.NewBinding(key.WithKeys("B"), key.WithHelp("B", "block (perm)")),
Reject: key.NewBinding(key.WithKeys("j"), key.WithHelp("j", "reject")),
RejectPerm: key.NewBinding(key.WithKeys("J"), key.WithHelp("J", "reject (perm)")),
}
var cancelKey = key.NewBinding(key.WithKeys("esc", "q", "ctrl+c"), key.WithHelp("esc/q", "cancel"))
+319
View File
@@ -0,0 +1,319 @@
package ui
import (
"strings"
"time"
"github.com/anotherhadi/usbguard-tui/internal/guard"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type state int
const (
stateList state = iota
statePopup
)
type (
tickMsg time.Time
devicesMsg []guard.Device
daemonStatusMsg string
actionMsg struct{ err error }
)
type Model struct {
state state
list list.Model
actionList list.Model
help help.Model
daemonStatus string
width int
height int
notice string
selectedDev *guard.Device
}
func New() Model {
l := list.New(nil, deviceDelegate{}, 0, 0)
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.SetShowStatusBar(true)
l.SetShowTitle(false)
l.DisableQuitKeybindings()
// free j/k for our shortcuts
l.KeyMap.CursorUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"))
l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down"))
l.FilterInput.PromptStyle = lipgloss.NewStyle().Foreground(colorAccent)
l.FilterInput.Cursor.Style = lipgloss.NewStyle().Foreground(colorAccent)
return Model{
state: stateList,
list: l,
actionList: makeActionList(),
help: help.New(),
}
}
func makeActionList() list.Model {
items := []list.Item{
actionItem{"allow", guard.AllowDevice, false, guard.Allowed},
actionItem{"allow (permanent)", guard.AllowDevice, true, guard.Allowed},
actionItem{"block", guard.BlockDevice, false, guard.Blocked},
actionItem{"block (permanent)", guard.BlockDevice, true, guard.Blocked},
actionItem{"reject", guard.RejectDevice, false, guard.Rejected},
actionItem{"reject (permanent)", guard.RejectDevice, true, guard.Rejected},
}
l := list.New(items, actionDelegate{}, 24, 6)
l.SetShowHelp(false)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.DisableQuitKeybindings()
l.SetFilteringEnabled(false)
return l
}
func (m Model) Init() tea.Cmd {
return tea.Batch(fetchDevices, fetchDaemonStatus, tickCmd())
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.help.Width = msg.Width
m.list.SetSize(msg.Width, m.listHeight())
m.updateActionListSize()
return m, nil
case tickMsg:
return m, tea.Batch(fetchDevices, fetchDaemonStatus, tickCmd())
case devicesMsg:
items := make([]list.Item, len(msg))
for i, d := range msg {
items[i] = d
}
cmd := m.list.SetItems(items)
return m, cmd
case daemonStatusMsg:
m.daemonStatus = string(msg)
return m, nil
case actionMsg:
m.state = stateList
m.selectedDev = nil
if msg.err != nil {
switch msg.err {
case guard.ErrReadOnly:
m.notice = "Read-only rules: applied temporarily. Add the rule to your config for persistence."
case guard.ErrPermission:
m.notice = "Permission denied. Run with appropriate privileges."
default:
m.notice = msg.err.Error()
}
} else {
m.notice = ""
}
return m, fetchDevices
case tea.KeyMsg:
if m.state == statePopup {
return m.updatePopup(msg)
}
return m.updateList(msg)
}
if m.state == stateList {
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
return m, nil
}
func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
if !m.list.SettingFilter() {
id := m.selectedDevID()
switch {
case key.Matches(msg, listKeys.Quit):
return m, tea.Quit
case key.Matches(msg, listKeys.Refresh):
m.notice = ""
return m, tea.Batch(fetchDevices, fetchDaemonStatus)
case key.Matches(msg, listKeys.Help):
m.help.ShowAll = !m.help.ShowAll
m.list.SetSize(m.width, m.listHeight())
return m, nil
case key.Matches(msg, listKeys.Open):
if item := m.list.SelectedItem(); item != nil {
d := item.(guard.Device)
m.selectedDev = &d
m.updateActionListSize()
m.actionList.Select(0)
m.state = statePopup
return m, nil
}
case id >= 0 && key.Matches(msg, listKeys.Allow):
return m, doAction(id, guard.AllowDevice, false)
case id >= 0 && key.Matches(msg, listKeys.AllowPerm):
return m, doAction(id, guard.AllowDevice, true)
case id >= 0 && key.Matches(msg, listKeys.Block):
return m, doAction(id, guard.BlockDevice, false)
case id >= 0 && key.Matches(msg, listKeys.BlockPerm):
return m, doAction(id, guard.BlockDevice, true)
case id >= 0 && key.Matches(msg, listKeys.Reject):
return m, doAction(id, guard.RejectDevice, false)
case id >= 0 && key.Matches(msg, listKeys.RejectPerm):
return m, doAction(id, guard.RejectDevice, true)
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m Model) updatePopup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, cancelKey):
m.state = stateList
m.selectedDev = nil
return m, nil
case key.Matches(msg, listKeys.Open):
if item := m.actionList.SelectedItem(); item != nil {
a := item.(actionItem)
return m, doAction(m.selectedDev.ID, a.fn, a.permanent)
}
}
var cmd tea.Cmd
m.actionList, cmd = m.actionList.Update(msg)
return m, cmd
}
func (m Model) View() string {
header := m.renderHeader()
notice := m.renderNotice()
listView := strings.TrimRight(m.list.View(), "\n")
helpView := strings.TrimRight(m.help.View(listKeys), "\n")
bg := strings.Join([]string{header, listView, notice, helpView}, "\n")
if m.state == statePopup && m.selectedDev != nil {
return placeOverlay(bg, m.renderActionSelect(), m.width, m.height)
}
return bg
}
func (m Model) renderHeader() string {
title := headerStyle.Render("USBGuard-tui")
switch m.daemonStatus {
case "active":
return title + mutedStyle.Render(" - ") + daemonActiveStyle.Render("active")
case "":
return title
default:
return title + mutedStyle.Render(" - ") + daemonOtherStyle.Render(m.daemonStatus)
}
}
func (m Model) renderNotice() string {
if m.notice == "" {
return ""
}
return warnStyle.Render(m.notice)
}
func (m Model) renderActionSelect() string {
dev := m.selectedDev
color := statusColors[dev.Status]
innerW := m.actionListInnerWidth()
title := popupTitleStyle.Copy().Foreground(color).Width(innerW).Render(dev.Name)
hint := lipgloss.NewStyle().Foreground(colorMuted).Width(innerW).Render("↑↓ navigate enter confirm esc cancel")
content := strings.Join([]string{title, m.actionList.View(), "", hint}, "\n")
return popupStyle.Width(innerW).Render(content)
}
func (m Model) popupOuterWidth() int {
w := m.width - 6
if w > 60 {
w = 60
}
if w < 32 {
w = 32
}
return w
}
func (m Model) actionListInnerWidth() int {
return m.popupOuterWidth() - 8 // border(2) + padding_h(6)
}
// updateActionListSize sizes the action list and toggles pagination based on available space.
// When there is enough room for all items: pagination is hidden and height is set exactly,
// avoiding the phantom line that bubbles/list reserves when showPagination=true.
// When space is limited: pagination is shown naturally by bubbles/list.
func (m *Model) updateActionListSize() {
const items = 6
innerW := m.actionListInnerWidth()
// popup overhead: border(2) + padding_v(2) + title(1) + blank(1) + hint(1) = 7
available := m.height - 7 - 2 // 2 lines margin
if available >= items {
m.actionList.SetShowPagination(false)
m.actionList.SetSize(innerW, items)
} else {
m.actionList.SetShowPagination(true)
h := available
if h < 2 {
h = 2
}
m.actionList.SetSize(innerW, h)
}
}
func (m Model) listHeight() int {
helpH := lipgloss.Height(strings.TrimRight(m.help.View(listKeys), "\n"))
return m.height - 1 - helpH - 1 // header - help - notice
}
func (m Model) selectedDevID() int {
if item := m.list.SelectedItem(); item != nil {
return item.(guard.Device).ID
}
return -1
}
func tickCmd() tea.Cmd {
return tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func fetchDevices() tea.Msg {
devices, err := guard.ListDevices()
if err != nil {
return actionMsg{err: err}
}
return devicesMsg(devices)
}
func fetchDaemonStatus() tea.Msg {
return daemonStatusMsg(guard.DaemonStatus())
}
func doAction(id int, fn func(int, bool) error, permanent bool) tea.Cmd {
return func() tea.Msg {
return actionMsg{err: fn(id, permanent)}
}
}
+51
View File
@@ -0,0 +1,51 @@
package ui
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
var dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
// placeOverlay renders fg centered over bg, with bg stripped and rendered dim gray.
func placeOverlay(bg, fg string, width, height int) string {
fgLines := strings.Split(fg, "\n")
fgH := len(fgLines)
fgW := 0
for _, l := range fgLines {
if w := lipgloss.Width(l); w > fgW {
fgW = w
}
}
bgLines := strings.Split(bg, "\n")
x0 := (width - fgW) / 2
y0 := (height - fgH) / 2
result := make([]string, height)
for i := 0; i < height; i++ {
raw := ""
if i < len(bgLines) {
raw = ansi.Strip(bgLines[i])
}
if w := lipgloss.Width(raw); w < width {
raw += strings.Repeat(" ", width-w)
}
fgIdx := i - y0
if fgIdx < 0 || fgIdx >= fgH {
result[i] = dimStyle.Render(raw)
} else {
fgLine := fgLines[fgIdx]
fgLineW := lipgloss.Width(fgLine)
left := ansi.Truncate(raw, x0, "")
right := ansi.Cut(raw, x0+fgLineW, width)
result[i] = dimStyle.Render(left) + fgLine + dimStyle.Render(right)
}
}
return strings.Join(result, "\n")
}
+52
View File
@@ -0,0 +1,52 @@
package ui
import (
"github.com/anotherhadi/usbguard-tui/internal/guard"
"github.com/charmbracelet/lipgloss"
)
var (
colorAllowed = lipgloss.Color("28")
colorAllowedSelected = lipgloss.Color("42")
colorBlocked = lipgloss.Color("124")
colorBlockedSelected = lipgloss.Color("196")
colorRejected = lipgloss.Color("130")
colorRejectedSelected = lipgloss.Color("214")
colorMuted = lipgloss.Color("240")
colorAccent = lipgloss.Color("99")
)
var statusColors = map[guard.Status]lipgloss.Color{
guard.Allowed: colorAllowed,
guard.Blocked: colorBlocked,
guard.Rejected: colorRejected,
}
var statusColorsSelected = map[guard.Status]lipgloss.Color{
guard.Allowed: colorAllowedSelected,
guard.Blocked: colorBlockedSelected,
guard.Rejected: colorRejectedSelected,
}
var (
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(colorAccent).
PaddingLeft(1)
daemonActiveStyle = lipgloss.NewStyle().Foreground(colorAllowedSelected)
daemonOtherStyle = lipgloss.NewStyle().Foreground(colorMuted)
mutedStyle = lipgloss.NewStyle().Foreground(colorMuted)
popupStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorAccent).
Padding(1, 3)
popupTitleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1)
keyHintStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
warnStyle = lipgloss.NewStyle().Foreground(colorRejected)
errStyle = lipgloss.NewStyle().Foreground(colorBlocked).Bold(true)
)