diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3aff57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/go.work +/go.work.sum diff --git a/README.md b/README.md index c050c01..411e925 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,12 @@ Colors and styles can be customized using [ilovetui](https://github.com/anotherh ## 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 -- Action popup with mouse support for quick device management +- Action popup for quick device management - Filter devices by name with `/` - 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 diff --git a/go.mod b/go.mod index c44fe8c..9cfd935 100644 --- a/go.mod +++ b/go.mod @@ -6,16 +6,17 @@ require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.6 charm.land/lipgloss/v2 v2.0.3 + github.com/anotherhadi/ilovetui v0.2.1 github.com/charmbracelet/x/ansi v0.11.7 ) require ( charm.land/glamour/v2 v2.0.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/aymerick/douceur v0.2.0 // 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/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // 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/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect diff --git a/go.sum b/go.sum index a9ed389..d8d147c 100644 --- a/go.sum +++ b/go.sum @@ -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/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/anotherhadi/ilovetui v0.1.6 h1:NKg+T1DpV08Q4r+iowFrXF+0bTd6Y2f4OFpFwhsfsyY= -github.com/anotherhadi/ilovetui v0.1.6/go.mod h1:HVai6u5NGKSMOpmioYpwrN0lSxQjc7HtISUc5hTwvOw= +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/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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/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/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/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= 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/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/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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/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= diff --git a/internal/guard/client.go b/internal/guard/client.go index a6fb0ba..470c523 100644 --- a/internal/guard/client.go +++ b/internal/guard/client.go @@ -21,37 +21,115 @@ func ListDevices() ([]Device, error) { if err != nil { return nil, wrapExecError(err) } - rules := listRules() + + deviceRuleText := devicePolicyRuleText() + + permanentTexts, havePermanentTexts := permanentRuleTexts() + + implicitTarget, haveImplicitTarget := implicitPolicyTarget() + var devices []Device for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line == "" { continue } d, err := parseLine(line) - if err == nil { - d.Permanent = rules[d.Hash] == d.Status - devices = append(devices, d) + if err != nil { + continue } + d.RuleState = resolveRuleState(d, deviceRuleText, permanentTexts, havePermanentTexts, implicitTarget, haveImplicitTarget) + devices = append(devices, d) } return devices, nil } -func listRules() map[string]Status { - out, err := exec.Command("usbguard", "list-rules").Output() +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 } - rules := make(map[string]Status) - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + result := make(map[int]string) + var currentRuleText string + for _, line := range strings.Split(string(out), "\n") { if line == "" { continue } - d, err := parseLine(line) - if err == nil && d.Hash != "" { - rules[d.Hash] = d.Status + 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 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) } @@ -88,16 +166,32 @@ func wrapExecError(err error) error { } 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 false + return "" } configPath := extractConfigPath(string(out)) if configPath == "" { - return false + return "" } - ruleFile := parseRuleFilePath(configPath) - return strings.HasPrefix(ruleFile, "/nix/store/") + return parseRuleFilePath(configPath) } func extractConfigPath(s string) string { @@ -126,7 +220,8 @@ func parseRuleFilePath(configPath string) string { func classifyError(output string) error { lower := strings.ToLower(output) 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 case strings.Contains(lower, "read-only"), strings.Contains(lower, "immutable"): return ErrReadOnly diff --git a/internal/guard/device.go b/internal/guard/device.go index 014a0e5..9d69d55 100644 --- a/internal/guard/device.go +++ b/internal/guard/device.go @@ -15,21 +15,27 @@ const ( Rejected Status = "reject" ) +type RuleState string + +const ( + RuleDefault RuleState = "default" + RuleTemporary RuleState = "temporary" + RulePermanent RuleState = "permanent" +) + type Device struct { ID int Name string Status Status VidPid string Hash string - Permanent bool + RuleState RuleState } func (d Device) Title() string { return d.Name } func (d Device) Description() string { return fmt.Sprintf("id:%-3d %s", d.ID, d.VidPid) } func (d Device) FilterValue() string { return d.Name + " " + d.VidPid } -// parseLine parses a line from "usbguard list-devices": -// 1: allow id 04b3:301b serial "" name "USB Hub" hash "..." via-port "usb1" func parseLine(line string) (Device, error) { colonIdx := strings.Index(line, ":") if colonIdx < 0 { diff --git a/internal/ui/actionmodal.go b/internal/ui/actionmodal.go new file mode 100644 index 0000000..d8fdf13 --- /dev/null +++ b/internal/ui/actionmodal.go @@ -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")) +} diff --git a/internal/ui/delegate.go b/internal/ui/delegate.go index f4a4aa8..3974afd 100644 --- a/internal/ui/delegate.go +++ b/internal/ui/delegate.go @@ -7,10 +7,16 @@ import ( "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/ilovetui/style" "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{} 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] if !ok { - clr = ilovetui.S.Muted + clr = style.S.Muted } var nameStyle, descStyle lipgloss.Style if selected { nameStyle = lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), false, false, false, true). - BorderForeground(ilovetui.S.Primary). + BorderForeground(style.S.Primary). Foreground(clr). Bold(true). PaddingLeft(1) descStyle = lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), false, false, false, true). - BorderForeground(ilovetui.S.Primary). - Foreground(ilovetui.S.Muted). + BorderForeground(style.S.Primary). + Foreground(style.S.Muted). PaddingLeft(1) } else { 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" - if dev.Permanent { - permIndicator = "● perm" + 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", - nameStyle.Render(dev.Name), - descStyle.Render(fmt.Sprintf("id:%-3d %s %s %s", dev.ID, dev.VidPid, string(dev.Status), permIndicator)), + clampToWidth(nameStyle.Render(name), width), + 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() { clr, ok := statusColorsSelected[a.status] if !ok { - clr = ilovetui.S.Primary + clr = style.S.Primary } fmt.Fprintf(w, " %s", lipgloss.NewStyle().Bold(true).Foreground(clr).Render("> "+a.label)) } else { diff --git a/internal/ui/icons.go b/internal/ui/icons.go new file mode 100644 index 0000000..64b94cd --- /dev/null +++ b/internal/ui/icons.go @@ -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 +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go index f8cbbe7..b9d573a 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -8,38 +8,58 @@ type listKeyMap struct { Refresh key.Binding Quit key.Binding Help key.Binding - // shown only in full help + Allow key.Binding AllowPerm key.Binding Block key.Binding BlockPerm key.Binding Reject key.Binding RejectPerm key.Binding + + 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 { - return []key.Binding{k.Open, k.Filter, k.Refresh, k.Quit, k.Help} -} - -func (k listKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.Open, k.Filter, k.Refresh, k.Quit}, - {k.Allow, k.AllowPerm, k.Block, k.BlockPerm, k.Reject, k.RejectPerm}, +func (k listKeyMap) globalBindings() []key.Binding { + return []key.Binding{ + k.Open, k.Filter, k.Refresh, k.Quit, + k.Allow, k.AllowPerm, k.Block, k.BlockPerm, k.Reject, k.RejectPerm, + k.AllowAll, k.AllowAllPerm, k.PrintAll, + k.Up, k.Down, k.GoToStart, k.GoToEnd, k.PrevPage, k.NextPage, } } var listKeys = listKeyMap{ - Open: key.NewBinding(key.WithKeys("enter", "tab", "l"), key.WithHelp("enter/l", "select action")), - Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), - Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), - Quit: key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q/esc", "quit")), - Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "more")), - Allow: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "allow")), - AllowPerm: key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "allow (perm)")), - Block: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "block")), - BlockPerm: key.NewBinding(key.WithKeys("B"), key.WithHelp("B", "block (perm)")), - Reject: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "reject")), - RejectPerm: key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "reject (perm)")), + Open: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter/tab", "select action")), + Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + Quit: key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q/esc", "quit")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "more")), + + Allow: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "allow")), + AllowPerm: key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "allow (perm)")), + Block: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "block")), + BlockPerm: key.NewBinding(key.WithKeys("B"), key.WithHelp("B", "block (perm)")), + Reject: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "reject")), + RejectPerm: key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "reject (perm)")), + AllowAll: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "allow all")), + AllowAllPerm: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "allow all (perm)")), + 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")) diff --git a/internal/ui/model.go b/internal/ui/model.go index 1b74db9..8feb910 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -5,81 +5,77 @@ import ( "strings" "time" - "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" - "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/anotherhadi/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" ) -type state int - -const ( - stateList state = iota - statePopup -) - type ( - tickMsg time.Time - devicesMsg []guard.Device - daemonStatusMsg string - actionMsg struct{ err error } - nixRuleMsg struct{ rule string } + tickMsg time.Time + devicesMsg []guard.Device + daemonStatusMsg string + defaultPolicyMsg guard.Status + actionMsg struct{ err error } + nixRuleMsg struct{ rule string } ) +type deviceSummary struct { + total, allowed, blocked, rejected int +} + type Model struct { - state state - list list.Model - actionList list.Model - help help.Model - daemonStatus string - width int - height int - notice string - selectedDev *guard.Device - rulesManaged bool - pendingRules []string + list list.Model + help helpbar.Model + modals modal.Model + notif notification.Model + daemonStatus string + defaultPolicy guard.Status + deviceCounts deviceSummary + width int + height int + rulesManaged bool + rulesWritable bool + pendingRules []string + fatalShown bool } func (m Model) PendingRules() []string { return m.pendingRules } func New() Model { - l := list.New(nil, deviceDelegate{}, 0, 0) + l := bubbles.NewList(nil, 0, 0) + l.SetDelegate(deviceDelegate{}) l.SetShowHelp(false) l.SetFilteringEnabled(true) - l.SetShowStatusBar(true) + l.SetShowStatusBar(false) l.SetShowTitle(false) l.DisableQuitKeybindings() l.KeyMap.CursorUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")) l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")) - l.Styles = list.DefaultStyles(true) - filterStyles := textinput.DefaultStyles(true) - filterStyles.Focused.Prompt = filterStyles.Focused.Prompt.Foreground(ilovetui.S.Primary) - filterStyles.Blurred.Prompt = filterStyles.Blurred.Prompt.Foreground(ilovetui.S.Primary) - l.Styles.Filter = filterStyles - - h := ilovetui.NewHelp() + h := helpbar.New( + helpbar.WithToggle(listKeys.Help), + helpbar.WithGlobal(listKeys.globalBindings()...), + ) rulesManaged := guard.IsRulesManaged() - notice := "" - 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) - } + rulesWritable, _ := guard.RulesWritable() return Model{ - state: stateList, - list: l, - actionList: makeActionList(rulesManaged), - help: h, - rulesManaged: rulesManaged, - notice: notice, + list: l, + help: h, + modals: modal.New(), + notif: notification.New(), + rulesManaged: rulesManaged, + rulesWritable: rulesWritable, } } @@ -104,20 +100,34 @@ func makeActionList(rulesManaged bool) list.Model { 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.SetShowTitle(false) l.SetShowStatusBar(false) + l.SetShowPagination(false) l.DisableQuitKeybindings() l.SetFilteringEnabled(false) + + l.SetDelegate(actionDelegate{}) return l } func (m Model) Init() tea.Cmd { - return tea.Batch(fetchDevices, fetchDaemonStatus, 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) { + 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) { case tea.WindowSizeMsg: @@ -125,17 +135,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height m.help.SetWidth(msg.Width) m.list.SetSize(msg.Width, m.listHeight()) - m.updateActionListSize() return m, nil case tickMsg: - return m, tea.Batch(fetchDevices, fetchDaemonStatus, tickCmd()) + return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy, tickCmd()) case devicesMsg: items := make([]list.Item, len(msg)) + summary := deviceSummary{total: len(msg)} for i, d := range msg { items[i] = d + switch d.Status { + case guard.Allowed: + summary.allowed++ + case guard.Blocked: + summary.blocked++ + case guard.Rejected: + summary.rejected++ + } } + m.deviceCounts = summary cmd := m.list.SetItems(items) return m, cmd @@ -143,59 +162,55 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.daemonStatus = string(msg) return m, nil + case defaultPolicyMsg: + m.defaultPolicy = guard.Status(msg) + return m, nil + case nixRuleMsg: - m.state = stateList - m.selectedDev = nil 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 case actionMsg: - m.state = stateList - m.selectedDev = nil if msg.err != nil { - switch msg.err { - case guard.ErrReadOnly: - m.notice = "Rules file is not writable: permanent changes are not supported." - case guard.ErrPermission: - m.notice = "Permission denied. Run with appropriate privileges." - default: - m.notice = msg.err.Error() + + if msg.err == guard.ErrPermission { + + if m.fatalShown { + return m, nil + } + m.fatalShown = true + return m, modal.Show("Permission Error", newPermissionModal(), + modal.WithModalStyle(permissionModalStyle())) } - } else { - m.notice = m.defaultNotice() + return m, errorToast(msg.err) } return m, fetchDevices case tea.KeyPressMsg: - if m.state == statePopup { - return m.updatePopup(msg) + if m.modals.Open() { + return m, nil } return m.updateList(msg) - case tea.MouseClickMsg: - if m.state == statePopup { - var cmd tea.Cmd - m.actionList, cmd = m.actionList.Update(msg) - return m, cmd - } - case tea.MouseWheelMsg: - return m.updateMouseWheel(msg) + if m.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 { - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return m, cmd + if m.modals.Open() { + return m, nil } - - return m, nil + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, 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 } if !m.list.SettingFilter() { - id := m.selectedDevID() + dev, hasSelection := m.selectedDevice() switch { case key.Matches(msg, listKeys.Quit): return m, tea.Quit case key.Matches(msg, listKeys.Refresh): - m.notice = m.defaultNotice() - return m, tea.Batch(fetchDevices, fetchDaemonStatus) + return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy) case key.Matches(msg, listKeys.Help): m.help.ShowAll = !m.help.ShowAll m.list.SetSize(m.width, m.listHeight()) return m, nil case key.Matches(msg, listKeys.Open): - if item := m.list.SelectedItem(); item != nil { - d := item.(guard.Device) - m.selectedDev = &d - m.updateActionListSize() - m.actionList.Select(0) - m.state = statePopup - return m, nil + if hasSelection { + return m, modal.Show(dev.Name, newActionModal(dev, m.rulesManaged), + modal.WithModalStyle(actionModalStyle(dev.Status))) } + case key.Matches(msg, listKeys.AllowAll): + return m, doBulkAction(m.visibleDeviceIDs(), guard.AllowDevice, false) + case key.Matches(msg, listKeys.AllowAllPerm): + if m.rulesManaged { + return m, queueNixOSRules(m.visibleDevices(), guard.Allowed) + } + return m, doBulkAction(m.visibleDeviceIDs(), guard.AllowDevice, true) + case key.Matches(msg, listKeys.PrintAll): + return m, queueCurrentStateRules(m.visibleDevices()) } - if id >= 0 { - if cmd := m.deviceActionCmd(msg, id); cmd != nil { + if hasSelection { + if cmd := m.deviceActionCmd(msg, dev); cmd != nil { return m, cmd } } @@ -235,27 +254,6 @@ func (m Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.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 { return tea.View{ Content: m.renderContent(), @@ -267,115 +265,140 @@ func (m Model) View() tea.View { func (m Model) renderContent() string { header := m.renderHeader() - notice := m.renderNotice() listView := strings.TrimRight(m.list.View(), "\n") - helpView := strings.TrimRight(m.help.View(listKeys), "\n") - bg := strings.Join([]string{header, listView, notice, helpView}, "\n") + helpView := strings.TrimRight(m.help.View(), "\n") + bg := strings.Join([]string{header, listView, helpView}, "\n") - if m.state == statePopup && m.selectedDev != nil { - return placeOverlay(bg, m.renderActionSelect(), m.width, m.height) - } + bg = m.modals.Render(bg) + bg = m.notif.Render(bg) return bg } func (m Model) renderHeader() string { 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 { case "active": - return title + mutedStyle.Render(" - ") + daemonActiveStyle.Render("active") + return label + daemonActiveStyle.Render("active") case "": - return title + return label + mutedStyle.Render("checking...") default: - return title + mutedStyle.Render(" - ") + daemonOtherStyle.Render(m.daemonStatus) + return label + daemonOtherStyle.Render(m.daemonStatus) } } -func (m Model) renderNotice() string { - if m.notice == "" { +func (m Model) renderPolicyLine() string { + label := infoLabelStyle.Render("Default policy") + if m.defaultPolicy == "" { + return label + mutedStyle.Render("unknown") + } + clr, ok := statusColors[m.defaultPolicy] + if !ok { + clr = style.S.Muted + } + return label + lipgloss.NewStyle().Foreground(clr).Render(string(m.defaultPolicy)) +} + +func (m Model) renderDevicesLine() string { + label := infoLabelStyle.Render("Devices") + if m.deviceCounts.total == 0 { + return label + mutedStyle.Render("0") + } + parts := []string{mutedStyle.Render(fmt.Sprintf("%d total", m.deviceCounts.total))} + if m.deviceCounts.allowed > 0 { + parts = append(parts, lipgloss.NewStyle().Foreground(statusColors[guard.Allowed]). + Render(fmt.Sprintf("%d allow", m.deviceCounts.allowed))) + } + if m.deviceCounts.blocked > 0 { + parts = append(parts, lipgloss.NewStyle().Foreground(statusColors[guard.Blocked]). + Render(fmt.Sprintf("%d block", m.deviceCounts.blocked))) + } + if m.deviceCounts.rejected > 0 { + parts = append(parts, lipgloss.NewStyle().Foreground(statusColors[guard.Rejected]). + Render(fmt.Sprintf("%d reject", m.deviceCounts.rejected))) + } + return label + strings.Join(parts, mutedStyle.Render(" · ")) +} + +func (m Model) renderRulesLine() string { + label := infoLabelStyle.Render("Rules") + switch { + case m.rulesManaged: + return label + warnStyle.Render("read-only (NixOS managed)") + case !m.rulesWritable: + return label + warnStyle.Render("read-only") + default: + return label + daemonActiveStyle.Render("writable") + } +} + +func (m Model) renderPendingRulesLine() string { + count := len(m.pendingRules) + if count == 0 { return "" } - return warnStyle.Render(m.notice) -} - -func (m Model) renderActionSelect() string { - dev := m.selectedDev - color := statusColors[dev.Status] - innerW := m.actionListInnerWidth() - - title := popupTitleStyle.Foreground(color).Width(innerW).Render(dev.Name) - hint := lipgloss.NewStyle().Foreground(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) + noun := "rule" + if count > 1 { + noun = "rules" } + label := infoLabelStyle.Render("Pending rules") + return label + warnStyle.Render(fmt.Sprintf("%d %s queued (printed on exit)", count, noun)) } func (m Model) listHeight() int { - helpH := lipgloss.Height(strings.TrimRight(m.help.View(listKeys), "\n")) - return m.height - 1 - helpH - 1 // header - help - notice + headerH := lipgloss.Height(m.renderHeader()) + 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 { - 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 { @@ -396,51 +419,78 @@ func fetchDaemonStatus() tea.Msg { return daemonStatusMsg(guard.DaemonStatus()) } +func fetchDefaultPolicy() tea.Msg { + return defaultPolicyMsg(guard.DefaultPolicy()) +} + +func errorToast(err error) tea.Cmd { + msg := err.Error() + if err == guard.ErrReadOnly { + msg = "Rules file is not writable: permanent changes are not supported." + } + return notification.Show("Error", msg, notification.Error, notification.WithID("action-error")) +} + func doAction(id int, fn func(int, bool) error, permanent bool) tea.Cmd { return func() tea.Msg { return actionMsg{err: fn(id, permanent)} } } +func doBulkAction(ids []int, fn func(int, bool) error, permanent bool) tea.Cmd { + return func() tea.Msg { + for _, id := range ids { + if err := fn(id, permanent); err != nil { + return actionMsg{err: err} + } + } + return actionMsg{} + } +} + +func queueNixOSRules(devices []guard.Device, status guard.Status) tea.Cmd { + cmds := make([]tea.Cmd, len(devices)) + for i, d := range devices { + 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 - needsWritable bool + binding key.Binding + fn func(int, bool) error + perm bool + status guard.Status } var deviceActionBindings = []actionBinding{ - {listKeys.Allow, guard.AllowDevice, false, false}, - {listKeys.AllowPerm, guard.AllowDevice, true, true}, - {listKeys.Block, guard.BlockDevice, false, false}, - {listKeys.BlockPerm, guard.BlockDevice, true, true}, - {listKeys.Reject, guard.RejectDevice, false, false}, - {listKeys.RejectPerm, guard.RejectDevice, true, true}, + {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, id int) tea.Cmd { +func (m Model) deviceActionCmd(msg tea.KeyPressMsg, dev guard.Device) tea.Cmd { for _, b := range deviceActionBindings { - if (!b.needsWritable || !m.rulesManaged) && key.Matches(msg, b.binding) { - return doAction(id, b.fn, b.perm) + 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 } - -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 -} diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go deleted file mode 100644 index 4d1118c..0000000 --- a/internal/ui/overlay.go +++ /dev/null @@ -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") -} diff --git a/internal/ui/permissionmodal.go b/internal/ui/permissionmodal.go new file mode 100644 index 0000000..b5f8f7a --- /dev/null +++ b/internal/ui/permissionmodal.go @@ -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")) +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 1893e6e..47129f8 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -4,39 +4,37 @@ import ( "image/color" "charm.land/lipgloss/v2" - "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/ilovetui/style" "github.com/anotherhadi/usbguard-tui/internal/guard" ) var statusColors = map[guard.Status]color.Color{ - guard.Allowed: ilovetui.S.Success, - guard.Blocked: ilovetui.S.Error, - guard.Rejected: ilovetui.S.Warning, + guard.Allowed: style.S.Success, + guard.Blocked: style.S.Error, + guard.Rejected: style.S.Warning, } var statusColorsSelected = map[guard.Status]color.Color{ - guard.Allowed: ilovetui.S.Success, - guard.Blocked: ilovetui.S.Error, - guard.Rejected: ilovetui.S.Warning, + guard.Allowed: style.S.Success, + guard.Blocked: style.S.Error, + guard.Rejected: style.S.Warning, } var ( headerStyle = lipgloss.NewStyle(). Bold(true). - Foreground(ilovetui.S.Primary). + Foreground(style.S.Primary). PaddingLeft(1) - daemonActiveStyle = lipgloss.NewStyle().Foreground(ilovetui.S.Success) - daemonOtherStyle = lipgloss.NewStyle().Foreground(ilovetui.S.Muted) + daemonActiveStyle = lipgloss.NewStyle().Foreground(style.S.Success) + daemonOtherStyle = lipgloss.NewStyle().Foreground(style.S.Muted) - mutedStyle = lipgloss.NewStyle().Foreground(ilovetui.S.Muted) + mutedStyle = lipgloss.NewStyle().Foreground(style.S.Muted) - popupStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(ilovetui.S.Primary). - Padding(1, 3) + infoLabelStyle = lipgloss.NewStyle(). + Foreground(style.S.Muted). + PaddingLeft(1). + Width(16) - popupTitleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1) - - warnStyle = lipgloss.NewStyle().Foreground(ilovetui.S.Warning) + warnStyle = lipgloss.NewStyle().Foreground(style.S.Warning) )