25 Commits

Author SHA1 Message Date
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
Hadi 6267bc6087 Add goreleaser
Signed-off-by: Hadi <hadi@example.com>
2026-05-04 13:14:50 +02:00
Hadi 1661ec4f57 add perm/tmp indication
Signed-off-by: Hadi <hadi@example.com>
2026-05-04 13:13:29 +02:00
Hadi 6e3beb44e1 add --version
Signed-off-by: Hadi <hadi@example.com>
2026-05-04 13:10:58 +02:00
Hadi 6811a1c7fd edit keybinds
Signed-off-by: Hadi <hadi@example.com>
2026-05-04 13:02:45 +02:00
Hadi e67b259cfb upgrade bubbletea - v2
Signed-off-by: Hadi <hadi@example.com>
2026-05-04 12:59:11 +02:00
Hadi dfa9a30586 Add badges 2026-05-01 01:05:56 +02:00
Hadi d181eae077 Update README 2026-04-30 19:32:02 +02:00
23 changed files with 1055 additions and 401 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"
+28
View File
@@ -0,0 +1,28 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2
View File
@@ -0,0 +1,2 @@
/go.work
/go.work.sum
+31
View File
@@ -0,0 +1,31 @@
version: 2
before:
hooks:
- go mod tidy
builds:
- binary: usbguard-tui
goos:
- linux
goarch:
- amd64
- arm64
ldflags:
- -s -w -X main.version={{.Version}}
archives:
- formats:
- tar.gz
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
checksum:
name_template: checksums.txt
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
+22 -2
View File
@@ -2,13 +2,33 @@
▖▖▄▖▄ ▄▖ ▌ ▄▖▖▖▄▖ ▖▖▄▖▄ ▄▖ ▌ ▄▖▖▖▄▖
▌▌▚ ▙▘▌ ▌▌▀▌▛▘▛▌ ▐ ▌▌▐ ▌▌▚ ▙▘▌ ▌▌▀▌▛▘▛▌ ▐ ▌▌▐
▙▌▄▌▙▘▙▌▙▌█▌▌ ▙▌ ▐ ▙▌▟▖ ▙▌▄▌▙▘▙▌▙▌█▌▌ ▙▌ ▐ ▙▌▟▖
``` ```
[![Go Version](https://img.shields.io/github/go-mod/go-version/anotherhadi/usbguard-tui)](go.mod)
[![Release](https://img.shields.io/github/v/release/anotherhadi/usbguard-tui)](https://github.com/anotherhadi/usbguard-tui/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/anotherhadi/usbguard-tui)](https://goreportcard.com/report/github.com/anotherhadi/usbguard-tui)
# USBGuard TUI # USBGuard TUI
A terminal UI for managing USB devices via [usbguard](https://usbguard.github.io/). A terminal UI for managing USB devices via [usbguard](https://usbguard.github.io/).
Built with [bubbletea](https://github.com/charmbracelet/bubbletea) & Goland!
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.
<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
+8 -8
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.1.0";
ldflags = ["-s" "-w"]; 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-SMhllO87YlmySHroKfPq1pHb67CwHaZ3XMp3t983etc="; vendorHash = "sha256-tXMeJy9IpXTRhikYedcL+76H9X3In9mb1/KnN1XFPu4=";
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});
}; };
} }
+29 -19
View File
@@ -3,32 +3,42 @@ module github.com/anotherhadi/usbguard-tui
go 1.26.2 go 1.26.2
require ( require (
github.com/charmbracelet/bubbles v1.0.0 charm.land/bubbles/v2 v2.1.0
github.com/charmbracelet/bubbletea v1.3.10 charm.land/bubbletea/v2 v2.0.6
github.com/charmbracelet/lipgloss v1.1.0 charm.land/lipgloss/v2 v2.0.3
github.com/charmbracelet/x/ansi v0.11.6 github.com/anotherhadi/ilovetui v0.2.1
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/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/harmonica v0.2.0 // 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/clipperhouse/displaywidth v0.9.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // 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/muesli/termenv v0.16.0 // 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
golang.org/x/sys v0.38.0 // indirect github.com/yuin/goldmark v1.7.8 // indirect
golang.org/x/text v0.3.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/sys v0.43.0 // indirect
golang.org/x/text v0.24.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+73 -44
View File
@@ -1,60 +1,89 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
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/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/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.1 h1:ndU52r0nzlT6LM53HNBvwVyvn3dRPKrbGVvzux9YXbk=
github.com/anotherhadi/ilovetui v0.2.1/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-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 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/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 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/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1 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/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 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=
+155 -3
View File
@@ -2,6 +2,7 @@ package guard
import ( import (
"errors" "errors"
"os"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
@@ -20,19 +21,117 @@ func ListDevices() ([]Device, error) {
if err != nil { if err != nil {
return nil, wrapExecError(err) return nil, wrapExecError(err)
} }
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 {
continue
}
d.RuleState = resolveRuleState(d, deviceRuleText, permanentTexts, havePermanentTexts, implicitTarget, haveImplicitTarget)
devices = append(devices, d) devices = append(devices, d)
} }
}
return devices, nil return devices, nil
} }
func resolveRuleState(d Device, deviceRuleText map[int]string, permanentTexts map[string]bool, havePermanentTexts bool, implicitTarget Status, haveImplicitTarget bool) RuleState {
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 {
return nil
}
result := make(map[int]string)
var currentRuleText string
for _, line := range strings.Split(string(out), "\n") {
if line == "" {
continue
}
if line[0] == ' ' || line[0] == '\t' {
trimmed := strings.TrimLeft(line, " \t")
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 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) }
func BlockDevice(id int, permanent bool) error { return applyPolicy("block-device", id, permanent) } func BlockDevice(id int, permanent bool) error { return applyPolicy("block-device", id, permanent) }
func RejectDevice(id int, permanent bool) error { return applyPolicy("reject-device", id, permanent) } func RejectDevice(id int, permanent bool) error { return applyPolicy("reject-device", id, permanent) }
@@ -66,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 -2
View File
@@ -15,19 +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
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 {
@@ -56,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
} }
@@ -73,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")
) )
+81
View File
@@ -0,0 +1,81 @@
package ui
import (
"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)
return a, tea.Batch(modal.Close(), func() tea.Msg { return nixRuleMsg{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("[NixOS: perm rules printed on exit]"))
}
parts = append(parts, hintStyle.Render("↑↓ navigate enter confirm esc cancel"))
return tea.NewView(strings.Join(parts, "\n"))
}
+33 -27
View File
@@ -4,13 +4,19 @@ import (
"fmt" "fmt"
"io" "io"
"github.com/charmbracelet/bubbles/list" "charm.land/bubbles/v2/list"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/lipgloss" "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 }
@@ -25,58 +31,58 @@ func (d deviceDelegate) Render(w io.Writer, m list.Model, index int, item list.I
selected := index == m.Index() selected := index == m.Index()
var color lipgloss.Color colorMap := statusColors
if selected { if selected {
var ok bool colorMap = statusColorsSelected
color, ok = statusColorsSelected[dev.Status]
if !ok {
color = colorMuted
} }
} else { clr, ok := colorMap[dev.Status]
var ok bool
color, ok = statusColors[dev.Status]
if !ok { if !ok {
color = 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(color). 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(color).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)
} }
name := dev.Name
if icon := deviceIcon(dev.Name); icon != "" {
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", dev.ID, dev.VidPid, string(dev.Status))), 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 }
@@ -89,11 +95,11 @@ func (d actionDelegate) Render(w io.Writer, m list.Model, index int, item list.I
return return
} }
if index == m.Index() { if index == m.Index() {
color, ok := statusColorsSelected[a.status] clr, ok := statusColorsSelected[a.status]
if !ok { if !ok {
color = colorAccent clr = style.S.Primary
} }
fmt.Fprintf(w, " %s", lipgloss.NewStyle().Bold(true).Foreground(color).Render("> "+a.label)) fmt.Fprintf(w, " %s", lipgloss.NewStyle().Bold(true).Foreground(clr).Render("> "+a.label))
} else { } else {
fmt.Fprintf(w, " %s", a.label) fmt.Fprintf(w, " %s", a.label)
} }
+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
}
+34 -14
View File
@@ -1,6 +1,6 @@
package ui package ui
import "github.com/charmbracelet/bubbles/key" import "charm.land/bubbles/v2/key"
type listKeyMap struct { type listKeyMap struct {
Open key.Binding Open key.Binding
@@ -8,38 +8,58 @@ type listKeyMap struct {
Refresh key.Binding Refresh key.Binding
Quit key.Binding Quit key.Binding
Help key.Binding Help key.Binding
// shown only in full help
Allow key.Binding Allow key.Binding
AllowPerm key.Binding AllowPerm key.Binding
Block key.Binding Block key.Binding
BlockPerm key.Binding BlockPerm key.Binding
Reject key.Binding Reject key.Binding
RejectPerm key.Binding RejectPerm key.Binding
AllowAll key.Binding
AllowAllPerm key.Binding
PrintAll key.Binding
Up key.Binding
Down key.Binding
GoToStart key.Binding
GoToEnd key.Binding
PrevPage key.Binding
NextPage key.Binding
} }
func (k listKeyMap) ShortHelp() []key.Binding { func (k listKeyMap) globalBindings() []key.Binding {
return []key.Binding{k.Open, k.Filter, k.Refresh, k.Quit, k.Help} return []key.Binding{
} k.Open, k.Filter, k.Refresh, k.Quit,
k.Allow, k.AllowPerm, k.Block, k.BlockPerm, k.Reject, k.RejectPerm,
func (k listKeyMap) FullHelp() [][]key.Binding { k.AllowAll, k.AllowAllPerm, k.PrintAll,
return [][]key.Binding{ k.Up, k.Down, k.GoToStart, k.GoToEnd, k.PrevPage, k.NextPage,
{k.Open, k.Filter, k.Refresh, k.Quit},
{k.Allow, k.AllowPerm, k.Block, k.BlockPerm, k.Reject, k.RejectPerm},
} }
} }
var listKeys = listKeyMap{ var listKeys = listKeyMap{
Open: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select action")), Open: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter/tab", "select action")),
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), Quit: key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q/esc", "quit")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "more")), Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "more")),
Allow: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "allow")), Allow: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "allow")),
AllowPerm: key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "allow (perm)")), AllowPerm: key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "allow (perm)")),
Block: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "block")), Block: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "block")),
BlockPerm: key.NewBinding(key.WithKeys("B"), key.WithHelp("B", "block (perm)")), BlockPerm: key.NewBinding(key.WithKeys("B"), key.WithHelp("B", "block (perm)")),
Reject: key.NewBinding(key.WithKeys("j"), key.WithHelp("j", "reject")), Reject: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "reject")),
RejectPerm: key.NewBinding(key.WithKeys("J"), key.WithHelp("J", "reject (perm)")), 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)")),
PrintAll: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "print all 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")) var cancelKey = key.NewBinding(key.WithKeys("esc", "q", "ctrl+c"), key.WithHelp("esc/q", "cancel"))
+337 -160
View File
@@ -1,105 +1,160 @@
package ui package ui
import ( import (
"fmt"
"strings" "strings"
"time" "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/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" "github.com/anotherhadi/usbguard-tui/internal/guard"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type state int
const (
stateList state = iota
statePopup
) )
type ( type (
tickMsg time.Time tickMsg time.Time
devicesMsg []guard.Device devicesMsg []guard.Device
daemonStatusMsg string daemonStatusMsg string
defaultPolicyMsg guard.Status
actionMsg struct{ err error } actionMsg struct{ err error }
nixRuleMsg struct{ rule string }
) )
type deviceSummary struct {
total, allowed, blocked, rejected int
}
type Model struct { type Model struct {
state state
list list.Model list list.Model
actionList list.Model help helpbar.Model
help help.Model modals modal.Model
notif notification.Model
daemonStatus string daemonStatus string
defaultPolicy guard.Status
deviceCounts deviceSummary
width int width int
height int height int
notice string rulesManaged bool
selectedDev *guard.Device rulesWritable bool
pendingRules []string
fatalShown bool
} }
func (m Model) PendingRules() []string { return m.pendingRules }
func New() Model { func New() Model {
l := list.New(nil, deviceDelegate{}, 0, 0) l := bubbles.NewList(nil, 0, 0)
l.SetDelegate(deviceDelegate{})
l.SetShowHelp(false) l.SetShowHelp(false)
l.SetFilteringEnabled(true) l.SetFilteringEnabled(true)
l.SetShowStatusBar(true) l.SetShowStatusBar(false)
l.SetShowTitle(false) l.SetShowTitle(false)
l.DisableQuitKeybindings() l.DisableQuitKeybindings()
// free j/k for our shortcuts
l.KeyMap.CursorUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")) l.KeyMap.CursorUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"))
l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")) l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"))
l.FilterInput.PromptStyle = lipgloss.NewStyle().Foreground(colorAccent)
l.FilterInput.Cursor.Style = lipgloss.NewStyle().Foreground(colorAccent) h := helpbar.New(
helpbar.WithToggle(listKeys.Help),
helpbar.WithGlobal(listKeys.globalBindings()...),
)
rulesManaged := guard.IsRulesManaged()
rulesWritable, _ := guard.RulesWritable()
return Model{ return Model{
state: stateList,
list: l, list: l,
actionList: makeActionList(), help: h,
help: help.New(), modals: modal.New(),
notif: notification.New(),
rulesManaged: rulesManaged,
rulesWritable: rulesWritable,
} }
} }
func makeActionList() list.Model { func makeActionList(rulesManaged bool) list.Model {
items := []list.Item{ var items []list.Item
actionItem{"allow", guard.AllowDevice, false, guard.Allowed}, if rulesManaged {
actionItem{"allow (permanent)", guard.AllowDevice, true, guard.Allowed}, items = []list.Item{
actionItem{"block", guard.BlockDevice, false, guard.Blocked}, actionItem{"allow", guard.AllowDevice, false, guard.Allowed, false},
actionItem{"block (permanent)", guard.BlockDevice, true, guard.Blocked}, actionItem{"allow (perm)", nil, true, guard.Allowed, true},
actionItem{"reject", guard.RejectDevice, false, guard.Rejected}, actionItem{"block", guard.BlockDevice, false, guard.Blocked, false},
actionItem{"reject (permanent)", guard.RejectDevice, true, guard.Rejected}, actionItem{"block (perm)", nil, true, guard.Blocked, true},
actionItem{"reject", guard.RejectDevice, false, guard.Rejected, false},
actionItem{"reject (perm)", nil, true, guard.Rejected, true},
} }
l := list.New(items, actionDelegate{}, 24, 6) } 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.SetShowHelp(false)
l.SetShowTitle(false) l.SetShowTitle(false)
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
l.SetShowPagination(false)
l.DisableQuitKeybindings() l.DisableQuitKeybindings()
l.SetFilteringEnabled(false) l.SetFilteringEnabled(false)
l.SetDelegate(actionDelegate{})
return l return l
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch(fetchDevices, fetchDaemonStatus, tickCmd()) return tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy, tickCmd(), m.modals.Init(), m.notif.Init())
} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
next, cmd := m.updateMain(msg)
nm := next.(Model)
var modalCmd, notifCmd tea.Cmd
nm.modals, modalCmd = nm.modals.Update(msg)
nm.notif, notifCmd = nm.notif.Update(msg)
return nm, tea.Batch(cmd, modalCmd, notifCmd)
}
func (m Model) updateMain(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.help.Width = msg.Width m.help.SetWidth(msg.Width)
m.list.SetSize(msg.Width, m.listHeight()) m.list.SetSize(msg.Width, m.listHeight())
m.updateActionListSize()
return m, nil return m, nil
case tickMsg: case tickMsg:
return m, tea.Batch(fetchDevices, fetchDaemonStatus, tickCmd()) return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy, tickCmd())
case devicesMsg: case devicesMsg:
items := make([]list.Item, len(msg)) items := make([]list.Item, len(msg))
summary := deviceSummary{total: len(msg)}
for i, d := range msg { for i, d := range msg {
items[i] = d 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) cmd := m.list.SetItems(items)
return m, cmd return m, cmd
@@ -107,76 +162,91 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.daemonStatus = string(msg) m.daemonStatus = string(msg)
return m, nil return m, nil
case defaultPolicyMsg:
m.defaultPolicy = guard.Status(msg)
return m, nil
case nixRuleMsg:
m.pendingRules = append(m.pendingRules, msg.rule)
return m, nil
case actionMsg: case actionMsg:
m.state = stateList
m.selectedDev = nil
if msg.err != nil { if msg.err != nil {
switch msg.err {
case guard.ErrReadOnly: if msg.err == guard.ErrPermission {
m.notice = "Read-only rules: applied temporarily. Add the rule to your config for persistence."
case guard.ErrPermission: if m.fatalShown {
m.notice = "Permission denied. Run with appropriate privileges." return m, nil
default:
m.notice = msg.err.Error()
} }
} else { m.fatalShown = true
m.notice = "" return m, modal.Show("Permission Error", newPermissionModal(),
modal.WithModalStyle(permissionModalStyle()))
}
return m, errorToast(msg.err)
} }
return m, fetchDevices return m, fetchDevices
case tea.KeyMsg: case tea.KeyPressMsg:
if m.state == statePopup { if m.modals.Open() {
return m.updatePopup(msg) return m, nil
} }
return m.updateList(msg) return m.updateList(msg)
case tea.MouseWheelMsg:
if m.modals.Open() {
return m, nil
}
switch msg.Button {
case tea.MouseWheelUp:
m.list.CursorUp()
case tea.MouseWheelDown:
m.list.CursorDown()
}
return m, nil
} }
if m.state == stateList { if m.modals.Open() {
return m, nil
}
var cmd tea.Cmd var cmd tea.Cmd
m.list, cmd = m.list.Update(msg) m.list, cmd = m.list.Update(msg)
return m, cmd return m, cmd
} }
return m, nil func (m Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "ctrl+c" { if msg.String() == "ctrl+c" {
return m, tea.Quit return m, tea.Quit
} }
if !m.list.SettingFilter() { if !m.list.SettingFilter() {
id := m.selectedDevID() dev, hasSelection := m.selectedDevice()
switch { switch {
case key.Matches(msg, listKeys.Quit): case key.Matches(msg, listKeys.Quit):
return m, tea.Quit return m, tea.Quit
case key.Matches(msg, listKeys.Refresh): case key.Matches(msg, listKeys.Refresh):
m.notice = "" return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy)
return m, tea.Batch(fetchDevices, fetchDaemonStatus)
case key.Matches(msg, listKeys.Help): case key.Matches(msg, listKeys.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
m.list.SetSize(m.width, m.listHeight()) m.list.SetSize(m.width, m.listHeight())
return m, nil return m, nil
case key.Matches(msg, listKeys.Open): case key.Matches(msg, listKeys.Open):
if item := m.list.SelectedItem(); item != nil { if hasSelection {
d := item.(guard.Device) return m, modal.Show(dev.Name, newActionModal(dev, m.rulesManaged),
m.selectedDev = &d modal.WithModalStyle(actionModalStyle(dev.Status)))
m.updateActionListSize() }
m.actionList.Select(0) case key.Matches(msg, listKeys.AllowAll):
m.state = statePopup return m, doBulkAction(m.visibleDeviceIDs(), guard.AllowDevice, false)
return m, nil 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.PrintAll):
return m, queueCurrentStateRules(m.visibleDevices())
}
if hasSelection {
if cmd := m.deviceActionCmd(msg, dev); cmd != nil {
return m, cmd
} }
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 var cmd tea.Cmd
@@ -184,114 +254,151 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
func (m Model) updatePopup(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) View() tea.View {
switch { return tea.View{
case key.Matches(msg, cancelKey): Content: m.renderContent(),
m.state = stateList AltScreen: true,
m.selectedDev = nil WindowTitle: "USBGuard TUI",
return m, nil MouseMode: tea.MouseModeCellMotion,
case key.Matches(msg, listKeys.Open):
if item := m.actionList.SelectedItem(); item != nil {
a := item.(actionItem)
return m, doAction(m.selectedDev.ID, a.fn, a.permanent)
} }
} }
var cmd tea.Cmd
m.actionList, cmd = m.actionList.Update(msg)
return m, cmd
}
func (m Model) View() string { func (m Model) renderContent() string {
header := m.renderHeader() header := m.renderHeader()
notice := m.renderNotice()
listView := strings.TrimRight(m.list.View(), "\n") listView := strings.TrimRight(m.list.View(), "\n")
helpView := strings.TrimRight(m.help.View(listKeys), "\n") helpView := strings.TrimRight(m.help.View(), "\n")
bg := strings.Join([]string{header, listView, notice, helpView}, "\n") bg := strings.Join([]string{header, listView, helpView}, "\n")
if m.state == statePopup && m.selectedDev != nil { bg = m.modals.Render(bg)
return placeOverlay(bg, m.renderActionSelect(), m.width, m.height) bg = m.notif.Render(bg)
}
return bg return bg
} }
func (m Model) renderHeader() string { func (m Model) renderHeader() string {
title := headerStyle.Render("USBGuard-tui") title := headerStyle.Render("USBGuard-tui")
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 { switch m.daemonStatus {
case "active": case "active":
return title + mutedStyle.Render(" - ") + daemonActiveStyle.Render("active") return label + daemonActiveStyle.Render("active")
case "": case "":
return title return label + mutedStyle.Render("checking...")
default: default:
return title + mutedStyle.Render(" - ") + daemonOtherStyle.Render(m.daemonStatus) return label + daemonOtherStyle.Render(m.daemonStatus)
} }
} }
func (m Model) renderNotice() string { func (m Model) renderPolicyLine() string {
if m.notice == "" { 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 "" return ""
} }
return warnStyle.Render(m.notice) noun := "rule"
} if count > 1 {
noun = "rules"
func (m Model) renderActionSelect() string {
dev := m.selectedDev
color := statusColors[dev.Status]
innerW := m.actionListInnerWidth()
title := popupTitleStyle.Copy().Foreground(color).Width(innerW).Render(dev.Name)
hint := lipgloss.NewStyle().Foreground(colorMuted).Width(innerW).Render("↑↓ navigate enter confirm esc cancel")
content := strings.Join([]string{title, m.actionList.View(), "", hint}, "\n")
return popupStyle.Width(innerW).Render(content)
}
func (m Model) popupOuterWidth() int {
w := m.width - 6
if w > 60 {
w = 60
}
if w < 32 {
w = 32
}
return w
}
func (m Model) actionListInnerWidth() int {
return m.popupOuterWidth() - 8 // border(2) + padding_h(6)
}
// updateActionListSize sizes the action list and toggles pagination based on available space.
// When there is enough room for all items: pagination is hidden and height is set exactly,
// avoiding the phantom line that bubbles/list reserves when showPagination=true.
// When space is limited: pagination is shown naturally by bubbles/list.
func (m *Model) updateActionListSize() {
const items = 6
innerW := m.actionListInnerWidth()
// popup overhead: border(2) + padding_v(2) + title(1) + blank(1) + hint(1) = 7
available := m.height - 7 - 2 // 2 lines margin
if available >= items {
m.actionList.SetShowPagination(false)
m.actionList.SetSize(innerW, items)
} else {
m.actionList.SetShowPagination(true)
h := available
if h < 2 {
h = 2
}
m.actionList.SetSize(innerW, h)
} }
label := infoLabelStyle.Render("Pending rules")
return label + warnStyle.Render(fmt.Sprintf("%d %s queued (printed on exit)", count, noun))
} }
func (m Model) listHeight() int { func (m Model) listHeight() int {
helpH := lipgloss.Height(strings.TrimRight(m.help.View(listKeys), "\n")) headerH := lipgloss.Height(m.renderHeader())
return m.height - 1 - helpH - 1 // header - help - notice helpH := m.help.Height()
return m.height - headerH - helpH
} }
func (m Model) selectedDevID() int { func (m Model) selectedDevice() (guard.Device, bool) {
if item := m.list.SelectedItem(); item != nil { if item := m.list.SelectedItem(); item != nil {
return item.(guard.Device).ID return item.(guard.Device), true
} }
return -1 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 { func tickCmd() tea.Cmd {
@@ -312,8 +419,78 @@ func fetchDaemonStatus() tea.Msg {
return daemonStatusMsg(guard.DaemonStatus()) 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 { func doAction(id int, fn func(int, bool) error, permanent bool) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
return actionMsg{err: fn(id, permanent)} 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 {
rule := guard.NixOSRule(d, status)
cmds[i] = func() tea.Msg { return nixRuleMsg{rule: rule} }
}
return tea.Batch(cmds...)
}
func queueCurrentStateRules(devices []guard.Device) tea.Cmd {
cmds := make([]tea.Cmd, len(devices))
for i, d := range devices {
rule := guard.NixOSRule(d, d.Status)
cmds[i] = func() tea.Msg { return nixRuleMsg{rule: rule} }
}
return tea.Batch(cmds...)
}
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
}
-51
View File
@@ -1,51 +0,0 @@
package ui
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
var dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
// placeOverlay renders fg centered over bg, with bg stripped and rendered dim gray.
func placeOverlay(bg, fg string, width, height int) string {
fgLines := strings.Split(fg, "\n")
fgH := len(fgLines)
fgW := 0
for _, l := range fgLines {
if w := lipgloss.Width(l); w > fgW {
fgW = w
}
}
bgLines := strings.Split(bg, "\n")
x0 := (width - fgW) / 2
y0 := (height - fgH) / 2
result := make([]string, height)
for i := 0; i < height; i++ {
raw := ""
if i < len(bgLines) {
raw = ansi.Strip(bgLines[i])
}
if w := lipgloss.Width(raw); w < width {
raw += strings.Repeat(" ", width-w)
}
fgIdx := i - y0
if fgIdx < 0 || fgIdx >= fgH {
result[i] = dimStyle.Render(raw)
} else {
fgLine := fgLines[fgIdx]
fgLineW := lipgloss.Width(fgLine)
left := ansi.Truncate(raw, x0, "")
right := ansi.Cut(raw, x0+fgLineW, width)
result[i] = dimStyle.Render(left) + fgLine + dimStyle.Render(right)
}
}
return strings.Join(result, "\n")
}
+44
View File
@@ -0,0 +1,44 @@
package ui
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"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, tea.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"),
textStyle.Render("(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"))
}
+21 -33
View File
@@ -1,52 +1,40 @@
package ui package ui
import ( import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/usbguard-tui/internal/guard" "github.com/anotherhadi/usbguard-tui/internal/guard"
"github.com/charmbracelet/lipgloss"
) )
var ( var statusColors = map[guard.Status]color.Color{
colorAllowed = lipgloss.Color("28") guard.Allowed: style.S.Success,
colorAllowedSelected = lipgloss.Color("42") guard.Blocked: style.S.Error,
colorBlocked = lipgloss.Color("124") guard.Rejected: style.S.Warning,
colorBlockedSelected = lipgloss.Color("196")
colorRejected = lipgloss.Color("130")
colorRejectedSelected = lipgloss.Color("214")
colorMuted = lipgloss.Color("240")
colorAccent = lipgloss.Color("99")
)
var statusColors = map[guard.Status]lipgloss.Color{
guard.Allowed: colorAllowed,
guard.Blocked: colorBlocked,
guard.Rejected: colorRejected,
} }
var statusColorsSelected = map[guard.Status]lipgloss.Color{ var statusColorsSelected = map[guard.Status]color.Color{
guard.Allowed: colorAllowedSelected, guard.Allowed: style.S.Success,
guard.Blocked: colorBlockedSelected, guard.Blocked: style.S.Error,
guard.Rejected: colorRejectedSelected, guard.Rejected: style.S.Warning,
} }
var ( var (
headerStyle = lipgloss.NewStyle(). headerStyle = lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(colorAccent). Foreground(style.S.Primary).
PaddingLeft(1) PaddingLeft(1)
daemonActiveStyle = lipgloss.NewStyle().Foreground(colorAllowedSelected) daemonActiveStyle = lipgloss.NewStyle().Foreground(style.S.Success)
daemonOtherStyle = lipgloss.NewStyle().Foreground(colorMuted) daemonOtherStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
mutedStyle = lipgloss.NewStyle().Foreground(colorMuted) mutedStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
popupStyle = lipgloss.NewStyle(). infoLabelStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Foreground(style.S.Muted).
BorderForeground(colorAccent). PaddingLeft(1).
Padding(1, 3) Width(16)
popupTitleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1) warnStyle = lipgloss.NewStyle().Foreground(style.S.Warning)
keyHintStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
warnStyle = lipgloss.NewStyle().Foreground(colorRejected)
errStyle = lipgloss.NewStyle().Foreground(colorBlocked).Bold(true)
) )
+21 -3
View File
@@ -4,20 +4,38 @@ import (
"fmt" "fmt"
"os" "os"
tea "github.com/charmbracelet/bubbletea" 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" "github.com/anotherhadi/usbguard-tui/internal/ui"
) )
var version = "dev"
func main() { func main() {
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-version" || os.Args[1] == "-v") {
fmt.Println("usbguard-tui", version)
return
}
if err := guard.Check(); err != nil { if err := guard.Check(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
p := tea.NewProgram(ui.New(), tea.WithAltScreen()) p := tea.NewProgram(ui.New())
if _, err := p.Run(); err != nil { m, err := p.Run()
if err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
if fm, ok := m.(ui.Model); ok {
if rules := fm.PendingRules(); len(rules) > 0 {
fmt.Println("# Add to your NixOS configuration:")
fmt.Println("services.usbguard.rules = lib.mkAfter ''")
for _, rule := range rules {
fmt.Println(" ", rule)
}
fmt.Println("'';")
}
}
} }