23 Commits

Author SHA1 Message Date
Hadi 4199b6aa7f v1.2.0
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-08-20 20:31:14 +02:00
Hadi ccc353261b Shorthelp/Fullhelp
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-08-20 20:30:55 +02:00
Hadi e77eaf4e2c Move UI & make a root model.
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-08-20 20:28:24 +02:00
Hadi e99f32ded8 Print on exit -> copy to clipboard
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-08-20 19:51:33 +02:00
Hadi 1b19643db4 fix layout
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-08-20 19:31:36 +02:00
Hadi bbab824a1d New features and fixes: new shortcuts, UI/UX, better status resolution
Signed-off-by: Hadi <hadi@example.fr>
2026-08-20 15:52:24 +02:00
Hadi 031ff48696 v1.1.0
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-26 20:14:22 +02:00
Hadi 95d7f368e1 Add ilovetui integration
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-26 20:14:08 +02:00
Hadi 64b36e716c Merge branch 'main' of github.com:anotherhadi/usbguard-tui 2026-05-11 20:10:07 +02:00
Hadi 85184dafca Add FUNDING.yml
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-11 20:10:01 +02:00
Hadi 6db3a32758 Merge branch 'main' of github.com:anotherhadi/usbguard-tui 2026-05-06 14:42:02 +02:00
Hadi 1ac92a5ace Print nixos rules on exit
Signed-off-by: Hadi <hadi@example.com>
2026-05-06 14:41:50 +02:00
Hadi 2c54df832c Use the hash instead of ID to get the Status
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-05 19:47:01 +02:00
Hadi ecd12f18e0 Remove dead code
Signed-off-by: Hadi <hadi@example.com>
2026-05-05 09:50:49 +02:00
Hadi 787d4ac0f1 fmt
Signed-off-by: Hadi <hadi@example.com>
2026-05-05 09:42:34 +02:00
Hadi 8c250389b3 fix nixos readonly config
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-04 23:56:13 +02:00
Hadi b19739b0a6 add features section
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-04 22:48:45 +02:00
Hadi 20f9b7cf89 Edit mouse actions
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-04 22:48:38 +02:00
Hadi abe6b5dde5 Fix typo 2026-05-04 19:13:55 +02:00
Hadi c7f42c1a12 Move contributing.md
Signed-off-by: Hadi <hadi@example.com>
2026-05-04 15:01:54 +02:00
Hadi 3731160024 Add demo
Signed-off-by: Hadi <hadi@example.com>
2026-05-04 15:01:47 +02:00
Hadi 62cba43e15 add the "default" package
Signed-off-by: Hadi <hadi@example.com>
2026-05-04 14:44:39 +02:00
Hadi f1dd37cfc6 Fix version on flake.nix
Signed-off-by: Hadi <hadi@example.com>
2026-05-04 13:21:03 +02:00
25 changed files with 1196 additions and 556 deletions
+1
View File
@@ -0,0 +1 @@
ko_fi: anotherhadi
Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

+27
View File
@@ -0,0 +1,27 @@
Output ./assets/demo.gif
Require usbguard-tui
Set Shell "zsh"
Set FontSize 32
Set Width 1500
Set Height 1000
Type "usbguard-tui"
Sleep 500ms
Enter
Sleep 1s
Down
Sleep 200ms
Down
Sleep 200ms
Enter
Sleep 1s
Down Sleep 200ms
Down Sleep 200ms
Sleep 1s
Enter
Sleep 1s
Type "Q"
+2
View File
@@ -0,0 +1,2 @@
/go.work
/go.work.sum
+14 -1
View File
@@ -15,7 +15,20 @@ A terminal UI for managing USB devices via [usbguard](https://usbguard.github.io
USBGuard is a software framework for implementing a USB device authorization policy (allowlisting/blocklisting). It protects your system against rogue USB devices by scanning them and checking their parameters against a set of rules. USBGuard is a software framework for implementing a USB device authorization policy (allowlisting/blocklisting). It protects your system against rogue USB devices by scanning them and checking their parameters against a set of rules.
Built with [bubbletea](https://github.com/charmbracelet/bubbletea) & Goland! <img alt="USBGuard-tui demo" src="./.github/assets/demo.gif" width="600" />
Built with [bubbletea](https://github.com/charmbracelet/bubbletea) & Golang!
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
## Features
- List all connected USB devices with their current status
- Allow, block, or reject devices: temporarily or permanently
- Action popup for quick device management
- Filter devices by name with `/`
- Auto-refresh
- Keyboard shortcuts for all actions (`a`/`A`, `b`/`B`, `e`/`E`, ...)
## Requirements ## Requirements
+55
View File
@@ -0,0 +1,55 @@
package main
import (
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/app"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/usbguard-tui/ui"
)
type appModel struct {
core ui.Model
modals modal.Model
notif notification.Model
}
func newApp() appModel {
return appModel{
core: ui.New(),
modals: modal.New(),
notif: notification.New(),
}
}
func (a appModel) Init() tea.Cmd {
return tea.Batch(a.core.Init(), a.modals.Init(), a.notif.Init())
}
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(app.QuitMsg); ok {
return a, tea.Quit
}
a.core.ModalOpen = a.modals.Open()
var coreCmd tea.Cmd
a.core, coreCmd = a.core.Update(msg)
var modalCmd, notifCmd tea.Cmd
a.modals, modalCmd = a.modals.Update(msg)
a.notif, notifCmd = a.notif.Update(msg)
return a, tea.Batch(coreCmd, modalCmd, notifCmd)
}
func (a appModel) View() tea.View {
bg := a.core.View()
bg = a.modals.Render(bg)
bg = a.notif.Render(bg)
return tea.View{
Content: bg,
AltScreen: true,
WindowTitle: "USBGuard TUI",
MouseMode: tea.MouseModeCellMotion,
}
}
+7 -7
View File
@@ -14,18 +14,18 @@
(system: f system (import nixpkgs {inherit system;})); (system: f system (import nixpkgs {inherit system;}));
pname = "usbguard-tui"; pname = "usbguard-tui";
version = "1.0.0"; version = "1.2.0";
ldflags = ["-s" "-w" "-X main.version=${version}"]; ldflags = ["-s" "-w" "-X main.version=${version}"];
in { in {
packages = forAllSystems (system: pkgs: { packages = forAllSystems (system: pkgs: let
"${pname}" = pkgs.buildGoModule { pkg = pkgs.buildGoModule {
inherit pname version ldflags; inherit pname version ldflags;
src = ./.; src = ./.;
outputs = ["out"]; outputs = ["out"];
vendorHash = "sha256-NfmNdQaISob3ZguQnwgfXHUDUFION988ucp18q996pM="; vendorHash = "sha256-8QP2FbNmvqrJhFe3Ia7tPhkxMeFWBhvr4Zr9kpV9bR4=";
meta = with pkgs.lib; { meta = with pkgs.lib; {
description = "A terminal UI for managing USB devices via usbguard."; description = "A terminal UI for managing USB devices via usbguard.";
@@ -33,9 +33,9 @@
platforms = platforms.unix; platforms = platforms.unix;
}; };
}; };
in {
"${pname}" = pkg;
default = pkg;
}); });
defaultPackage =
forAllSystems (system: pkgs: self.packages.${system}.${pname});
}; };
} }
+15
View File
@@ -6,24 +6,39 @@ require (
charm.land/bubbles/v2 v2.1.0 charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.6 charm.land/bubbletea/v2 v2.0.6
charm.land/lipgloss/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.3
github.com/anotherhadi/ilovetui v0.2.2
github.com/charmbracelet/x/ansi v0.11.7 github.com/charmbracelet/x/ansi v0.11.7
) )
require ( require (
charm.land/glamour/v2 v2.0.0 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.24.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+41
View File
@@ -2,20 +2,38 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anotherhadi/ilovetui v0.2.2 h1:ZOwyZRxFBZKVJeEAR+lg0xeQfsMZuymtKLwBV0MnBYY=
github.com/anotherhadi/ilovetui v0.2.2/go.mod h1:gAn69amLM3z0x8PV4obyf5bJntL/PTZusEuhu/xdFIY=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 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/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -26,12 +44,22 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 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/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 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/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -40,9 +68,22 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 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/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+145 -13
View File
@@ -2,6 +2,7 @@ package guard
import ( import (
"errors" "errors"
"os"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
@@ -20,37 +21,115 @@ func ListDevices() ([]Device, error) {
if err != nil { if err != nil {
return nil, wrapExecError(err) return nil, wrapExecError(err)
} }
rules := listRules()
deviceRuleText := devicePolicyRuleText()
permanentTexts, havePermanentTexts := permanentRuleTexts()
implicitTarget, haveImplicitTarget := implicitPolicyTarget()
var devices []Device var devices []Device
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" { if line == "" {
continue continue
} }
d, err := parseLine(line) d, err := parseLine(line)
if err == nil { if err != nil {
d.Permanent = rules[d.VidPid] == d.Status continue
devices = append(devices, d)
} }
d.RuleState = resolveRuleState(d, deviceRuleText, permanentTexts, havePermanentTexts, implicitTarget, haveImplicitTarget)
devices = append(devices, d)
} }
return devices, nil return devices, nil
} }
func listRules() map[string]Status { func resolveRuleState(d Device, deviceRuleText map[int]string, permanentTexts map[string]bool, havePermanentTexts bool, implicitTarget Status, haveImplicitTarget bool) RuleState {
out, err := exec.Command("usbguard", "list-rules").Output() text, matched := deviceRuleText[d.ID]
if matched {
if !havePermanentTexts {
return RulePermanent
}
if permanentTexts[text] {
return RulePermanent
}
return RuleTemporary
}
if !haveImplicitTarget || d.Status != implicitTarget {
return RuleTemporary
}
return RuleDefault
}
func implicitPolicyTarget() (Status, bool) {
out, err := exec.Command("usbguard", "get-parameter", "ImplicitPolicyTarget").Output()
if err != nil {
return "", false
}
return Status(strings.TrimSpace(string(out))), true
}
func DefaultPolicy() Status {
target, _ := implicitPolicyTarget()
return target
}
func devicePolicyRuleText() map[int]string {
out, err := exec.Command("usbguard", "list-rules", "-d").Output()
if err != nil { if err != nil {
return nil return nil
} }
rules := make(map[string]Status) result := make(map[int]string)
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { var currentRuleText string
for _, line := range strings.Split(string(out), "\n") {
if line == "" { if line == "" {
continue continue
} }
d, err := parseLine(line) if line[0] == ' ' || line[0] == '\t' {
if err == nil { trimmed := strings.TrimLeft(line, " \t")
rules[d.VidPid] = d.Status colonIdx := strings.Index(trimmed, ":")
if colonIdx < 0 {
continue
}
devID, err := strconv.Atoi(strings.TrimSpace(trimmed[:colonIdx]))
if err != nil {
continue
}
result[devID] = currentRuleText
continue
} }
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
continue
}
currentRuleText = normalizeRuleText(line[colonIdx+1:])
} }
return rules return result
}
func permanentRuleTexts() (map[string]bool, bool) {
path := ruleFilePath()
if path == "" {
return nil, false
}
data, err := os.ReadFile(path)
if err != nil {
return nil, false
}
texts := make(map[string]bool)
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
texts[normalizeRuleText(line)] = true
}
return texts, true
}
func normalizeRuleText(s string) string {
return strings.Join(strings.Fields(s), " ")
} }
func AllowDevice(id int, permanent bool) error { return applyPolicy("allow-device", id, permanent) } func AllowDevice(id int, permanent bool) error { return applyPolicy("allow-device", id, permanent) }
@@ -86,10 +165,63 @@ func wrapExecError(err error) error {
return err return err
} }
func IsRulesManaged() bool {
return strings.HasPrefix(ruleFilePath(), "/nix/store/")
}
func RulesWritable() (bool, bool) {
path := ruleFilePath()
if path == "" {
return false, false
}
f, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
return false, true
}
f.Close()
return true, true
}
func ruleFilePath() string {
out, err := exec.Command("systemctl", "cat", "usbguard").Output()
if err != nil {
return ""
}
configPath := extractConfigPath(string(out))
if configPath == "" {
return ""
}
return parseRuleFilePath(configPath)
}
func extractConfigPath(s string) string {
fields := strings.Fields(s)
for i, f := range fields {
if f == "-c" && i+1 < len(fields) {
return fields[i+1]
}
}
return ""
}
func parseRuleFilePath(configPath string) string {
data, err := os.ReadFile(configPath)
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
if after, ok := strings.CutPrefix(line, "RuleFile="); ok {
return strings.TrimSpace(after)
}
}
return ""
}
func classifyError(output string) error { func classifyError(output string) error {
lower := strings.ToLower(output) lower := strings.ToLower(output)
switch { switch {
case strings.Contains(lower, "permission denied"), strings.Contains(lower, "not authorized"): case strings.Contains(lower, "permission denied"), strings.Contains(lower, "not authorized"),
strings.Contains(lower, "operation not permitted"):
return ErrPermission return ErrPermission
case strings.Contains(lower, "read-only"), strings.Contains(lower, "immutable"): case strings.Contains(lower, "read-only"), strings.Contains(lower, "immutable"):
return ErrReadOnly return ErrReadOnly
+15 -3
View File
@@ -15,20 +15,27 @@ const (
Rejected Status = "reject" Rejected Status = "reject"
) )
type RuleState string
const (
RuleDefault RuleState = "default"
RuleTemporary RuleState = "temporary"
RulePermanent RuleState = "permanent"
)
type Device struct { type Device struct {
ID int ID int
Name string Name string
Status Status Status Status
VidPid string VidPid string
Permanent bool Hash string
RuleState RuleState
} }
func (d Device) Title() string { return d.Name } 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) Description() string { return fmt.Sprintf("id:%-3d %s", d.ID, d.VidPid) }
func (d Device) FilterValue() string { return d.Name + " " + 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) { func parseLine(line string) (Device, error) {
colonIdx := strings.Index(line, ":") colonIdx := strings.Index(line, ":")
if colonIdx < 0 { if colonIdx < 0 {
@@ -57,6 +64,7 @@ func parseLine(line string) (Device, error) {
Name: name, Name: name,
Status: status, Status: status,
VidPid: extractUnquoted(rest, "id"), VidPid: extractUnquoted(rest, "id"),
Hash: extractField(rest, "hash"),
}, nil }, nil
} }
@@ -74,6 +82,10 @@ func extractField(rule, field string) string {
return rest[:end] return rest[:end]
} }
func NixOSRule(dev Device, status Status) string {
return fmt.Sprintf("%s id %s name \"%s\"", status, dev.VidPid, dev.Name)
}
func extractUnquoted(rule, field string) string { func extractUnquoted(rule, field string) string {
prefix := field + " " prefix := field + " "
idx := strings.Index(rule, prefix) idx := strings.Index(rule, prefix)
+1 -1
View File
@@ -5,5 +5,5 @@ import "errors"
var ( var (
ErrNotFound = errors.New("usbguard not found in PATH") ErrNotFound = errors.New("usbguard not found in PATH")
ErrPermission = errors.New("insufficient permissions to manage devices") ErrPermission = errors.New("insufficient permissions to manage devices")
ErrReadOnly = errors.New("rules file is read-only") ErrReadOnly = errors.New("rules file is not writable")
) )
-45
View File
@@ -1,45 +0,0 @@
package ui
import "charm.land/bubbles/v2/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", "tab", "l"), key.WithHelp("enter/l", "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", "esc", "ctrl+c"), key.WithHelp("q/esc", "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("e"), key.WithHelp("e", "reject")),
RejectPerm: key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "reject (perm)")),
}
var cancelKey = key.NewBinding(key.WithKeys("esc", "q", "ctrl+c"), key.WithHelp("esc/q", "cancel"))
-349
View File
@@ -1,349 +0,0 @@
package ui
import (
"strings"
"time"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/usbguard-tui/internal/guard"
)
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()
l.KeyMap.CursorUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"))
l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"))
l.Styles = list.DefaultStyles(true)
filterStyles := textinput.DefaultStyles(true)
filterStyles.Focused.Prompt = filterStyles.Focused.Prompt.Foreground(colorAccent)
filterStyles.Blurred.Prompt = filterStyles.Blurred.Prompt.Foreground(colorAccent)
l.Styles.Filter = filterStyles
h := help.New()
h.Styles = help.DefaultStyles(true)
return Model{
state: stateList,
list: l,
actionList: makeActionList(),
help: h,
}
}
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.SetWidth(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.KeyPressMsg:
if m.state == statePopup {
return m.updatePopup(msg)
}
return m.updateList(msg)
case tea.MouseClickMsg:
if m.state == statePopup {
var cmd tea.Cmd
m.actionList, cmd = m.actionList.Update(msg)
return m, cmd
}
case tea.MouseWheelMsg:
if m.state == statePopup {
var cmd tea.Cmd
m.actionList, cmd = m.actionList.Update(msg)
return m, cmd
}
}
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.KeyPressMsg) (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.KeyPressMsg) (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() tea.View {
return tea.View{
Content: m.renderContent(),
AltScreen: true,
WindowTitle: "USBGuard TUI",
MouseMode: tea.MouseModeCellMotion,
}
}
func (m Model) renderContent() 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.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
@@ -1,51 +0,0 @@
package ui
import (
"strings"
"charm.land/lipgloss/v2"
"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")
}
-54
View File
@@ -1,54 +0,0 @@
package ui
import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/usbguard-tui/internal/guard"
)
var (
colorAllowed color.Color = lipgloss.Color("28")
colorAllowedSelected color.Color = lipgloss.Color("42")
colorBlocked color.Color = lipgloss.Color("124")
colorBlockedSelected color.Color = lipgloss.Color("196")
colorRejected color.Color = lipgloss.Color("130")
colorRejectedSelected color.Color = lipgloss.Color("214")
colorMuted color.Color = lipgloss.Color("240")
colorAccent color.Color = lipgloss.Color("99")
)
var statusColors = map[guard.Status]color.Color{
guard.Allowed: colorAllowed,
guard.Blocked: colorBlocked,
guard.Rejected: colorRejected,
}
var statusColorsSelected = map[guard.Status]color.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)
)
+1 -2
View File
@@ -6,7 +6,6 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/usbguard-tui/internal/guard" "github.com/anotherhadi/usbguard-tui/internal/guard"
"github.com/anotherhadi/usbguard-tui/internal/ui"
) )
var version = "dev" var version = "dev"
@@ -22,7 +21,7 @@ func main() {
os.Exit(1) os.Exit(1)
} }
p := tea.NewProgram(ui.New()) p := tea.NewProgram(newApp())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
+83
View File
@@ -0,0 +1,83 @@
package ui
import (
"fmt"
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/usbguard-tui/internal/guard"
)
type actionModal struct {
list list.Model
dev guard.Device
rulesManaged bool
}
func newActionModal(dev guard.Device, rulesManaged bool) tea.Model {
return actionModal{
list: makeActionList(rulesManaged),
dev: dev,
rulesManaged: rulesManaged,
}
}
func actionModalStyle(status guard.Status) modal.Styles {
s := modal.DefaultStyles()
if clr, ok := statusColors[status]; ok {
s.Title = s.Title.Foreground(clr)
}
return s
}
func (a actionModal) Init() tea.Cmd { return nil }
func (a actionModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch {
case key.Matches(msg, cancelKey):
return a, modal.Close()
case key.Matches(msg, listKeys.Open):
item := a.list.SelectedItem()
if item == nil {
return a, nil
}
it := item.(actionItem)
if it.nixos {
rule := guard.NixOSRule(a.dev, it.status)
key := a.dev.VidPid
return a, tea.Batch(modal.Close(), func() tea.Msg { return nixRuleMsg{key: key, rule: rule} })
}
return a, tea.Batch(modal.Close(), doAction(a.dev.ID, it.fn, it.permanent))
}
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
a.list.CursorUp()
case tea.MouseWheelDown:
a.list.CursorDown()
}
return a, nil
}
var cmd tea.Cmd
a.list, cmd = a.list.Update(msg)
return a, cmd
}
func (a actionModal) View() tea.View {
hintStyle := lipgloss.NewStyle().Foreground(style.S.Muted)
parts := []string{a.list.View(), ""}
if a.rulesManaged {
parts = append(parts, hintStyle.Render(fmt.Sprintf("[NixOS: perm rules queued — press %s to copy]", listKeys.CopyRules.Help().Key)))
}
parts = append(parts, hintStyle.Render("↑↓ navigate enter confirm esc cancel"))
return tea.NewView(strings.Join(parts, "\n"))
}
+31 -30
View File
@@ -2,20 +2,25 @@ package ui
import ( import (
"fmt" "fmt"
"image/color"
"io" "io"
"charm.land/bubbles/v2/list" "charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/usbguard-tui/internal/guard" "github.com/anotherhadi/usbguard-tui/internal/guard"
) )
// deviceDelegate renders device list items with status colors. var ruleStateIndicators = map[guard.RuleState]string{
guard.RulePermanent: "● perm",
guard.RuleTemporary: "○ tmp",
guard.RuleDefault: "· default",
}
type deviceDelegate struct{} type deviceDelegate struct{}
func (d deviceDelegate) Height() int { return 2 } func (d deviceDelegate) Height() int { return 2 }
func (d deviceDelegate) Spacing() int { return 0 } func (d deviceDelegate) Spacing() int { return 0 }
func (d deviceDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 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) { func (d deviceDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
@@ -26,66 +31,62 @@ func (d deviceDelegate) Render(w io.Writer, m list.Model, index int, item list.I
selected := index == m.Index() selected := index == m.Index()
var clr color.Color colorMap := statusColors
if selected { if selected {
var ok bool colorMap = statusColorsSelected
clr, ok = statusColorsSelected[dev.Status] }
if !ok { clr, ok := colorMap[dev.Status]
clr = colorMuted if !ok {
} clr = style.S.Muted
} else {
var ok bool
clr, ok = statusColors[dev.Status]
if !ok {
clr = colorMuted
}
} }
var nameStyle, descStyle lipgloss.Style var nameStyle, descStyle lipgloss.Style
if selected { if selected {
nameStyle = lipgloss.NewStyle(). nameStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true). Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(colorAccent). BorderForeground(style.S.Primary).
Foreground(clr). Foreground(clr).
Bold(true). Bold(true).
PaddingLeft(1) PaddingLeft(1)
descStyle = lipgloss.NewStyle(). descStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true). Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(colorAccent). BorderForeground(style.S.Primary).
Foreground(colorMuted). Foreground(style.S.Muted).
PaddingLeft(1) PaddingLeft(1)
} else { } else {
nameStyle = lipgloss.NewStyle().Foreground(clr).PaddingLeft(2) nameStyle = lipgloss.NewStyle().Foreground(clr).PaddingLeft(2)
descStyle = lipgloss.NewStyle().Foreground(colorMuted).PaddingLeft(2) descStyle = lipgloss.NewStyle().Foreground(style.S.Muted).PaddingLeft(2)
} }
permIndicator := "○ tmp" name := dev.Name
if dev.Permanent { if icon := deviceIcon(dev.Name); icon != "" {
permIndicator = "● perm" name = icon + " " + name
} }
permIndicator := ruleStateIndicators[dev.RuleState]
width := m.Width()
fmt.Fprintf(w, "%s\n%s", fmt.Fprintf(w, "%s\n%s",
nameStyle.Render(dev.Name), clampToWidth(nameStyle.Render(name), width),
descStyle.Render(fmt.Sprintf("id:%-3d %s %s %s", dev.ID, dev.VidPid, string(dev.Status), permIndicator)), clampToWidth(descStyle.Render(fmt.Sprintf("id:%-3d %s %s %s", dev.ID, dev.VidPid, string(dev.Status), permIndicator)), width),
) )
} }
// actionItem represents a device policy action in the select popup.
type actionItem struct { type actionItem struct {
label string label string
fn func(int, bool) error fn func(int, bool) error
permanent bool permanent bool
status guard.Status status guard.Status
nixos bool
} }
func (a actionItem) Title() string { return a.label } func (a actionItem) Title() string { return a.label }
func (a actionItem) Description() string { return "" } func (a actionItem) Description() string { return "" }
func (a actionItem) FilterValue() string { return a.label } func (a actionItem) FilterValue() string { return a.label }
// actionDelegate renders single-line action items.
type actionDelegate struct{} type actionDelegate struct{}
func (d actionDelegate) Height() int { return 1 } func (d actionDelegate) Height() int { return 1 }
func (d actionDelegate) Spacing() int { return 0 } func (d actionDelegate) Spacing() int { return 0 }
func (d actionDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 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) { func (d actionDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
@@ -96,7 +97,7 @@ func (d actionDelegate) Render(w io.Writer, m list.Model, index int, item list.I
if index == m.Index() { if index == m.Index() {
clr, ok := statusColorsSelected[a.status] clr, ok := statusColorsSelected[a.status]
if !ok { if !ok {
clr = colorAccent clr = style.S.Primary
} }
fmt.Fprintf(w, " %s", lipgloss.NewStyle().Bold(true).Foreground(clr).Render("> "+a.label)) fmt.Fprintf(w, " %s", lipgloss.NewStyle().Bold(true).Foreground(clr).Render("> "+a.label))
} else { } else {
+58
View File
@@ -0,0 +1,58 @@
package ui
import (
"strings"
"github.com/anotherhadi/ilovetui/style"
)
const (
iconUSB = " "
iconKeyboard = ""
iconPointer = ""
iconCamera = ""
iconStorage = ""
iconAudio = ""
iconBluetooth = ""
iconNetwork = ""
iconPrint = ""
iconHub = ""
iconPhone = ""
iconGamepad = ""
iconSecurity = ""
)
type deviceIconCategory struct {
icon string
keywords []string
}
var deviceIconCategories = []deviceIconCategory{
{iconKeyboard, []string{"keyboard"}},
{iconPointer, []string{"mouse", "touchpad", "trackpad"}},
{iconCamera, []string{"webcam", "camera"}},
{iconStorage, []string{"storage", "disk", "drive", "ssd", "data", "flash", "card reader"}},
{iconAudio, []string{"headset", "headphone", "audio", "speaker", "microphone"}},
{iconBluetooth, []string{"bluetooth"}},
{iconNetwork, []string{"wifi", "wi-fi", "wireless", "ethernet", "network adapter", "network card"}},
{iconPrint, []string{"printer", "scanner"}},
{iconHub, []string{"hub"}},
{iconPhone, []string{"phone", "android", "iphone"}},
{iconGamepad, []string{"gamepad", "joystick"}},
{iconSecurity, []string{"security key", "smartcard", "smart card", "yubikey", "fingerprint"}},
}
func deviceIcon(name string) string {
if !style.S.NerdFonts {
return ""
}
lower := strings.ToLower(name)
for _, c := range deviceIconCategories {
for _, kw := range c.keywords {
if strings.Contains(lower, kw) {
return c.icon + " "
}
}
}
return iconUSB
}
+71
View File
@@ -0,0 +1,71 @@
package ui
import "charm.land/bubbles/v2/key"
type listKeyMap struct {
Open key.Binding
Filter key.Binding
Refresh key.Binding
Quit key.Binding
Help key.Binding
Allow key.Binding
AllowPerm key.Binding
Block key.Binding
BlockPerm key.Binding
Reject key.Binding
RejectPerm key.Binding
AllowAll key.Binding
AllowAllPerm key.Binding
CopyRules key.Binding
Up key.Binding
Down key.Binding
GoToStart key.Binding
GoToEnd key.Binding
PrevPage key.Binding
NextPage key.Binding
}
func (k listKeyMap) globalBindings() []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,
k.AllowAll, k.AllowAllPerm, k.CopyRules,
k.Up, k.Down, k.GoToStart, k.GoToEnd, k.PrevPage, k.NextPage,
}
}
func (k listKeyMap) shortHelpBindings() []key.Binding {
return []key.Binding{
k.Open, k.Allow, k.Block, k.Reject, k.Filter, k.Quit,
}
}
var listKeys = listKeyMap{
Open: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter/tab", "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", "esc", "ctrl+c"), key.WithHelp("q/esc", "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("e"), key.WithHelp("e", "reject")),
RejectPerm: key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "reject (perm)")),
AllowAll: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "allow all")),
AllowAllPerm: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "allow all (perm)")),
CopyRules: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy rules")),
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
GoToStart: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start")),
GoToEnd: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end")),
PrevPage: key.NewBinding(key.WithKeys("left", "pgup", "h"), key.WithHelp("←/pgup/h", "prev page")),
NextPage: key.NewBinding(key.WithKeys("right", "pgdown", "l"), key.WithHelp("→/pgdn/l", "next page")),
}
var cancelKey = key.NewBinding(key.WithKeys("esc", "q", "ctrl+c"), key.WithHelp("esc/q", "cancel"))
+545
View File
@@ -0,0 +1,545 @@
package ui
import (
"fmt"
"slices"
"strings"
"time"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/anotherhadi/ilovetui/app"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/helpbar"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/usbguard-tui/internal/guard"
)
type (
tickMsg time.Time
devicesMsg []guard.Device
daemonStatusMsg string
defaultPolicyMsg guard.Status
actionMsg struct{ err error }
nixRuleMsg struct{ key, rule string }
)
type pendingRule struct {
key string
rule string
}
type deviceSummary struct {
total, allowed, blocked, rejected int
}
type Model struct {
list list.Model
help helpbar.Model
daemonStatus string
defaultPolicy guard.Status
deviceCounts deviceSummary
width int
height int
rulesManaged bool
rulesWritable bool
pendingRules []pendingRule
fatalShown bool
ModalOpen bool
}
func New() Model {
l := bubbles.NewList(nil, 0, 0)
l.SetDelegate(deviceDelegate{})
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.SetShowStatusBar(false)
l.SetShowTitle(false)
l.DisableQuitKeybindings()
l.KeyMap.CursorUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"))
l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"))
h := helpbar.New(
helpbar.WithToggle(listKeys.Help),
)
rulesManaged := guard.IsRulesManaged()
rulesWritable, _ := guard.RulesWritable()
return Model{
list: l,
help: h,
rulesManaged: rulesManaged,
rulesWritable: rulesWritable,
}
}
func makeActionList(rulesManaged bool) list.Model {
var items []list.Item
if rulesManaged {
items = []list.Item{
actionItem{"allow", guard.AllowDevice, false, guard.Allowed, false},
actionItem{"allow (perm)", nil, true, guard.Allowed, true},
actionItem{"block", guard.BlockDevice, false, guard.Blocked, false},
actionItem{"block (perm)", nil, true, guard.Blocked, true},
actionItem{"reject", guard.RejectDevice, false, guard.Rejected, false},
actionItem{"reject (perm)", nil, true, guard.Rejected, true},
}
} else {
items = []list.Item{
actionItem{"allow", guard.AllowDevice, false, guard.Allowed, false},
actionItem{"allow (permanent)", guard.AllowDevice, true, guard.Allowed, false},
actionItem{"block", guard.BlockDevice, false, guard.Blocked, false},
actionItem{"block (permanent)", guard.BlockDevice, true, guard.Blocked, false},
actionItem{"reject", guard.RejectDevice, false, guard.Rejected, false},
actionItem{"reject (permanent)", guard.RejectDevice, true, guard.Rejected, false},
}
}
l := bubbles.NewList(items, 24, len(items))
l.SetShowHelp(false)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowPagination(false)
l.DisableQuitKeybindings()
l.SetFilteringEnabled(false)
l.SetDelegate(actionDelegate{})
return l
}
func (m Model) Init() tea.Cmd {
return tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy, tickCmd(), app.SetTitle("USBGuard TUI"))
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.help.SetWidth(msg.Width)
m.resizeList()
return m, nil
case tickMsg:
return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy, tickCmd())
case devicesMsg:
items := make([]list.Item, len(msg))
summary := deviceSummary{total: len(msg)}
for i, d := range msg {
items[i] = d
switch d.Status {
case guard.Allowed:
summary.allowed++
case guard.Blocked:
summary.blocked++
case guard.Rejected:
summary.rejected++
}
}
m.deviceCounts = summary
cmd := m.list.SetItems(items)
m.resizeList()
return m, cmd
case daemonStatusMsg:
m.daemonStatus = string(msg)
return m, nil
case defaultPolicyMsg:
m.defaultPolicy = guard.Status(msg)
return m, nil
case nixRuleMsg:
hadPending := len(m.pendingRules) > 0
if i := slices.IndexFunc(m.pendingRules, func(r pendingRule) bool { return r.key == msg.key }); i >= 0 {
m.pendingRules[i].rule = msg.rule
} else {
m.pendingRules = append(m.pendingRules, pendingRule{key: msg.key, rule: msg.rule})
}
if !hadPending {
m.resizeList()
}
return m, nil
case actionMsg:
if msg.err != nil {
if msg.err == guard.ErrPermission {
if m.fatalShown {
return m, nil
}
m.fatalShown = true
return m, modal.Show("Permission Error", newPermissionModal(),
modal.WithModalStyle(permissionModalStyle()))
}
return m, errorToast(msg.err)
}
return m, fetchDevices
case tea.KeyPressMsg:
if m.ModalOpen {
return m, nil
}
return m.updateList(msg)
case tea.MouseWheelMsg:
if m.ModalOpen {
return m, nil
}
switch msg.Button {
case tea.MouseWheelUp:
m.list.CursorUp()
case tea.MouseWheelDown:
m.list.CursorDown()
}
return m, nil
}
if m.ModalOpen {
return m, nil
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m Model) updateList(msg tea.KeyPressMsg) (Model, tea.Cmd) {
if msg.String() == "ctrl+c" {
return m, app.Quit()
}
if !m.list.SettingFilter() {
dev, hasSelection := m.selectedDevice()
switch {
case key.Matches(msg, listKeys.Quit):
return m, app.Quit()
case key.Matches(msg, listKeys.Refresh):
return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy)
case key.Matches(msg, listKeys.Help):
m.help.ShowAll = !m.help.ShowAll
m.resizeList()
return m, nil
case key.Matches(msg, listKeys.Open):
if hasSelection {
return m, modal.Show(dev.Name, newActionModal(dev, m.rulesManaged),
modal.WithModalStyle(actionModalStyle(dev.Status)))
}
case key.Matches(msg, listKeys.AllowAll):
return m, doBulkAction(m.visibleDeviceIDs(), guard.AllowDevice, false)
case key.Matches(msg, listKeys.AllowAllPerm):
if m.rulesManaged {
return m, queueNixOSRules(m.visibleDevices(), guard.Allowed)
}
return m, doBulkAction(m.visibleDeviceIDs(), guard.AllowDevice, true)
case key.Matches(msg, listKeys.CopyRules):
toCopy := m.pendingRules
if len(toCopy) == 0 {
toCopy = mergeCurrentStateRules(nil, m.visibleDevices())
}
cmd := copyRulesCmd(toCopy, m.rulesManaged)
m.pendingRules = nil
m.resizeList()
return m, cmd
}
if hasSelection {
if cmd := m.deviceActionCmd(msg, dev); cmd != nil {
return m, cmd
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m Model) View() string {
header := m.renderHeader()
listView := strings.TrimRight(m.list.View(), "\n")
helpView := strings.TrimRight(m.help.View(m.helpBindings()...), "\n")
return strings.Join([]string{header, listView, helpView}, "\n")
}
func (m Model) helpBindings() []key.Binding {
if m.help.ShowAll {
return listKeys.globalBindings()
}
return listKeys.shortHelpBindings()
}
func (m Model) renderHeader() string {
title := headerStyle.Render("USBGuard TUI")
if style.S.NerdFonts {
title = "󱊟 " + title
}
lines := []string{
title,
"",
m.renderServiceLine(),
m.renderPolicyLine(),
m.renderDevicesLine(),
m.renderRulesLine(),
}
if pending := m.renderPendingRulesLine(); pending != "" {
lines = append(lines, pending)
}
for i, l := range lines {
lines[i] = clampToWidth(l, m.width)
}
return strings.Join(lines, "\n")
}
func clampToWidth(s string, width int) string {
if width <= 0 {
return s
}
return ansi.Truncate(s, width, "…")
}
func (m Model) renderServiceLine() string {
label := infoLabelStyle.Render("Service")
switch m.daemonStatus {
case "active":
return label + daemonActiveStyle.Render("active")
case "":
return label + mutedStyle.Render("checking...")
default:
return label + daemonOtherStyle.Render(m.daemonStatus)
}
}
func (m Model) renderPolicyLine() string {
label := infoLabelStyle.Render("Default policy")
if m.defaultPolicy == "" {
return label + mutedStyle.Render("unknown")
}
clr, ok := statusColors[m.defaultPolicy]
if !ok {
clr = style.S.Muted
}
return label + lipgloss.NewStyle().Foreground(clr).Render(string(m.defaultPolicy))
}
func (m Model) renderDevicesLine() string {
label := infoLabelStyle.Render("Devices")
if m.deviceCounts.total == 0 {
return label + mutedStyle.Render("0")
}
parts := []string{mutedStyle.Render(fmt.Sprintf("%d total", m.deviceCounts.total))}
if m.deviceCounts.allowed > 0 {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColors[guard.Allowed]).
Render(fmt.Sprintf("%d allow", m.deviceCounts.allowed)))
}
if m.deviceCounts.blocked > 0 {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColors[guard.Blocked]).
Render(fmt.Sprintf("%d block", m.deviceCounts.blocked)))
}
if m.deviceCounts.rejected > 0 {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColors[guard.Rejected]).
Render(fmt.Sprintf("%d reject", m.deviceCounts.rejected)))
}
return label + strings.Join(parts, mutedStyle.Render(" · "))
}
func (m Model) renderRulesLine() string {
label := infoLabelStyle.Render("Rules")
switch {
case m.rulesManaged:
return label + warnStyle.Render("read-only (NixOS managed)")
case !m.rulesWritable:
return label + warnStyle.Render("read-only")
default:
return label + daemonActiveStyle.Render("writable")
}
}
func (m Model) renderPendingRulesLine() string {
count := len(m.pendingRules)
if count == 0 {
return ""
}
noun := "rule"
if count > 1 {
noun = "rules"
}
label := infoLabelStyle.Render("Pending rules")
return label + warnStyle.Render(fmt.Sprintf("%d %s queued (press %s to copy)", count, noun, listKeys.CopyRules.Help().Key))
}
func (m Model) listHeight() int {
headerH := lipgloss.Height(m.renderHeader())
helpH := m.help.Height(m.helpBindings()...)
return m.height - headerH - helpH
}
func (m *Model) resizeList() {
m.list.SetSize(m.width, m.listHeight())
m.list.SetSize(m.width, m.listHeight())
}
func (m Model) selectedDevice() (guard.Device, bool) {
if item := m.list.SelectedItem(); item != nil {
return item.(guard.Device), true
}
return guard.Device{}, false
}
func (m Model) visibleDevices() []guard.Device {
items := m.list.VisibleItems()
devices := make([]guard.Device, len(items))
for i, item := range items {
devices[i] = item.(guard.Device)
}
return devices
}
func (m Model) visibleDeviceIDs() []int {
items := m.list.VisibleItems()
ids := make([]int, len(items))
for i, item := range items {
ids[i] = item.(guard.Device).ID
}
return ids
}
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 fetchDefaultPolicy() tea.Msg {
return defaultPolicyMsg(guard.DefaultPolicy())
}
func errorToast(err error) tea.Cmd {
msg := err.Error()
if err == guard.ErrReadOnly {
msg = "Rules file is not writable: permanent changes are not supported."
}
return notification.Show("Error", msg, notification.Error, notification.WithID("action-error"))
}
func doAction(id int, fn func(int, bool) error, permanent bool) tea.Cmd {
return func() tea.Msg {
return actionMsg{err: fn(id, permanent)}
}
}
func doBulkAction(ids []int, fn func(int, bool) error, permanent bool) tea.Cmd {
return func() tea.Msg {
for _, id := range ids {
if err := fn(id, permanent); err != nil {
return actionMsg{err: err}
}
}
return actionMsg{}
}
}
func queueNixOSRules(devices []guard.Device, status guard.Status) tea.Cmd {
cmds := make([]tea.Cmd, len(devices))
for i, d := range devices {
key := d.VidPid
rule := guard.NixOSRule(d, status)
cmds[i] = func() tea.Msg { return nixRuleMsg{key: key, rule: rule} }
}
return tea.Batch(cmds...)
}
func mergeCurrentStateRules(pending []pendingRule, devices []guard.Device) []pendingRule {
for _, d := range devices {
key := d.VidPid
rule := guard.NixOSRule(d, d.Status)
if i := slices.IndexFunc(pending, func(r pendingRule) bool { return r.key == key }); i >= 0 {
pending[i].rule = rule
} else {
pending = append(pending, pendingRule{key: key, rule: rule})
}
}
return pending
}
func copyRulesCmd(pending []pendingRule, nixos bool) tea.Cmd {
if len(pending) == 0 {
return notification.Show("Copy rules", "No rules to copy.", notification.Warning)
}
rules := make([]string, len(pending))
for i, r := range pending {
rules[i] = r.rule
}
text := formatRulesForClipboard(rules, nixos)
noun := "rule"
if len(rules) > 1 {
noun = "rules"
}
msg := fmt.Sprintf("%d %s copied to clipboard.", len(rules), noun)
return tea.Batch(
tea.SetClipboard(text),
notification.Show("Copy rules", msg, notification.Success),
)
}
func formatRulesForClipboard(rules []string, nixos bool) string {
if !nixos {
return strings.Join(rules, "\n")
}
var b strings.Builder
b.WriteString("# Add to your NixOS configuration:\n")
b.WriteString("services.usbguard.rules = lib.mkAfter ''\n")
for _, r := range rules {
b.WriteString(" " + r + "\n")
}
b.WriteString("'';")
return b.String()
}
type actionBinding struct {
binding key.Binding
fn func(int, bool) error
perm bool
status guard.Status
}
var deviceActionBindings = []actionBinding{
{listKeys.Allow, guard.AllowDevice, false, guard.Allowed},
{listKeys.AllowPerm, guard.AllowDevice, true, guard.Allowed},
{listKeys.Block, guard.BlockDevice, false, guard.Blocked},
{listKeys.BlockPerm, guard.BlockDevice, true, guard.Blocked},
{listKeys.Reject, guard.RejectDevice, false, guard.Rejected},
{listKeys.RejectPerm, guard.RejectDevice, true, guard.Rejected},
}
func (m Model) deviceActionCmd(msg tea.KeyPressMsg, dev guard.Device) tea.Cmd {
for _, b := range deviceActionBindings {
if !key.Matches(msg, b.binding) {
continue
}
if b.perm && m.rulesManaged {
return queueNixOSRules([]guard.Device{dev}, b.status)
}
return doAction(dev.ID, b.fn, b.perm)
}
return nil
}
+44
View File
@@ -0,0 +1,44 @@
package ui
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/app"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/style"
)
type permissionModal struct{}
func newPermissionModal() tea.Model { return permissionModal{} }
func permissionModalStyle() modal.Styles {
s := modal.DefaultStyles()
s.Title = s.Title.Foreground(style.S.Error)
s.Border = s.Border.BorderForeground(style.S.Error)
return s
}
func (permissionModal) Init() tea.Cmd { return nil }
func (m permissionModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case tea.KeyPressMsg, tea.MouseClickMsg:
return m, app.Quit()
}
return m, nil
}
func (permissionModal) View() tea.View {
textStyle := lipgloss.NewStyle().Foreground(style.S.Text)
hintStyle := lipgloss.NewStyle().Foreground(style.S.Muted)
parts := []string{
textStyle.Render("Can't reach the usbguard daemon (permission denied)."),
textStyle.Render("Join the usbguard group, or use sudo."),
hintStyle.Render("press any key to quit"),
}
return tea.NewView(strings.Join(parts, "\n"))
}
+40
View File
@@ -0,0 +1,40 @@
package ui
import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/usbguard-tui/internal/guard"
)
var statusColors = map[guard.Status]color.Color{
guard.Allowed: style.S.Success,
guard.Blocked: style.S.Error,
guard.Rejected: style.S.Warning,
}
var statusColorsSelected = map[guard.Status]color.Color{
guard.Allowed: style.S.Success,
guard.Blocked: style.S.Error,
guard.Rejected: style.S.Warning,
}
var (
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(style.S.Primary).
PaddingLeft(1)
daemonActiveStyle = lipgloss.NewStyle().Foreground(style.S.Success)
daemonOtherStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
mutedStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
infoLabelStyle = lipgloss.NewStyle().
Foreground(style.S.Muted).
PaddingLeft(1).
Width(16)
warnStyle = lipgloss.NewStyle().Foreground(style.S.Warning)
)