mirror of
https://github.com/anotherhadi/usbguard-tui.git
synced 2026-08-21 12:05:50 +02:00
Move UI & make a root model.
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -1,83 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/list"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/ilovetui/modal"
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
"github.com/anotherhadi/usbguard-tui/internal/guard"
|
||||
)
|
||||
|
||||
type actionModal struct {
|
||||
list list.Model
|
||||
dev guard.Device
|
||||
rulesManaged bool
|
||||
}
|
||||
|
||||
func newActionModal(dev guard.Device, rulesManaged bool) tea.Model {
|
||||
return actionModal{
|
||||
list: makeActionList(rulesManaged),
|
||||
dev: dev,
|
||||
rulesManaged: rulesManaged,
|
||||
}
|
||||
}
|
||||
|
||||
func actionModalStyle(status guard.Status) modal.Styles {
|
||||
s := modal.DefaultStyles()
|
||||
if clr, ok := statusColors[status]; ok {
|
||||
s.Title = s.Title.Foreground(clr)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (a actionModal) Init() tea.Cmd { return nil }
|
||||
|
||||
func (a actionModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, cancelKey):
|
||||
return a, modal.Close()
|
||||
case key.Matches(msg, listKeys.Open):
|
||||
item := a.list.SelectedItem()
|
||||
if item == nil {
|
||||
return a, nil
|
||||
}
|
||||
it := item.(actionItem)
|
||||
if it.nixos {
|
||||
rule := guard.NixOSRule(a.dev, it.status)
|
||||
key := a.dev.VidPid
|
||||
return a, tea.Batch(modal.Close(), func() tea.Msg { return nixRuleMsg{key: key, rule: rule} })
|
||||
}
|
||||
return a, tea.Batch(modal.Close(), doAction(a.dev.ID, it.fn, it.permanent))
|
||||
}
|
||||
|
||||
case tea.MouseWheelMsg:
|
||||
switch msg.Button {
|
||||
case tea.MouseWheelUp:
|
||||
a.list.CursorUp()
|
||||
case tea.MouseWheelDown:
|
||||
a.list.CursorDown()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
a.list, cmd = a.list.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a actionModal) View() tea.View {
|
||||
hintStyle := lipgloss.NewStyle().Foreground(style.S.Muted)
|
||||
parts := []string{a.list.View(), ""}
|
||||
if a.rulesManaged {
|
||||
parts = append(parts, hintStyle.Render(fmt.Sprintf("[NixOS: perm rules queued — press %s to copy]", listKeys.CopyRules.Help().Key)))
|
||||
}
|
||||
parts = append(parts, hintStyle.Render("↑↓ navigate enter confirm esc cancel"))
|
||||
return tea.NewView(strings.Join(parts, "\n"))
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"charm.land/bubbles/v2/list"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"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 }
|
||||
func (d deviceDelegate) Spacing() int { return 0 }
|
||||
func (d deviceDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
|
||||
|
||||
func (d deviceDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
|
||||
dev, ok := item.(guard.Device)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
selected := index == m.Index()
|
||||
|
||||
colorMap := statusColors
|
||||
if selected {
|
||||
colorMap = statusColorsSelected
|
||||
}
|
||||
clr, ok := colorMap[dev.Status]
|
||||
if !ok {
|
||||
clr = style.S.Muted
|
||||
}
|
||||
|
||||
var nameStyle, descStyle lipgloss.Style
|
||||
if selected {
|
||||
nameStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||
BorderForeground(style.S.Primary).
|
||||
Foreground(clr).
|
||||
Bold(true).
|
||||
PaddingLeft(1)
|
||||
descStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||
BorderForeground(style.S.Primary).
|
||||
Foreground(style.S.Muted).
|
||||
PaddingLeft(1)
|
||||
} else {
|
||||
nameStyle = lipgloss.NewStyle().Foreground(clr).PaddingLeft(2)
|
||||
descStyle = lipgloss.NewStyle().Foreground(style.S.Muted).PaddingLeft(2)
|
||||
}
|
||||
|
||||
name := dev.Name
|
||||
if icon := deviceIcon(dev.Name); icon != "" {
|
||||
name = icon + " " + name
|
||||
}
|
||||
|
||||
permIndicator := ruleStateIndicators[dev.RuleState]
|
||||
width := m.Width()
|
||||
fmt.Fprintf(w, "%s\n%s",
|
||||
clampToWidth(nameStyle.Render(name), width),
|
||||
clampToWidth(descStyle.Render(fmt.Sprintf("id:%-3d %s %s %s", dev.ID, dev.VidPid, string(dev.Status), permIndicator)), width),
|
||||
)
|
||||
}
|
||||
|
||||
type actionItem struct {
|
||||
label string
|
||||
fn func(int, bool) error
|
||||
permanent bool
|
||||
status guard.Status
|
||||
nixos bool
|
||||
}
|
||||
|
||||
func (a actionItem) Title() string { return a.label }
|
||||
func (a actionItem) Description() string { return "" }
|
||||
func (a actionItem) FilterValue() string { return a.label }
|
||||
|
||||
type actionDelegate struct{}
|
||||
|
||||
func (d actionDelegate) Height() int { return 1 }
|
||||
func (d actionDelegate) Spacing() int { return 0 }
|
||||
func (d actionDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
|
||||
|
||||
func (d actionDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
|
||||
a, ok := item.(actionItem)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if index == m.Index() {
|
||||
clr, ok := statusColorsSelected[a.status]
|
||||
if !ok {
|
||||
clr = style.S.Primary
|
||||
}
|
||||
fmt.Fprintf(w, " %s", lipgloss.NewStyle().Bold(true).Foreground(clr).Render("> "+a.label))
|
||||
} else {
|
||||
fmt.Fprintf(w, " %s", a.label)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package ui
|
||||
|
||||
import "charm.land/bubbles/v2/key"
|
||||
|
||||
type listKeyMap struct {
|
||||
Open key.Binding
|
||||
Filter key.Binding
|
||||
Refresh key.Binding
|
||||
Quit key.Binding
|
||||
Help key.Binding
|
||||
|
||||
Allow key.Binding
|
||||
AllowPerm key.Binding
|
||||
Block key.Binding
|
||||
BlockPerm key.Binding
|
||||
Reject key.Binding
|
||||
RejectPerm key.Binding
|
||||
|
||||
AllowAll key.Binding
|
||||
AllowAllPerm key.Binding
|
||||
CopyRules key.Binding
|
||||
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
GoToStart key.Binding
|
||||
GoToEnd key.Binding
|
||||
PrevPage key.Binding
|
||||
NextPage key.Binding
|
||||
}
|
||||
|
||||
func (k listKeyMap) globalBindings() []key.Binding {
|
||||
return []key.Binding{
|
||||
k.Open, k.Filter, k.Refresh, k.Quit,
|
||||
k.Allow, k.AllowPerm, k.Block, k.BlockPerm, k.Reject, k.RejectPerm,
|
||||
k.AllowAll, k.AllowAllPerm, k.CopyRules,
|
||||
k.Up, k.Down, k.GoToStart, k.GoToEnd, k.PrevPage, k.NextPage,
|
||||
}
|
||||
}
|
||||
|
||||
var listKeys = listKeyMap{
|
||||
Open: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter/tab", "select action")),
|
||||
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
|
||||
Quit: key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q/esc", "quit")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "more")),
|
||||
|
||||
Allow: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "allow")),
|
||||
AllowPerm: key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "allow (perm)")),
|
||||
Block: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "block")),
|
||||
BlockPerm: key.NewBinding(key.WithKeys("B"), key.WithHelp("B", "block (perm)")),
|
||||
Reject: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "reject")),
|
||||
RejectPerm: key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "reject (perm)")),
|
||||
AllowAll: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "allow all")),
|
||||
AllowAllPerm: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "allow all (perm)")),
|
||||
CopyRules: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy rules")),
|
||||
|
||||
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
|
||||
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||
GoToStart: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start")),
|
||||
GoToEnd: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end")),
|
||||
PrevPage: key.NewBinding(key.WithKeys("left", "pgup", "h"), key.WithHelp("←/pgup/h", "prev page")),
|
||||
NextPage: key.NewBinding(key.WithKeys("right", "pgdown", "l"), key.WithHelp("→/pgdn/l", "next page")),
|
||||
}
|
||||
|
||||
var cancelKey = key.NewBinding(key.WithKeys("esc", "q", "ctrl+c"), key.WithHelp("esc/q", "cancel"))
|
||||
@@ -1,564 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/list"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/bubbles"
|
||||
"github.com/anotherhadi/ilovetui/helpbar"
|
||||
"github.com/anotherhadi/ilovetui/modal"
|
||||
"github.com/anotherhadi/ilovetui/notification"
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
"github.com/anotherhadi/usbguard-tui/internal/guard"
|
||||
)
|
||||
|
||||
type (
|
||||
tickMsg time.Time
|
||||
devicesMsg []guard.Device
|
||||
daemonStatusMsg string
|
||||
defaultPolicyMsg guard.Status
|
||||
actionMsg struct{ err error }
|
||||
nixRuleMsg struct{ key, rule string }
|
||||
)
|
||||
|
||||
type pendingRule struct {
|
||||
key string
|
||||
rule string
|
||||
}
|
||||
|
||||
type deviceSummary struct {
|
||||
total, allowed, blocked, rejected int
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
list list.Model
|
||||
help helpbar.Model
|
||||
modals modal.Model
|
||||
notif notification.Model
|
||||
daemonStatus string
|
||||
defaultPolicy guard.Status
|
||||
deviceCounts deviceSummary
|
||||
width int
|
||||
height int
|
||||
rulesManaged bool
|
||||
rulesWritable bool
|
||||
pendingRules []pendingRule
|
||||
fatalShown bool
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
l := bubbles.NewList(nil, 0, 0)
|
||||
l.SetDelegate(deviceDelegate{})
|
||||
l.SetShowHelp(false)
|
||||
l.SetFilteringEnabled(true)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetShowTitle(false)
|
||||
l.DisableQuitKeybindings()
|
||||
l.KeyMap.CursorUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"))
|
||||
l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"))
|
||||
|
||||
h := helpbar.New(
|
||||
helpbar.WithToggle(listKeys.Help),
|
||||
helpbar.WithGlobal(listKeys.globalBindings()...),
|
||||
)
|
||||
|
||||
rulesManaged := guard.IsRulesManaged()
|
||||
rulesWritable, _ := guard.RulesWritable()
|
||||
|
||||
return Model{
|
||||
list: l,
|
||||
help: h,
|
||||
modals: modal.New(),
|
||||
notif: notification.New(),
|
||||
rulesManaged: rulesManaged,
|
||||
rulesWritable: rulesWritable,
|
||||
}
|
||||
}
|
||||
|
||||
func makeActionList(rulesManaged bool) list.Model {
|
||||
var items []list.Item
|
||||
if rulesManaged {
|
||||
items = []list.Item{
|
||||
actionItem{"allow", guard.AllowDevice, false, guard.Allowed, false},
|
||||
actionItem{"allow (perm)", nil, true, guard.Allowed, true},
|
||||
actionItem{"block", guard.BlockDevice, false, guard.Blocked, false},
|
||||
actionItem{"block (perm)", nil, true, guard.Blocked, true},
|
||||
actionItem{"reject", guard.RejectDevice, false, guard.Rejected, false},
|
||||
actionItem{"reject (perm)", nil, true, guard.Rejected, true},
|
||||
}
|
||||
} else {
|
||||
items = []list.Item{
|
||||
actionItem{"allow", guard.AllowDevice, false, guard.Allowed, false},
|
||||
actionItem{"allow (permanent)", guard.AllowDevice, true, guard.Allowed, false},
|
||||
actionItem{"block", guard.BlockDevice, false, guard.Blocked, false},
|
||||
actionItem{"block (permanent)", guard.BlockDevice, true, guard.Blocked, false},
|
||||
actionItem{"reject", guard.RejectDevice, false, guard.Rejected, false},
|
||||
actionItem{"reject (permanent)", guard.RejectDevice, true, guard.Rejected, false},
|
||||
}
|
||||
}
|
||||
l := bubbles.NewList(items, 24, len(items))
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowTitle(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetShowPagination(false)
|
||||
l.DisableQuitKeybindings()
|
||||
l.SetFilteringEnabled(false)
|
||||
|
||||
l.SetDelegate(actionDelegate{})
|
||||
return l
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy, tickCmd(), 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:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.help.SetWidth(msg.Width)
|
||||
m.resizeList()
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy, tickCmd())
|
||||
|
||||
case devicesMsg:
|
||||
items := make([]list.Item, len(msg))
|
||||
summary := deviceSummary{total: len(msg)}
|
||||
for i, d := range msg {
|
||||
items[i] = d
|
||||
switch d.Status {
|
||||
case guard.Allowed:
|
||||
summary.allowed++
|
||||
case guard.Blocked:
|
||||
summary.blocked++
|
||||
case guard.Rejected:
|
||||
summary.rejected++
|
||||
}
|
||||
}
|
||||
m.deviceCounts = summary
|
||||
cmd := m.list.SetItems(items)
|
||||
m.resizeList()
|
||||
return m, cmd
|
||||
|
||||
case daemonStatusMsg:
|
||||
m.daemonStatus = string(msg)
|
||||
return m, nil
|
||||
|
||||
case defaultPolicyMsg:
|
||||
m.defaultPolicy = guard.Status(msg)
|
||||
return m, nil
|
||||
|
||||
case nixRuleMsg:
|
||||
hadPending := len(m.pendingRules) > 0
|
||||
if i := slices.IndexFunc(m.pendingRules, func(r pendingRule) bool { return r.key == msg.key }); i >= 0 {
|
||||
m.pendingRules[i].rule = msg.rule
|
||||
} else {
|
||||
m.pendingRules = append(m.pendingRules, pendingRule{key: msg.key, rule: msg.rule})
|
||||
}
|
||||
if !hadPending {
|
||||
m.resizeList()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case actionMsg:
|
||||
if msg.err != nil {
|
||||
|
||||
if msg.err == guard.ErrPermission {
|
||||
|
||||
if m.fatalShown {
|
||||
return m, nil
|
||||
}
|
||||
m.fatalShown = true
|
||||
return m, modal.Show("Permission Error", newPermissionModal(),
|
||||
modal.WithModalStyle(permissionModalStyle()))
|
||||
}
|
||||
return m, errorToast(msg.err)
|
||||
}
|
||||
return m, fetchDevices
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if m.modals.Open() {
|
||||
return m, nil
|
||||
}
|
||||
return m.updateList(msg)
|
||||
|
||||
case tea.MouseWheelMsg:
|
||||
if m.modals.Open() {
|
||||
return m, nil
|
||||
}
|
||||
switch msg.Button {
|
||||
case tea.MouseWheelUp:
|
||||
m.list.CursorUp()
|
||||
case tea.MouseWheelDown:
|
||||
m.list.CursorDown()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.modals.Open() {
|
||||
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) {
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
if !m.list.SettingFilter() {
|
||||
dev, hasSelection := m.selectedDevice()
|
||||
switch {
|
||||
case key.Matches(msg, listKeys.Quit):
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, listKeys.Refresh):
|
||||
return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy)
|
||||
case key.Matches(msg, listKeys.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
m.resizeList()
|
||||
return m, nil
|
||||
case key.Matches(msg, listKeys.Open):
|
||||
if hasSelection {
|
||||
return m, modal.Show(dev.Name, newActionModal(dev, m.rulesManaged),
|
||||
modal.WithModalStyle(actionModalStyle(dev.Status)))
|
||||
}
|
||||
case key.Matches(msg, listKeys.AllowAll):
|
||||
return m, doBulkAction(m.visibleDeviceIDs(), guard.AllowDevice, false)
|
||||
case key.Matches(msg, listKeys.AllowAllPerm):
|
||||
if m.rulesManaged {
|
||||
return m, queueNixOSRules(m.visibleDevices(), guard.Allowed)
|
||||
}
|
||||
return m, doBulkAction(m.visibleDeviceIDs(), guard.AllowDevice, true)
|
||||
case key.Matches(msg, listKeys.CopyRules):
|
||||
toCopy := m.pendingRules
|
||||
if len(toCopy) == 0 {
|
||||
toCopy = mergeCurrentStateRules(nil, m.visibleDevices())
|
||||
}
|
||||
cmd := copyRulesCmd(toCopy, m.rulesManaged)
|
||||
m.pendingRules = nil
|
||||
m.resizeList()
|
||||
return m, cmd
|
||||
}
|
||||
if hasSelection {
|
||||
if cmd := m.deviceActionCmd(msg, dev); cmd != nil {
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
return tea.View{
|
||||
Content: m.renderContent(),
|
||||
AltScreen: true,
|
||||
WindowTitle: "USBGuard TUI",
|
||||
MouseMode: tea.MouseModeCellMotion,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderContent() string {
|
||||
header := m.renderHeader()
|
||||
listView := strings.TrimRight(m.list.View(), "\n")
|
||||
helpView := strings.TrimRight(m.help.View(), "\n")
|
||||
bg := strings.Join([]string{header, listView, helpView}, "\n")
|
||||
|
||||
bg = m.modals.Render(bg)
|
||||
bg = m.notif.Render(bg)
|
||||
return bg
|
||||
}
|
||||
|
||||
func (m Model) renderHeader() string {
|
||||
title := headerStyle.Render("USBGuard TUI")
|
||||
if style.S.NerdFonts {
|
||||
title = " " + title
|
||||
}
|
||||
lines := []string{
|
||||
title,
|
||||
"",
|
||||
m.renderServiceLine(),
|
||||
m.renderPolicyLine(),
|
||||
m.renderDevicesLine(),
|
||||
m.renderRulesLine(),
|
||||
}
|
||||
if pending := m.renderPendingRulesLine(); pending != "" {
|
||||
lines = append(lines, pending)
|
||||
}
|
||||
|
||||
for i, l := range lines {
|
||||
lines[i] = clampToWidth(l, m.width)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func clampToWidth(s string, width int) string {
|
||||
if width <= 0 {
|
||||
return s
|
||||
}
|
||||
return ansi.Truncate(s, width, "…")
|
||||
}
|
||||
|
||||
func (m Model) renderServiceLine() string {
|
||||
label := infoLabelStyle.Render("Service")
|
||||
switch m.daemonStatus {
|
||||
case "active":
|
||||
return label + daemonActiveStyle.Render("active")
|
||||
case "":
|
||||
return label + mutedStyle.Render("checking...")
|
||||
default:
|
||||
return label + daemonOtherStyle.Render(m.daemonStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderPolicyLine() string {
|
||||
label := infoLabelStyle.Render("Default policy")
|
||||
if m.defaultPolicy == "" {
|
||||
return label + mutedStyle.Render("unknown")
|
||||
}
|
||||
clr, ok := statusColors[m.defaultPolicy]
|
||||
if !ok {
|
||||
clr = style.S.Muted
|
||||
}
|
||||
return label + lipgloss.NewStyle().Foreground(clr).Render(string(m.defaultPolicy))
|
||||
}
|
||||
|
||||
func (m Model) renderDevicesLine() string {
|
||||
label := infoLabelStyle.Render("Devices")
|
||||
if m.deviceCounts.total == 0 {
|
||||
return label + mutedStyle.Render("0")
|
||||
}
|
||||
parts := []string{mutedStyle.Render(fmt.Sprintf("%d total", m.deviceCounts.total))}
|
||||
if m.deviceCounts.allowed > 0 {
|
||||
parts = append(parts, lipgloss.NewStyle().Foreground(statusColors[guard.Allowed]).
|
||||
Render(fmt.Sprintf("%d allow", m.deviceCounts.allowed)))
|
||||
}
|
||||
if m.deviceCounts.blocked > 0 {
|
||||
parts = append(parts, lipgloss.NewStyle().Foreground(statusColors[guard.Blocked]).
|
||||
Render(fmt.Sprintf("%d block", m.deviceCounts.blocked)))
|
||||
}
|
||||
if m.deviceCounts.rejected > 0 {
|
||||
parts = append(parts, lipgloss.NewStyle().Foreground(statusColors[guard.Rejected]).
|
||||
Render(fmt.Sprintf("%d reject", m.deviceCounts.rejected)))
|
||||
}
|
||||
return label + strings.Join(parts, mutedStyle.Render(" · "))
|
||||
}
|
||||
|
||||
func (m Model) renderRulesLine() string {
|
||||
label := infoLabelStyle.Render("Rules")
|
||||
switch {
|
||||
case m.rulesManaged:
|
||||
return label + warnStyle.Render("read-only (NixOS managed)")
|
||||
case !m.rulesWritable:
|
||||
return label + warnStyle.Render("read-only")
|
||||
default:
|
||||
return label + daemonActiveStyle.Render("writable")
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderPendingRulesLine() string {
|
||||
count := len(m.pendingRules)
|
||||
if count == 0 {
|
||||
return ""
|
||||
}
|
||||
noun := "rule"
|
||||
if count > 1 {
|
||||
noun = "rules"
|
||||
}
|
||||
label := infoLabelStyle.Render("Pending rules")
|
||||
return label + warnStyle.Render(fmt.Sprintf("%d %s queued (press %s to copy)", count, noun, listKeys.CopyRules.Help().Key))
|
||||
}
|
||||
|
||||
func (m Model) listHeight() int {
|
||||
headerH := lipgloss.Height(m.renderHeader())
|
||||
helpH := m.help.Height()
|
||||
return m.height - headerH - helpH
|
||||
}
|
||||
|
||||
func (m *Model) resizeList() {
|
||||
m.list.SetSize(m.width, m.listHeight())
|
||||
m.list.SetSize(m.width, m.listHeight())
|
||||
}
|
||||
|
||||
func (m Model) selectedDevice() (guard.Device, bool) {
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
return item.(guard.Device), true
|
||||
}
|
||||
return guard.Device{}, false
|
||||
}
|
||||
|
||||
func (m Model) visibleDevices() []guard.Device {
|
||||
items := m.list.VisibleItems()
|
||||
devices := make([]guard.Device, len(items))
|
||||
for i, item := range items {
|
||||
devices[i] = item.(guard.Device)
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
func (m Model) visibleDeviceIDs() []int {
|
||||
items := m.list.VisibleItems()
|
||||
ids := make([]int, len(items))
|
||||
for i, item := range items {
|
||||
ids[i] = item.(guard.Device).ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
func fetchDevices() tea.Msg {
|
||||
devices, err := guard.ListDevices()
|
||||
if err != nil {
|
||||
return actionMsg{err: err}
|
||||
}
|
||||
return devicesMsg(devices)
|
||||
}
|
||||
|
||||
func fetchDaemonStatus() tea.Msg {
|
||||
return daemonStatusMsg(guard.DaemonStatus())
|
||||
}
|
||||
|
||||
func fetchDefaultPolicy() tea.Msg {
|
||||
return defaultPolicyMsg(guard.DefaultPolicy())
|
||||
}
|
||||
|
||||
func errorToast(err error) tea.Cmd {
|
||||
msg := err.Error()
|
||||
if err == guard.ErrReadOnly {
|
||||
msg = "Rules file is not writable: permanent changes are not supported."
|
||||
}
|
||||
return notification.Show("Error", msg, notification.Error, notification.WithID("action-error"))
|
||||
}
|
||||
|
||||
func doAction(id int, fn func(int, bool) error, permanent bool) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return actionMsg{err: fn(id, permanent)}
|
||||
}
|
||||
}
|
||||
|
||||
func doBulkAction(ids []int, fn func(int, bool) error, permanent bool) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
for _, id := range ids {
|
||||
if err := fn(id, permanent); err != nil {
|
||||
return actionMsg{err: err}
|
||||
}
|
||||
}
|
||||
return actionMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
func queueNixOSRules(devices []guard.Device, status guard.Status) tea.Cmd {
|
||||
cmds := make([]tea.Cmd, len(devices))
|
||||
for i, d := range devices {
|
||||
key := d.VidPid
|
||||
rule := guard.NixOSRule(d, status)
|
||||
cmds[i] = func() tea.Msg { return nixRuleMsg{key: key, rule: rule} }
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func mergeCurrentStateRules(pending []pendingRule, devices []guard.Device) []pendingRule {
|
||||
for _, d := range devices {
|
||||
key := d.VidPid
|
||||
rule := guard.NixOSRule(d, d.Status)
|
||||
if i := slices.IndexFunc(pending, func(r pendingRule) bool { return r.key == key }); i >= 0 {
|
||||
pending[i].rule = rule
|
||||
} else {
|
||||
pending = append(pending, pendingRule{key: key, rule: rule})
|
||||
}
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
func copyRulesCmd(pending []pendingRule, nixos bool) tea.Cmd {
|
||||
if len(pending) == 0 {
|
||||
return notification.Show("Copy rules", "No rules to copy.", notification.Warning)
|
||||
}
|
||||
rules := make([]string, len(pending))
|
||||
for i, r := range pending {
|
||||
rules[i] = r.rule
|
||||
}
|
||||
text := formatRulesForClipboard(rules, nixos)
|
||||
noun := "rule"
|
||||
if len(rules) > 1 {
|
||||
noun = "rules"
|
||||
}
|
||||
msg := fmt.Sprintf("%d %s copied to clipboard.", len(rules), noun)
|
||||
return tea.Batch(
|
||||
tea.SetClipboard(text),
|
||||
notification.Show("Copy rules", msg, notification.Success),
|
||||
)
|
||||
}
|
||||
|
||||
func formatRulesForClipboard(rules []string, nixos bool) string {
|
||||
if !nixos {
|
||||
return strings.Join(rules, "\n")
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# Add to your NixOS configuration:\n")
|
||||
b.WriteString("services.usbguard.rules = lib.mkAfter ''\n")
|
||||
for _, r := range rules {
|
||||
b.WriteString(" " + r + "\n")
|
||||
}
|
||||
b.WriteString("'';")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type actionBinding struct {
|
||||
binding key.Binding
|
||||
fn func(int, bool) error
|
||||
perm bool
|
||||
status guard.Status
|
||||
}
|
||||
|
||||
var deviceActionBindings = []actionBinding{
|
||||
{listKeys.Allow, guard.AllowDevice, false, guard.Allowed},
|
||||
{listKeys.AllowPerm, guard.AllowDevice, true, guard.Allowed},
|
||||
{listKeys.Block, guard.BlockDevice, false, guard.Blocked},
|
||||
{listKeys.BlockPerm, guard.BlockDevice, true, guard.Blocked},
|
||||
{listKeys.Reject, guard.RejectDevice, false, guard.Rejected},
|
||||
{listKeys.RejectPerm, guard.RejectDevice, true, guard.Rejected},
|
||||
}
|
||||
|
||||
func (m Model) deviceActionCmd(msg tea.KeyPressMsg, dev guard.Device) tea.Cmd {
|
||||
for _, b := range deviceActionBindings {
|
||||
if !key.Matches(msg, b.binding) {
|
||||
continue
|
||||
}
|
||||
if b.perm && m.rulesManaged {
|
||||
return queueNixOSRules([]guard.Device{dev}, b.status)
|
||||
}
|
||||
return doAction(dev.ID, b.fn, b.perm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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"))
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
"github.com/anotherhadi/usbguard-tui/internal/guard"
|
||||
)
|
||||
|
||||
var statusColors = map[guard.Status]color.Color{
|
||||
guard.Allowed: style.S.Success,
|
||||
guard.Blocked: style.S.Error,
|
||||
guard.Rejected: style.S.Warning,
|
||||
}
|
||||
|
||||
var statusColorsSelected = map[guard.Status]color.Color{
|
||||
guard.Allowed: style.S.Success,
|
||||
guard.Blocked: style.S.Error,
|
||||
guard.Rejected: style.S.Warning,
|
||||
}
|
||||
|
||||
var (
|
||||
headerStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(style.S.Primary).
|
||||
PaddingLeft(1)
|
||||
|
||||
daemonActiveStyle = lipgloss.NewStyle().Foreground(style.S.Success)
|
||||
daemonOtherStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
|
||||
|
||||
mutedStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
|
||||
|
||||
infoLabelStyle = lipgloss.NewStyle().
|
||||
Foreground(style.S.Muted).
|
||||
PaddingLeft(1).
|
||||
Width(16)
|
||||
|
||||
warnStyle = lipgloss.NewStyle().Foreground(style.S.Warning)
|
||||
)
|
||||
Reference in New Issue
Block a user