New features and fixes: new shortcuts, UI/UX, better status resolution

Signed-off-by: Hadi <hadi@example.fr>
This commit is contained in:
Hadi
2026-08-20 15:52:24 +02:00
parent 031ff48696
commit bbab824a1d
14 changed files with 690 additions and 363 deletions
+2
View File
@@ -0,0 +1,2 @@
/go.work
/go.work.sum
+3 -3
View File
@@ -23,12 +23,12 @@ Colors and styles can be customized using [ilovetui](https://github.com/anotherh
## 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
+3 -1
View File
@@ -6,16 +6,17 @@ 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.1
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 charm.land/glamour/v2 v2.0.0 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/anotherhadi/ilovetui v0.1.6 // 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/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/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
@@ -24,6 +25,7 @@ require (
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/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/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
+15 -2
View File
@@ -6,10 +6,16 @@ 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/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 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/anotherhadi/ilovetui v0.1.6 h1:NKg+T1DpV08Q4r+iowFrXF+0bTd6Y2f4OFpFwhsfsyY= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/anotherhadi/ilovetui v0.1.6/go.mod h1:HVai6u5NGKSMOpmioYpwrN0lSxQjc7HtISUc5hTwvOw= 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-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
@@ -18,6 +24,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 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=
@@ -38,8 +46,12 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
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 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 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=
@@ -71,6 +83,7 @@ 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 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+112 -17
View File
@@ -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.Hash] == 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 && d.Hash != "" { trimmed := strings.TrimLeft(line, " \t")
rules[d.Hash] = d.Status colonIdx := strings.Index(trimmed, ":")
if colonIdx < 0 {
continue
}
devID, err := strconv.Atoi(strings.TrimSpace(trimmed[:colonIdx]))
if err != nil {
continue
}
result[devID] = currentRuleText
continue
} }
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
continue
}
currentRuleText = normalizeRuleText(line[colonIdx+1:])
} }
return rules return result
}
func permanentRuleTexts() (map[string]bool, bool) {
path := ruleFilePath()
if path == "" {
return nil, false
}
data, err := os.ReadFile(path)
if err != nil {
return nil, false
}
texts := make(map[string]bool)
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
texts[normalizeRuleText(line)] = true
}
return texts, true
}
func normalizeRuleText(s string) string {
return strings.Join(strings.Fields(s), " ")
} }
func AllowDevice(id int, permanent bool) error { return applyPolicy("allow-device", id, permanent) } func AllowDevice(id int, permanent bool) error { return applyPolicy("allow-device", id, permanent) }
@@ -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
+9 -3
View File
@@ -15,21 +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 Hash string
Permanent bool 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 {
+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"))
}
+21 -12
View File
@@ -7,10 +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" "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 }
@@ -31,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 = ilovetui.S.Muted 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(ilovetui.S.Primary). 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(ilovetui.S.Primary). BorderForeground(style.S.Primary).
Foreground(ilovetui.S.Muted). 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(ilovetui.S.Muted).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),
) )
} }
@@ -88,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 = ilovetui.S.Primary clr = style.S.Primary
} }
fmt.Fprintf(w, " %s", lipgloss.NewStyle().Bold(true).Foreground(clr).Render("> "+a.label)) fmt.Fprintf(w, " %s", lipgloss.NewStyle().Bold(true).Foreground(clr).Render("> "+a.label))
} else { } else {
+58
View File
@@ -0,0 +1,58 @@
package ui
import (
"strings"
"github.com/anotherhadi/ilovetui/style"
)
const (
iconUSB = " "
iconKeyboard = ""
iconPointer = ""
iconCamera = ""
iconStorage = ""
iconAudio = ""
iconBluetooth = ""
iconNetwork = ""
iconPrint = ""
iconHub = ""
iconPhone = ""
iconGamepad = ""
iconSecurity = ""
)
type deviceIconCategory struct {
icon string
keywords []string
}
var deviceIconCategories = []deviceIconCategory{
{iconKeyboard, []string{"keyboard"}},
{iconPointer, []string{"mouse", "touchpad", "trackpad"}},
{iconCamera, []string{"webcam", "camera"}},
{iconStorage, []string{"storage", "disk", "drive", "ssd", "data", "flash", "card reader"}},
{iconAudio, []string{"headset", "headphone", "audio", "speaker", "microphone"}},
{iconBluetooth, []string{"bluetooth"}},
{iconNetwork, []string{"wifi", "wi-fi", "wireless", "ethernet", "network adapter", "network card"}},
{iconPrint, []string{"printer", "scanner"}},
{iconHub, []string{"hub"}},
{iconPhone, []string{"phone", "android", "iphone"}},
{iconGamepad, []string{"gamepad", "joystick"}},
{iconSecurity, []string{"security key", "smartcard", "smart card", "yubikey", "fingerprint"}},
}
func deviceIcon(name string) string {
if !style.S.NerdFonts {
return ""
}
lower := strings.ToLower(name)
for _, c := range deviceIconCategories {
for _, kw := range c.keywords {
if strings.Contains(lower, kw) {
return c.icon + " "
}
}
}
return iconUSB
}
+40 -20
View File
@@ -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", "tab", "l"), key.WithHelp("enter/l", "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", "esc", "ctrl+c"), key.WithHelp("q/esc", "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")),
AllowPerm: key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "allow (perm)")), Allow: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "allow")),
Block: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "block")), AllowPerm: key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "allow (perm)")),
BlockPerm: key.NewBinding(key.WithKeys("B"), key.WithHelp("B", "block (perm)")), Block: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "block")),
Reject: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "reject")), BlockPerm: key.NewBinding(key.WithKeys("B"), key.WithHelp("B", "block (perm)")),
RejectPerm: key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "reject (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)")),
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"))
+286 -236
View File
@@ -5,81 +5,77 @@ import (
"strings" "strings"
"time" "time"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list" "charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui" "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"
) )
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
actionMsg struct{ err error } defaultPolicyMsg guard.Status
nixRuleMsg struct{ rule string } 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 help helpbar.Model
actionList list.Model modals modal.Model
help help.Model notif notification.Model
daemonStatus string daemonStatus string
width int defaultPolicy guard.Status
height int deviceCounts deviceSummary
notice string width int
selectedDev *guard.Device height int
rulesManaged bool rulesManaged bool
pendingRules []string rulesWritable bool
pendingRules []string
fatalShown bool
} }
func (m Model) PendingRules() []string { return m.pendingRules } 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()
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", "j"), key.WithHelp("↓/j", "down")) l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"))
l.Styles = list.DefaultStyles(true) h := helpbar.New(
filterStyles := textinput.DefaultStyles(true) helpbar.WithToggle(listKeys.Help),
filterStyles.Focused.Prompt = filterStyles.Focused.Prompt.Foreground(ilovetui.S.Primary) helpbar.WithGlobal(listKeys.globalBindings()...),
filterStyles.Blurred.Prompt = filterStyles.Blurred.Prompt.Foreground(ilovetui.S.Primary) )
l.Styles.Filter = filterStyles
h := ilovetui.NewHelp()
rulesManaged := guard.IsRulesManaged() rulesManaged := guard.IsRulesManaged()
notice := "" rulesWritable, _ := guard.RulesWritable()
if rulesManaged {
notice = "Rules managed by NixOS config: permanent actions will print NixOS rules on exit."
listKeys.AllowPerm.SetEnabled(false)
listKeys.BlockPerm.SetEnabled(false)
listKeys.RejectPerm.SetEnabled(false)
}
return Model{ return Model{
state: stateList, list: l,
list: l, help: h,
actionList: makeActionList(rulesManaged), modals: modal.New(),
help: h, notif: notification.New(),
rulesManaged: rulesManaged, rulesManaged: rulesManaged,
notice: notice, rulesWritable: rulesWritable,
} }
} }
@@ -104,20 +100,34 @@ func makeActionList(rulesManaged bool) list.Model {
actionItem{"reject (permanent)", guard.RejectDevice, true, guard.Rejected, false}, actionItem{"reject (permanent)", guard.RejectDevice, true, guard.Rejected, false},
} }
} }
l := list.New(items, actionDelegate{}, 24, len(items)) 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:
@@ -125,17 +135,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height m.height = msg.Height
m.help.SetWidth(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
@@ -143,59 +162,55 @@ 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: case nixRuleMsg:
m.state = stateList
m.selectedDev = nil
m.pendingRules = append(m.pendingRules, msg.rule) m.pendingRules = append(m.pendingRules, msg.rule)
count := len(m.pendingRules)
if count == 1 {
m.notice = "1 NixOS rule queued (printed on exit)"
} else {
m.notice = fmt.Sprintf("%d NixOS rules queued (printed on exit)", count)
}
return m, nil 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 = "Rules file is not writable: permanent changes are not supported."
case guard.ErrPermission: if m.fatalShown {
m.notice = "Permission denied. Run with appropriate privileges." return m, nil
default: }
m.notice = msg.err.Error() m.fatalShown = true
return m, modal.Show("Permission Error", newPermissionModal(),
modal.WithModalStyle(permissionModalStyle()))
} }
} else { return m, errorToast(msg.err)
m.notice = m.defaultNotice()
} }
return m, fetchDevices return m, fetchDevices
case tea.KeyPressMsg: 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.MouseClickMsg:
if m.state == statePopup {
var cmd tea.Cmd
m.actionList, cmd = m.actionList.Update(msg)
return m, cmd
}
case tea.MouseWheelMsg: case tea.MouseWheelMsg:
return m.updateMouseWheel(msg) 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() {
var cmd tea.Cmd return m, nil
m.list, cmd = m.list.Update(msg)
return m, cmd
} }
var cmd tea.Cmd
return m, nil m.list, cmd = m.list.Update(msg)
return m, cmd
} }
func (m Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
@@ -203,29 +218,33 @@ func (m Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
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 = m.defaultNotice() 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)
m.state = statePopup
return m, nil
} }
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.PrintAll):
return m, queueCurrentStateRules(m.visibleDevices())
} }
if id >= 0 { if hasSelection {
if cmd := m.deviceActionCmd(msg, id); cmd != nil { if cmd := m.deviceActionCmd(msg, dev); cmd != nil {
return m, cmd return m, cmd
} }
} }
@@ -235,27 +254,6 @@ func (m Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, cmd 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)
if a.nixos && m.selectedDev != nil {
rule := guard.NixOSRule(*m.selectedDev, a.status)
return m, func() tea.Msg { return nixRuleMsg{rule: rule} }
}
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 { func (m Model) View() tea.View {
return tea.View{ return tea.View{
Content: m.renderContent(), Content: m.renderContent(),
@@ -267,115 +265,140 @@ func (m Model) View() tea.View {
func (m Model) renderContent() 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.Foreground(color).Width(innerW).Render(dev.Name)
hint := lipgloss.NewStyle().Foreground(ilovetui.S.Muted).Width(innerW).Render("↑↓ navigate enter confirm esc cancel")
parts := []string{title, m.actionList.View(), ""}
if m.rulesManaged {
nixosHint := lipgloss.NewStyle().Foreground(ilovetui.S.Muted).Width(innerW).Render("[NixOS: perm rules printed on exit]")
parts = append(parts, nixosHint)
}
parts = append(parts, hint)
return popupStyle.Width(innerW).Render(strings.Join(parts, "\n"))
}
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 will print NixOS rules on exit."
}
return ""
}
func (m Model) actionItemCount() int {
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; +1 for NixOS footer
overhead := 7
if m.rulesManaged {
overhead = 8
}
available := m.height - overhead - 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 {
@@ -396,51 +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 { type actionBinding struct {
binding key.Binding binding key.Binding
fn func(int, bool) error fn func(int, bool) error
perm bool perm bool
needsWritable bool status guard.Status
} }
var deviceActionBindings = []actionBinding{ var deviceActionBindings = []actionBinding{
{listKeys.Allow, guard.AllowDevice, false, false}, {listKeys.Allow, guard.AllowDevice, false, guard.Allowed},
{listKeys.AllowPerm, guard.AllowDevice, true, true}, {listKeys.AllowPerm, guard.AllowDevice, true, guard.Allowed},
{listKeys.Block, guard.BlockDevice, false, false}, {listKeys.Block, guard.BlockDevice, false, guard.Blocked},
{listKeys.BlockPerm, guard.BlockDevice, true, true}, {listKeys.BlockPerm, guard.BlockDevice, true, guard.Blocked},
{listKeys.Reject, guard.RejectDevice, false, false}, {listKeys.Reject, guard.RejectDevice, false, guard.Rejected},
{listKeys.RejectPerm, guard.RejectDevice, true, true}, {listKeys.RejectPerm, guard.RejectDevice, true, guard.Rejected},
} }
func (m Model) deviceActionCmd(msg tea.KeyPressMsg, id int) tea.Cmd { func (m Model) deviceActionCmd(msg tea.KeyPressMsg, dev guard.Device) tea.Cmd {
for _, b := range deviceActionBindings { for _, b := range deviceActionBindings {
if (!b.needsWritable || !m.rulesManaged) && key.Matches(msg, b.binding) { if !key.Matches(msg, b.binding) {
return doAction(id, b.fn, b.perm) continue
} }
if b.perm && m.rulesManaged {
return queueNixOSRules([]guard.Device{dev}, b.status)
}
return doAction(dev.ID, b.fn, b.perm)
} }
return nil 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
}
-51
View File
@@ -1,51 +0,0 @@
package ui
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
var dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
// placeOverlay renders fg centered over bg, with bg stripped and rendered dim gray.
func placeOverlay(bg, fg string, width, height int) string {
fgLines := strings.Split(fg, "\n")
fgH := len(fgLines)
fgW := 0
for _, l := range fgLines {
if w := lipgloss.Width(l); w > fgW {
fgW = w
}
}
bgLines := strings.Split(bg, "\n")
x0 := (width - fgW) / 2
y0 := (height - fgH) / 2
result := make([]string, height)
for i := 0; i < height; i++ {
raw := ""
if i < len(bgLines) {
raw = ansi.Strip(bgLines[i])
}
if w := lipgloss.Width(raw); w < width {
raw += strings.Repeat(" ", width-w)
}
fgIdx := i - y0
if fgIdx < 0 || fgIdx >= fgH {
result[i] = dimStyle.Render(raw)
} else {
fgLine := fgLines[fgIdx]
fgLineW := lipgloss.Width(fgLine)
left := ansi.Truncate(raw, x0, "")
right := ansi.Cut(raw, x0+fgLineW, width)
result[i] = dimStyle.Render(left) + fgLine + dimStyle.Render(right)
}
}
return strings.Join(result, "\n")
}
+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"))
}
+16 -18
View File
@@ -4,39 +4,37 @@ import (
"image/color" "image/color"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui" "github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/usbguard-tui/internal/guard" "github.com/anotherhadi/usbguard-tui/internal/guard"
) )
var statusColors = map[guard.Status]color.Color{ var statusColors = map[guard.Status]color.Color{
guard.Allowed: ilovetui.S.Success, guard.Allowed: style.S.Success,
guard.Blocked: ilovetui.S.Error, guard.Blocked: style.S.Error,
guard.Rejected: ilovetui.S.Warning, guard.Rejected: style.S.Warning,
} }
var statusColorsSelected = map[guard.Status]color.Color{ var statusColorsSelected = map[guard.Status]color.Color{
guard.Allowed: ilovetui.S.Success, guard.Allowed: style.S.Success,
guard.Blocked: ilovetui.S.Error, guard.Blocked: style.S.Error,
guard.Rejected: ilovetui.S.Warning, guard.Rejected: style.S.Warning,
} }
var ( var (
headerStyle = lipgloss.NewStyle(). headerStyle = lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(ilovetui.S.Primary). Foreground(style.S.Primary).
PaddingLeft(1) PaddingLeft(1)
daemonActiveStyle = lipgloss.NewStyle().Foreground(ilovetui.S.Success) daemonActiveStyle = lipgloss.NewStyle().Foreground(style.S.Success)
daemonOtherStyle = lipgloss.NewStyle().Foreground(ilovetui.S.Muted) daemonOtherStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
mutedStyle = lipgloss.NewStyle().Foreground(ilovetui.S.Muted) mutedStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
popupStyle = lipgloss.NewStyle(). infoLabelStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Foreground(style.S.Muted).
BorderForeground(ilovetui.S.Primary). PaddingLeft(1).
Padding(1, 3) Width(16)
popupTitleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1) warnStyle = lipgloss.NewStyle().Foreground(style.S.Warning)
warnStyle = lipgloss.NewStyle().Foreground(ilovetui.S.Warning)
) )