From 09b054cc5c10c9249973b44ba8c6ac260095c1d8 Mon Sep 17 00:00:00 2001
From: Hadi <112569860+anotherhadi@users.noreply.github.com>
Date: Thu, 30 Apr 2026 17:33:42 +0200
Subject: [PATCH] Init
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
---
CONTRIBUTING.md | 10 ++
LICENSE | 21 +++
README.md | 78 ++++++++++
flake.lock | 27 ++++
flake.nix | 41 +++++
go.mod | 34 +++++
go.sum | 60 ++++++++
internal/guard/client.go | 79 ++++++++++
internal/guard/device.go | 88 +++++++++++
internal/guard/errors.go | 9 ++
internal/ui/delegate.go | 100 ++++++++++++
internal/ui/keys.go | 45 ++++++
internal/ui/model.go | 319 +++++++++++++++++++++++++++++++++++++++
internal/ui/overlay.go | 51 +++++++
internal/ui/styles.go | 52 +++++++
main.go | 23 +++
16 files changed, 1037 insertions(+)
create mode 100644 CONTRIBUTING.md
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 flake.lock
create mode 100644 flake.nix
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 internal/guard/client.go
create mode 100644 internal/guard/device.go
create mode 100644 internal/guard/errors.go
create mode 100644 internal/ui/delegate.go
create mode 100644 internal/ui/keys.go
create mode 100644 internal/ui/model.go
create mode 100644 internal/ui/overlay.go
create mode 100644 internal/ui/styles.go
create mode 100644 main.go
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)
+ }
+}