mirror of
https://github.com/anotherhadi/usbguard-tui.git
synced 2026-08-21 12:05:50 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4199b6aa7f | |||
| ccc353261b | |||
| e77eaf4e2c | |||
| e99f32ded8 | |||
| 1b19643db4 | |||
| bbab824a1d | |||
| 031ff48696 | |||
| 95d7f368e1 | |||
| 64b36e716c | |||
| 85184dafca | |||
| 6db3a32758 | |||
| 1ac92a5ace | |||
| 2c54df832c |
@@ -0,0 +1 @@
|
|||||||
|
ko_fi: anotherhadi
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/go.work
|
||||||
|
/go.work.sum
|
||||||
@@ -19,14 +19,16 @@ USBGuard is a software framework for implementing a USB device authorization pol
|
|||||||
|
|
||||||
Built with [bubbletea](https://github.com/charmbracelet/bubbletea) & Golang!
|
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
|
## Features
|
||||||
|
|
||||||
- List all connected USB devices with their current status (allowed, blocked, rejected)
|
- List all connected USB devices with their current status
|
||||||
- Allow, block, or reject devices: temporarily or permanently
|
- Allow, block, or reject devices: temporarily or permanently
|
||||||
- Action popup with mouse support for quick device management
|
- Action popup for quick device management
|
||||||
- Filter devices by name with `/`
|
- Filter devices by name with `/`
|
||||||
- Auto-refresh
|
- Auto-refresh
|
||||||
- Keyboard shortcuts for all actions (`a`/`A`, `b`/`B`, `e`/`E`)
|
- Keyboard shortcuts for all actions (`a`/`A`, `b`/`B`, `e`/`E`, ...)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
(system: f system (import nixpkgs {inherit system;}));
|
(system: f system (import nixpkgs {inherit system;}));
|
||||||
|
|
||||||
pname = "usbguard-tui";
|
pname = "usbguard-tui";
|
||||||
version = "1.0.1";
|
version = "1.2.0";
|
||||||
|
|
||||||
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
||||||
in {
|
in {
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
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.";
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
+112
-17
@@ -21,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
|
||||||
}
|
}
|
||||||
return rules
|
result[devID] = currentRuleText
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
colonIdx := strings.Index(line, ":")
|
||||||
|
if colonIdx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
currentRuleText = normalizeRuleText(line[colonIdx+1:])
|
||||||
|
}
|
||||||
|
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) }
|
||||||
@@ -88,16 +166,32 @@ func wrapExecError(err error) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func IsRulesManaged() bool {
|
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()
|
out, err := exec.Command("systemctl", "cat", "usbguard").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return ""
|
||||||
}
|
}
|
||||||
configPath := extractConfigPath(string(out))
|
configPath := extractConfigPath(string(out))
|
||||||
if configPath == "" {
|
if configPath == "" {
|
||||||
return false
|
return ""
|
||||||
}
|
}
|
||||||
ruleFile := parseRuleFilePath(configPath)
|
return parseRuleFilePath(configPath)
|
||||||
return strings.HasPrefix(ruleFile, "/nix/store/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractConfigPath(s string) string {
|
func extractConfigPath(s string) string {
|
||||||
@@ -126,7 +220,8 @@ func parseRuleFilePath(configPath string) string {
|
|||||||
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,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,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"))
|
|
||||||
@@ -1,416 +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
|
|
||||||
rulesManaged bool
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
rulesManaged := guard.IsRulesManaged()
|
|
||||||
notice := ""
|
|
||||||
if rulesManaged {
|
|
||||||
notice = "Rules managed by NixOS config: permanent actions not available."
|
|
||||||
listKeys.AllowPerm.SetEnabled(false)
|
|
||||||
listKeys.BlockPerm.SetEnabled(false)
|
|
||||||
listKeys.RejectPerm.SetEnabled(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Model{
|
|
||||||
state: stateList,
|
|
||||||
list: l,
|
|
||||||
actionList: makeActionList(rulesManaged),
|
|
||||||
help: h,
|
|
||||||
rulesManaged: rulesManaged,
|
|
||||||
notice: notice,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeActionList(rulesManaged bool) list.Model {
|
|
||||||
var items []list.Item
|
|
||||||
if rulesManaged {
|
|
||||||
items = []list.Item{
|
|
||||||
actionItem{"allow", guard.AllowDevice, false, guard.Allowed},
|
|
||||||
actionItem{"block", guard.BlockDevice, false, guard.Blocked},
|
|
||||||
actionItem{"reject", guard.RejectDevice, false, guard.Rejected},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
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, len(items))
|
|
||||||
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 = "Rules file is not writable: permanent changes are not supported."
|
|
||||||
case guard.ErrPermission:
|
|
||||||
m.notice = "Permission denied. Run with appropriate privileges."
|
|
||||||
default:
|
|
||||||
m.notice = msg.err.Error()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m.notice = m.defaultNotice()
|
|
||||||
}
|
|
||||||
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:
|
|
||||||
return m.updateMouseWheel(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.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 = m.defaultNotice()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if id >= 0 {
|
|
||||||
if cmd := m.deviceActionCmd(msg, id); cmd != nil {
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) defaultNotice() string {
|
|
||||||
if m.rulesManaged {
|
|
||||||
return "Rules managed by NixOS config: permanent actions not available."
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) actionItemCount() int {
|
|
||||||
if m.rulesManaged {
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
return 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() {
|
|
||||||
items := m.actionItemCount()
|
|
||||||
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)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type actionBinding struct {
|
|
||||||
binding key.Binding
|
|
||||||
fn func(int, bool) error
|
|
||||||
perm bool
|
|
||||||
needsWritable bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var deviceActionBindings = []actionBinding{
|
|
||||||
{listKeys.Allow, guard.AllowDevice, false, false},
|
|
||||||
{listKeys.AllowPerm, guard.AllowDevice, true, true},
|
|
||||||
{listKeys.Block, guard.BlockDevice, false, false},
|
|
||||||
{listKeys.BlockPerm, guard.BlockDevice, true, true},
|
|
||||||
{listKeys.Reject, guard.RejectDevice, false, false},
|
|
||||||
{listKeys.RejectPerm, guard.RejectDevice, true, true},
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) deviceActionCmd(msg tea.KeyPressMsg, id int) tea.Cmd {
|
|
||||||
for _, b := range deviceActionBindings {
|
|
||||||
if (!b.needsWritable || !m.rulesManaged) && key.Matches(msg, b.binding) {
|
|
||||||
return doAction(id, b.fn, b.perm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) updateMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg.Button {
|
|
||||||
case tea.MouseWheelUp:
|
|
||||||
if m.state == statePopup {
|
|
||||||
m.actionList.CursorUp()
|
|
||||||
} else {
|
|
||||||
m.list.CursorUp()
|
|
||||||
}
|
|
||||||
case tea.MouseWheelDown:
|
|
||||||
if m.state == statePopup {
|
|
||||||
m.actionList.CursorDown()
|
|
||||||
} else {
|
|
||||||
m.list.CursorDown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -1,52 +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)
|
|
||||||
|
|
||||||
warnStyle = lipgloss.NewStyle().Foreground(colorRejected)
|
|
||||||
)
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -7,9 +7,16 @@ import (
|
|||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 }
|
||||||
@@ -30,34 +37,37 @@ func (d deviceDelegate) Render(w io.Writer, m list.Model, index int, item list.I
|
|||||||
}
|
}
|
||||||
clr, ok := colorMap[dev.Status]
|
clr, ok := colorMap[dev.Status]
|
||||||
if !ok {
|
if !ok {
|
||||||
clr = colorMuted
|
clr = style.S.Muted
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +76,7 @@ type actionItem struct {
|
|||||||
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 }
|
||||||
@@ -86,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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user