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
+10
View File
@@ -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.
+21
View File
@@ -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.
+78
View File
@@ -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
<details>
<summary>Go install</summary>
```sh
go install github.com/anotherhadi/usbguard-tui@latest
```
</details>
<details>
<summary>Build from source</summary>
```sh
git clone https://github.com/anotherhadi/usbguard-tui
cd usbguard-tui
go build -o usbguard-tui .
```
</details>
<details>
<summary>Nix run</summary>
```sh
nix run github:anotherhadi/usbguard-tui
```
</details>
<details>
<summary>NixOS: system installation</summary>
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 ];
```
</details>
## Usage
```
usbguard-tui
```
The device list refreshes automatically every 2 seconds.
---
<div align="center">
<a href="https://github.com/anotherhadi/usbguard-tui">github</a> |
<a href="https://gitlab.com/anotherhadi_mirror/usbguard-tui">gitlab (mirror)</a> |
<a href="https://git.hadi.icu/anotherhadi/usbguard-tui">gitea (mirror)</a>
</div
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1777268161,
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+41
View File
@@ -0,0 +1,41 @@
{
description = "A terminal UI for managing USB devices via usbguard.";
inputs = {nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";};
outputs = {
self,
nixpkgs,
}: let
supportedSystems = ["x86_64-linux" "aarch64-linux"];
forAllSystems = f:
nixpkgs.lib.genAttrs supportedSystems
(system: f system (import nixpkgs {inherit system;}));
pname = "usbguard-tui";
version = "1.0.0";
ldflags = ["-s" "-w"];
in {
packages = forAllSystems (system: pkgs: {
"${pname}" = pkgs.buildGoModule {
inherit pname version ldflags;
src = ./.;
outputs = ["out"];
vendorHash = "sha256-SMhllO87YlmySHroKfPq1pHb67CwHaZ3XMp3t983etc=";
meta = with pkgs.lib; {
description = "A terminal UI for managing USB devices via usbguard.";
homepage = "https://github.com/anotherhadi/usbguard-tui";
platforms = platforms.unix;
};
};
});
defaultPackage =
forAllSystems (system: pkgs: self.packages.${system}.${pname});
};
}
+34
View File
@@ -0,0 +1,34 @@
module github.com/anotherhadi/usbguard-tui
go 1.26.2
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.11.6
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+60
View File
@@ -0,0 +1,60 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+79
View File
@@ -0,0 +1,79 @@
package guard
import (
"errors"
"os/exec"
"strconv"
"strings"
)
func Check() error {
_, err := exec.LookPath("usbguard")
if err != nil {
return ErrNotFound
}
return nil
}
func ListDevices() ([]Device, error) {
out, err := exec.Command("usbguard", "list-devices").Output()
if err != nil {
return nil, wrapExecError(err)
}
var devices []Device
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
d, err := parseLine(line)
if err == nil {
devices = append(devices, d)
}
}
return devices, nil
}
func AllowDevice(id int, permanent bool) error { return applyPolicy("allow-device", id, permanent) }
func BlockDevice(id int, permanent bool) error { return applyPolicy("block-device", id, permanent) }
func RejectDevice(id int, permanent bool) error { return applyPolicy("reject-device", id, permanent) }
func DaemonStatus() string {
out, err := exec.Command("systemctl", "is-active", "usbguard").Output()
if err != nil {
return "unknown"
}
return strings.TrimSpace(string(out))
}
func applyPolicy(cmd string, id int, permanent bool) error {
args := []string{cmd}
if permanent {
args = append(args, "-p")
}
args = append(args, strconv.Itoa(id))
out, err := exec.Command("usbguard", args...).CombinedOutput()
if err != nil {
return classifyError(string(out))
}
return nil
}
func wrapExecError(err error) error {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return classifyError(string(exitErr.Stderr))
}
return err
}
func classifyError(output string) error {
lower := strings.ToLower(output)
switch {
case strings.Contains(lower, "permission denied"), strings.Contains(lower, "not authorized"):
return ErrPermission
case strings.Contains(lower, "read-only"), strings.Contains(lower, "immutable"):
return ErrReadOnly
default:
return errors.New(strings.TrimSpace(output))
}
}
+88
View File
@@ -0,0 +1,88 @@
package guard
import (
"errors"
"fmt"
"strconv"
"strings"
)
type Status string
const (
Allowed Status = "allow"
Blocked Status = "block"
Rejected Status = "reject"
)
type Device struct {
ID int
Name string
Status Status
VidPid string
}
func (d Device) Title() string { return d.Name }
func (d Device) Description() string { return fmt.Sprintf("id:%-3d %s", d.ID, d.VidPid) }
func (d Device) FilterValue() string { return d.Name + " " + d.VidPid }
// parseLine parses a line from "usbguard list-devices":
// 1: allow id 04b3:301b serial "" name "USB Hub" hash "..." via-port "usb1"
func parseLine(line string) (Device, error) {
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
return Device{}, errors.New("invalid format")
}
id, err := strconv.Atoi(strings.TrimSpace(line[:colonIdx]))
if err != nil {
return Device{}, err
}
rest := strings.TrimSpace(line[colonIdx+1:])
parts := strings.Fields(rest)
if len(parts) < 1 {
return Device{}, errors.New("missing status")
}
status := Status(parts[0])
name := extractField(rest, "name")
if name == "" {
name = fmt.Sprintf("Unknown Device #%d", id)
}
return Device{
ID: id,
Name: name,
Status: status,
VidPid: extractUnquoted(rest, "id"),
}, nil
}
func extractField(rule, field string) string {
prefix := field + ` "`
idx := strings.Index(rule, prefix)
if idx < 0 {
return ""
}
rest := rule[idx+len(prefix):]
end := strings.Index(rest, `"`)
if end < 0 {
return ""
}
return rest[:end]
}
func extractUnquoted(rule, field string) string {
prefix := field + " "
idx := strings.Index(rule, prefix)
if idx < 0 {
return ""
}
rest := rule[idx+len(prefix):]
end := strings.IndexAny(rest, " \t\n")
if end < 0 {
return rest
}
return rest[:end]
}
+9
View File
@@ -0,0 +1,9 @@
package guard
import "errors"
var (
ErrNotFound = errors.New("usbguard not found in PATH")
ErrPermission = errors.New("insufficient permissions to manage devices")
ErrReadOnly = errors.New("rules file is read-only")
)
+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)
)
+23
View File
@@ -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)
}
}