Move UI & make a root model.

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-08-20 20:28:24 +02:00
parent e99f32ded8
commit e77eaf4e2c
12 changed files with 76 additions and 47 deletions
+83
View File
@@ -0,0 +1,83 @@
package ui
import (
"fmt"
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/usbguard-tui/internal/guard"
)
type actionModal struct {
list list.Model
dev guard.Device
rulesManaged bool
}
func newActionModal(dev guard.Device, rulesManaged bool) tea.Model {
return actionModal{
list: makeActionList(rulesManaged),
dev: dev,
rulesManaged: rulesManaged,
}
}
func actionModalStyle(status guard.Status) modal.Styles {
s := modal.DefaultStyles()
if clr, ok := statusColors[status]; ok {
s.Title = s.Title.Foreground(clr)
}
return s
}
func (a actionModal) Init() tea.Cmd { return nil }
func (a actionModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch {
case key.Matches(msg, cancelKey):
return a, modal.Close()
case key.Matches(msg, listKeys.Open):
item := a.list.SelectedItem()
if item == nil {
return a, nil
}
it := item.(actionItem)
if it.nixos {
rule := guard.NixOSRule(a.dev, it.status)
key := a.dev.VidPid
return a, tea.Batch(modal.Close(), func() tea.Msg { return nixRuleMsg{key: key, rule: rule} })
}
return a, tea.Batch(modal.Close(), doAction(a.dev.ID, it.fn, it.permanent))
}
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
a.list.CursorUp()
case tea.MouseWheelDown:
a.list.CursorDown()
}
return a, nil
}
var cmd tea.Cmd
a.list, cmd = a.list.Update(msg)
return a, cmd
}
func (a actionModal) View() tea.View {
hintStyle := lipgloss.NewStyle().Foreground(style.S.Muted)
parts := []string{a.list.View(), ""}
if a.rulesManaged {
parts = append(parts, hintStyle.Render(fmt.Sprintf("[NixOS: perm rules queued — press %s to copy]", listKeys.CopyRules.Help().Key)))
}
parts = append(parts, hintStyle.Render("↑↓ navigate enter confirm esc cancel"))
return tea.NewView(strings.Join(parts, "\n"))
}
+106
View File
@@ -0,0 +1,106 @@
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)
}
}
+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
}
+65
View File
@@ -0,0 +1,65 @@
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"))
+539
View File
@@ -0,0 +1,539 @@
package ui
import (
"fmt"
"slices"
"strings"
"time"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/anotherhadi/ilovetui/app"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/helpbar"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/usbguard-tui/internal/guard"
)
type (
tickMsg time.Time
devicesMsg []guard.Device
daemonStatusMsg string
defaultPolicyMsg guard.Status
actionMsg struct{ err error }
nixRuleMsg struct{ key, rule string }
)
type pendingRule struct {
key string
rule string
}
type deviceSummary struct {
total, allowed, blocked, rejected int
}
type Model struct {
list list.Model
help helpbar.Model
daemonStatus string
defaultPolicy guard.Status
deviceCounts deviceSummary
width int
height int
rulesManaged bool
rulesWritable bool
pendingRules []pendingRule
fatalShown bool
ModalOpen bool
}
func New() Model {
l := bubbles.NewList(nil, 0, 0)
l.SetDelegate(deviceDelegate{})
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.SetShowStatusBar(false)
l.SetShowTitle(false)
l.DisableQuitKeybindings()
l.KeyMap.CursorUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"))
l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"))
h := helpbar.New(
helpbar.WithToggle(listKeys.Help),
helpbar.WithGlobal(listKeys.globalBindings()...),
)
rulesManaged := guard.IsRulesManaged()
rulesWritable, _ := guard.RulesWritable()
return Model{
list: l,
help: h,
rulesManaged: rulesManaged,
rulesWritable: rulesWritable,
}
}
func makeActionList(rulesManaged bool) list.Model {
var items []list.Item
if rulesManaged {
items = []list.Item{
actionItem{"allow", guard.AllowDevice, false, guard.Allowed, false},
actionItem{"allow (perm)", nil, true, guard.Allowed, true},
actionItem{"block", guard.BlockDevice, false, guard.Blocked, false},
actionItem{"block (perm)", nil, true, guard.Blocked, true},
actionItem{"reject", guard.RejectDevice, false, guard.Rejected, false},
actionItem{"reject (perm)", nil, true, guard.Rejected, true},
}
} else {
items = []list.Item{
actionItem{"allow", guard.AllowDevice, false, guard.Allowed, false},
actionItem{"allow (permanent)", guard.AllowDevice, true, guard.Allowed, false},
actionItem{"block", guard.BlockDevice, false, guard.Blocked, false},
actionItem{"block (permanent)", guard.BlockDevice, true, guard.Blocked, false},
actionItem{"reject", guard.RejectDevice, false, guard.Rejected, false},
actionItem{"reject (permanent)", guard.RejectDevice, true, guard.Rejected, false},
}
}
l := bubbles.NewList(items, 24, len(items))
l.SetShowHelp(false)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowPagination(false)
l.DisableQuitKeybindings()
l.SetFilteringEnabled(false)
l.SetDelegate(actionDelegate{})
return l
}
func (m Model) Init() tea.Cmd {
return tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy, tickCmd(), app.SetTitle("USBGuard TUI"))
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.help.SetWidth(msg.Width)
m.resizeList()
return m, nil
case tickMsg:
return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy, tickCmd())
case devicesMsg:
items := make([]list.Item, len(msg))
summary := deviceSummary{total: len(msg)}
for i, d := range msg {
items[i] = d
switch d.Status {
case guard.Allowed:
summary.allowed++
case guard.Blocked:
summary.blocked++
case guard.Rejected:
summary.rejected++
}
}
m.deviceCounts = summary
cmd := m.list.SetItems(items)
m.resizeList()
return m, cmd
case daemonStatusMsg:
m.daemonStatus = string(msg)
return m, nil
case defaultPolicyMsg:
m.defaultPolicy = guard.Status(msg)
return m, nil
case nixRuleMsg:
hadPending := len(m.pendingRules) > 0
if i := slices.IndexFunc(m.pendingRules, func(r pendingRule) bool { return r.key == msg.key }); i >= 0 {
m.pendingRules[i].rule = msg.rule
} else {
m.pendingRules = append(m.pendingRules, pendingRule{key: msg.key, rule: msg.rule})
}
if !hadPending {
m.resizeList()
}
return m, nil
case actionMsg:
if msg.err != nil {
if msg.err == guard.ErrPermission {
if m.fatalShown {
return m, nil
}
m.fatalShown = true
return m, modal.Show("Permission Error", newPermissionModal(),
modal.WithModalStyle(permissionModalStyle()))
}
return m, errorToast(msg.err)
}
return m, fetchDevices
case tea.KeyPressMsg:
if m.ModalOpen {
return m, nil
}
return m.updateList(msg)
case tea.MouseWheelMsg:
if m.ModalOpen {
return m, nil
}
switch msg.Button {
case tea.MouseWheelUp:
m.list.CursorUp()
case tea.MouseWheelDown:
m.list.CursorDown()
}
return m, nil
}
if m.ModalOpen {
return m, nil
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m Model) updateList(msg tea.KeyPressMsg) (Model, tea.Cmd) {
if msg.String() == "ctrl+c" {
return m, app.Quit()
}
if !m.list.SettingFilter() {
dev, hasSelection := m.selectedDevice()
switch {
case key.Matches(msg, listKeys.Quit):
return m, app.Quit()
case key.Matches(msg, listKeys.Refresh):
return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy)
case key.Matches(msg, listKeys.Help):
m.help.ShowAll = !m.help.ShowAll
m.resizeList()
return m, nil
case key.Matches(msg, listKeys.Open):
if hasSelection {
return m, modal.Show(dev.Name, newActionModal(dev, m.rulesManaged),
modal.WithModalStyle(actionModalStyle(dev.Status)))
}
case key.Matches(msg, listKeys.AllowAll):
return m, doBulkAction(m.visibleDeviceIDs(), guard.AllowDevice, false)
case key.Matches(msg, listKeys.AllowAllPerm):
if m.rulesManaged {
return m, queueNixOSRules(m.visibleDevices(), guard.Allowed)
}
return m, doBulkAction(m.visibleDeviceIDs(), guard.AllowDevice, true)
case key.Matches(msg, listKeys.CopyRules):
toCopy := m.pendingRules
if len(toCopy) == 0 {
toCopy = mergeCurrentStateRules(nil, m.visibleDevices())
}
cmd := copyRulesCmd(toCopy, m.rulesManaged)
m.pendingRules = nil
m.resizeList()
return m, cmd
}
if hasSelection {
if cmd := m.deviceActionCmd(msg, dev); cmd != nil {
return m, cmd
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m Model) View() string {
header := m.renderHeader()
listView := strings.TrimRight(m.list.View(), "\n")
helpView := strings.TrimRight(m.help.View(), "\n")
return strings.Join([]string{header, listView, helpView}, "\n")
}
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
}
+44
View File
@@ -0,0 +1,44 @@
package ui
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/app"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/style"
)
type permissionModal struct{}
func newPermissionModal() tea.Model { return permissionModal{} }
func permissionModalStyle() modal.Styles {
s := modal.DefaultStyles()
s.Title = s.Title.Foreground(style.S.Error)
s.Border = s.Border.BorderForeground(style.S.Error)
return s
}
func (permissionModal) Init() tea.Cmd { return nil }
func (m permissionModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case tea.KeyPressMsg, tea.MouseClickMsg:
return m, app.Quit()
}
return m, nil
}
func (permissionModal) View() tea.View {
textStyle := lipgloss.NewStyle().Foreground(style.S.Text)
hintStyle := lipgloss.NewStyle().Foreground(style.S.Muted)
parts := []string{
textStyle.Render("Can't reach the usbguard daemon (permission denied)."),
textStyle.Render("Join the usbguard group, or use sudo."),
hintStyle.Render("press any key to quit"),
}
return tea.NewView(strings.Join(parts, "\n"))
}
+40
View File
@@ -0,0 +1,40 @@
package ui
import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/usbguard-tui/internal/guard"
)
var statusColors = map[guard.Status]color.Color{
guard.Allowed: style.S.Success,
guard.Blocked: style.S.Error,
guard.Rejected: style.S.Warning,
}
var statusColorsSelected = map[guard.Status]color.Color{
guard.Allowed: style.S.Success,
guard.Blocked: style.S.Error,
guard.Rejected: style.S.Warning,
}
var (
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(style.S.Primary).
PaddingLeft(1)
daemonActiveStyle = lipgloss.NewStyle().Foreground(style.S.Success)
daemonOtherStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
mutedStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
infoLabelStyle = lipgloss.NewStyle().
Foreground(style.S.Muted).
PaddingLeft(1).
Width(16)
warnStyle = lipgloss.NewStyle().Foreground(style.S.Warning)
)