commit 09b054cc5c10c9249973b44ba8c6ac260095c1d8
Author: Hadi <112569860+anotherhadi@users.noreply.github.com>
Date: Thu Apr 30 17:33:42 2026 +0200
Init
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..7b8ae86
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,10 @@
+# Contributing
+
+Everybody is invited and welcome to contribute. There is a lot to do... Check the issues!
+
+The process is straight-forward.
+
+- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1)
+- Fork this git repository
+- Write your changes (bug fixes, new features, ...).
+- Create a Pull Request against the main branch.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7ce7478
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Hadi
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..36981cd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,78 @@
+```
+▖▖▄▖▄ ▄▖ ▌ ▄▖▖▖▄▖
+▌▌▚ ▙▘▌ ▌▌▀▌▛▘▛▌ ▐ ▌▌▐
+▙▌▄▌▙▘▙▌▙▌█▌▌ ▙▌ ▐ ▙▌▟▖
+
+```
+
+# USBGuard TUI
+
+A terminal UI for managing USB devices via [usbguard](https://usbguard.github.io/).
+Built with [bubbletea](https://github.com/charmbracelet/bubbletea) & Goland!
+
+## Requirements
+
+- usbguard installed and the daemon running
+- Sufficient privileges to communicate with the usbguard daemon socket
+
+## Installation
+
+
+Go install
+
+```sh
+go install github.com/anotherhadi/usbguard-tui@latest
+```
+
+
+
+
+Build from source
+
+```sh
+git clone https://github.com/anotherhadi/usbguard-tui
+cd usbguard-tui
+go build -o usbguard-tui .
+```
+
+
+
+
+Nix run
+
+```sh
+nix run github:anotherhadi/usbguard-tui
+```
+
+
+
+
+NixOS: system installation
+
+Add the flake input and include the package in your configuration:
+
+```nix
+# flake.nix
+inputs.usbguard-tui.url = "github:anotherhadi/usbguard-tui";
+
+# configuration.nix / home.nix
+environment.systemPackages = [ inputs.usbguard-tui.packages.${system}.default ];
+```
+
+
+
+## Usage
+
+```
+usbguard-tui
+```
+
+The device list refreshes automatically every 2 seconds.
+
+---
+
+
"+a.label))
+ } else {
+ fmt.Fprintf(w, " %s", a.label)
+ }
+}
diff --git a/internal/ui/keys.go b/internal/ui/keys.go
new file mode 100644
index 0000000..332e72b
--- /dev/null
+++ b/internal/ui/keys.go
@@ -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"))
diff --git a/internal/ui/model.go b/internal/ui/model.go
new file mode 100644
index 0000000..c081873
--- /dev/null
+++ b/internal/ui/model.go
@@ -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)}
+ }
+}
diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go
new file mode 100644
index 0000000..eee1836
--- /dev/null
+++ b/internal/ui/overlay.go
@@ -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")
+}
diff --git a/internal/ui/styles.go b/internal/ui/styles.go
new file mode 100644
index 0000000..6781373
--- /dev/null
+++ b/internal/ui/styles.go
@@ -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)
+)
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..83a5942
--- /dev/null
+++ b/main.go
@@ -0,0 +1,23 @@
+package main
+
+import (
+ "fmt"
+ "os"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/anotherhadi/usbguard-tui/internal/guard"
+ "github.com/anotherhadi/usbguard-tui/internal/ui"
+)
+
+func main() {
+ if err := guard.Check(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+
+ p := tea.NewProgram(ui.New(), tea.WithAltScreen())
+ if _, err := p.Run(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}