5 Commits

Author SHA1 Message Date
Hadi 4199b6aa7f v1.2.0
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-08-20 20:31:14 +02:00
Hadi ccc353261b Shorthelp/Fullhelp
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-08-20 20:30:55 +02:00
Hadi e77eaf4e2c Move UI & make a root model.
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-08-20 20:28:24 +02:00
Hadi e99f32ded8 Print on exit -> copy to clipboard
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-08-20 19:51:33 +02:00
Hadi 1b19643db4 fix layout
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-08-20 19:31:36 +02:00
12 changed files with 184 additions and 84 deletions
+55
View File
@@ -0,0 +1,55 @@
package main
import (
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/app"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/usbguard-tui/ui"
)
type appModel struct {
core ui.Model
modals modal.Model
notif notification.Model
}
func newApp() appModel {
return appModel{
core: ui.New(),
modals: modal.New(),
notif: notification.New(),
}
}
func (a appModel) Init() tea.Cmd {
return tea.Batch(a.core.Init(), a.modals.Init(), a.notif.Init())
}
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(app.QuitMsg); ok {
return a, tea.Quit
}
a.core.ModalOpen = a.modals.Open()
var coreCmd tea.Cmd
a.core, coreCmd = a.core.Update(msg)
var modalCmd, notifCmd tea.Cmd
a.modals, modalCmd = a.modals.Update(msg)
a.notif, notifCmd = a.notif.Update(msg)
return a, tea.Batch(coreCmd, modalCmd, notifCmd)
}
func (a appModel) View() tea.View {
bg := a.core.View()
bg = a.modals.Render(bg)
bg = a.notif.Render(bg)
return tea.View{
Content: bg,
AltScreen: true,
WindowTitle: "USBGuard TUI",
MouseMode: tea.MouseModeCellMotion,
}
}
+2 -2
View File
@@ -14,7 +14,7 @@
(system: f system (import nixpkgs {inherit system;})); (system: f system (import nixpkgs {inherit system;}));
pname = "usbguard-tui"; pname = "usbguard-tui";
version = "1.1.0"; version = "1.2.0";
ldflags = ["-s" "-w" "-X main.version=${version}"]; ldflags = ["-s" "-w" "-X main.version=${version}"];
in { in {
@@ -25,7 +25,7 @@
src = ./.; src = ./.;
outputs = ["out"]; outputs = ["out"];
vendorHash = "sha256-tXMeJy9IpXTRhikYedcL+76H9X3In9mb1/KnN1XFPu4="; vendorHash = "sha256-8QP2FbNmvqrJhFe3Ia7tPhkxMeFWBhvr4Zr9kpV9bR4=";
meta = with pkgs.lib; { meta = with pkgs.lib; {
description = "A terminal UI for managing USB devices via usbguard."; description = "A terminal UI for managing USB devices via usbguard.";
+1 -1
View File
@@ -6,7 +6,7 @@ require (
charm.land/bubbles/v2 v2.1.0 charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.6 charm.land/bubbletea/v2 v2.0.6
charm.land/lipgloss/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.3
github.com/anotherhadi/ilovetui v0.2.1 github.com/anotherhadi/ilovetui v0.2.2
github.com/charmbracelet/x/ansi v0.11.7 github.com/charmbracelet/x/ansi v0.11.7
) )
+2 -2
View File
@@ -14,8 +14,8 @@ github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 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/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.2 h1:ZOwyZRxFBZKVJeEAR+lg0xeQfsMZuymtKLwBV0MnBYY=
github.com/anotherhadi/ilovetui v0.2.1/go.mod h1:gAn69amLM3z0x8PV4obyf5bJntL/PTZusEuhu/xdFIY= github.com/anotherhadi/ilovetui v0.2.2/go.mod h1:gAn69amLM3z0x8PV4obyf5bJntL/PTZusEuhu/xdFIY=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
+2 -14
View File
@@ -6,7 +6,6 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/usbguard-tui/internal/guard" "github.com/anotherhadi/usbguard-tui/internal/guard"
"github.com/anotherhadi/usbguard-tui/internal/ui"
) )
var version = "dev" var version = "dev"
@@ -22,20 +21,9 @@ func main() {
os.Exit(1) os.Exit(1)
} }
p := tea.NewProgram(ui.New()) p := tea.NewProgram(newApp())
m, err := p.Run() if _, err := p.Run(); err != nil {
if err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
if fm, ok := m.(ui.Model); ok {
if rules := fm.PendingRules(); len(rules) > 0 {
fmt.Println("# Add to your NixOS configuration:")
fmt.Println("services.usbguard.rules = lib.mkAfter ''")
for _, rule := range rules {
fmt.Println(" ", rule)
}
fmt.Println("'';")
}
}
} }
@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"fmt"
"strings" "strings"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
@@ -50,7 +51,8 @@ func (a actionModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
it := item.(actionItem) it := item.(actionItem)
if it.nixos { if it.nixos {
rule := guard.NixOSRule(a.dev, it.status) rule := guard.NixOSRule(a.dev, it.status)
return a, tea.Batch(modal.Close(), func() tea.Msg { return nixRuleMsg{rule: rule} }) 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)) return a, tea.Batch(modal.Close(), doAction(a.dev.ID, it.fn, it.permanent))
} }
@@ -74,7 +76,7 @@ func (a actionModal) View() tea.View {
hintStyle := lipgloss.NewStyle().Foreground(style.S.Muted) hintStyle := lipgloss.NewStyle().Foreground(style.S.Muted)
parts := []string{a.list.View(), ""} parts := []string{a.list.View(), ""}
if a.rulesManaged { if a.rulesManaged {
parts = append(parts, hintStyle.Render("[NixOS: perm rules printed on exit]")) 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")) parts = append(parts, hintStyle.Render("↑↓ navigate enter confirm esc cancel"))
return tea.NewView(strings.Join(parts, "\n")) return tea.NewView(strings.Join(parts, "\n"))
+9 -3
View File
@@ -18,7 +18,7 @@ type listKeyMap struct {
AllowAll key.Binding AllowAll key.Binding
AllowAllPerm key.Binding AllowAllPerm key.Binding
PrintAll key.Binding CopyRules key.Binding
Up key.Binding Up key.Binding
Down key.Binding Down key.Binding
@@ -32,11 +32,17 @@ func (k listKeyMap) globalBindings() []key.Binding {
return []key.Binding{ return []key.Binding{
k.Open, k.Filter, k.Refresh, k.Quit, k.Open, k.Filter, k.Refresh, k.Quit,
k.Allow, k.AllowPerm, k.Block, k.BlockPerm, k.Reject, k.RejectPerm, k.Allow, k.AllowPerm, k.Block, k.BlockPerm, k.Reject, k.RejectPerm,
k.AllowAll, k.AllowAllPerm, k.PrintAll, k.AllowAll, k.AllowAllPerm, k.CopyRules,
k.Up, k.Down, k.GoToStart, k.GoToEnd, k.PrevPage, k.NextPage, k.Up, k.Down, k.GoToStart, k.GoToEnd, k.PrevPage, k.NextPage,
} }
} }
func (k listKeyMap) shortHelpBindings() []key.Binding {
return []key.Binding{
k.Open, k.Allow, k.Block, k.Reject, k.Filter, k.Quit,
}
}
var listKeys = listKeyMap{ var listKeys = listKeyMap{
Open: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter/tab", "select action")), Open: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter/tab", "select action")),
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
@@ -52,7 +58,7 @@ var listKeys = listKeyMap{
RejectPerm: key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "reject (perm)")), RejectPerm: key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "reject (perm)")),
AllowAll: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "allow all")), AllowAll: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "allow all")),
AllowAllPerm: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "allow all (perm)")), AllowAllPerm: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "allow all (perm)")),
PrintAll: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "print all rules")), CopyRules: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy rules")),
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
+106 -57
View File
@@ -2,6 +2,7 @@ package ui
import ( import (
"fmt" "fmt"
"slices"
"strings" "strings"
"time" "time"
@@ -11,6 +12,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
"github.com/anotherhadi/ilovetui/app"
"github.com/anotherhadi/ilovetui/bubbles" "github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/helpbar" "github.com/anotherhadi/ilovetui/helpbar"
"github.com/anotherhadi/ilovetui/modal" "github.com/anotherhadi/ilovetui/modal"
@@ -25,9 +27,14 @@ type (
daemonStatusMsg string daemonStatusMsg string
defaultPolicyMsg guard.Status defaultPolicyMsg guard.Status
actionMsg struct{ err error } actionMsg struct{ err error }
nixRuleMsg struct{ rule string } nixRuleMsg struct{ key, rule string }
) )
type pendingRule struct {
key string
rule string
}
type deviceSummary struct { type deviceSummary struct {
total, allowed, blocked, rejected int total, allowed, blocked, rejected int
} }
@@ -35,8 +42,6 @@ type deviceSummary struct {
type Model struct { type Model struct {
list list.Model list list.Model
help helpbar.Model help helpbar.Model
modals modal.Model
notif notification.Model
daemonStatus string daemonStatus string
defaultPolicy guard.Status defaultPolicy guard.Status
deviceCounts deviceSummary deviceCounts deviceSummary
@@ -44,11 +49,11 @@ type Model struct {
height int height int
rulesManaged bool rulesManaged bool
rulesWritable bool rulesWritable bool
pendingRules []string pendingRules []pendingRule
fatalShown bool fatalShown bool
}
func (m Model) PendingRules() []string { return m.pendingRules } ModalOpen bool
}
func New() Model { func New() Model {
l := bubbles.NewList(nil, 0, 0) l := bubbles.NewList(nil, 0, 0)
@@ -63,7 +68,6 @@ func New() Model {
h := helpbar.New( h := helpbar.New(
helpbar.WithToggle(listKeys.Help), helpbar.WithToggle(listKeys.Help),
helpbar.WithGlobal(listKeys.globalBindings()...),
) )
rulesManaged := guard.IsRulesManaged() rulesManaged := guard.IsRulesManaged()
@@ -72,8 +76,6 @@ func New() Model {
return Model{ return Model{
list: l, list: l,
help: h, help: h,
modals: modal.New(),
notif: notification.New(),
rulesManaged: rulesManaged, rulesManaged: rulesManaged,
rulesWritable: rulesWritable, rulesWritable: rulesWritable,
} }
@@ -113,28 +115,17 @@ func makeActionList(rulesManaged bool) list.Model {
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy, tickCmd(), m.modals.Init(), m.notif.Init()) return tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy, tickCmd(), app.SetTitle("USBGuard TUI"))
} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
next, cmd := m.updateMain(msg)
nm := next.(Model)
var modalCmd, notifCmd tea.Cmd
nm.modals, modalCmd = nm.modals.Update(msg)
nm.notif, notifCmd = nm.notif.Update(msg)
return nm, tea.Batch(cmd, modalCmd, notifCmd)
}
func (m Model) updateMain(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.help.SetWidth(msg.Width) m.help.SetWidth(msg.Width)
m.list.SetSize(msg.Width, m.listHeight()) m.resizeList()
return m, nil return m, nil
case tickMsg: case tickMsg:
@@ -156,6 +147,7 @@ func (m Model) updateMain(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.deviceCounts = summary m.deviceCounts = summary
cmd := m.list.SetItems(items) cmd := m.list.SetItems(items)
m.resizeList()
return m, cmd return m, cmd
case daemonStatusMsg: case daemonStatusMsg:
@@ -167,7 +159,15 @@ func (m Model) updateMain(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case nixRuleMsg: case nixRuleMsg:
m.pendingRules = append(m.pendingRules, msg.rule) 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 return m, nil
case actionMsg: case actionMsg:
@@ -187,13 +187,13 @@ func (m Model) updateMain(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, fetchDevices return m, fetchDevices
case tea.KeyPressMsg: case tea.KeyPressMsg:
if m.modals.Open() { if m.ModalOpen {
return m, nil return m, nil
} }
return m.updateList(msg) return m.updateList(msg)
case tea.MouseWheelMsg: case tea.MouseWheelMsg:
if m.modals.Open() { if m.ModalOpen {
return m, nil return m, nil
} }
switch msg.Button { switch msg.Button {
@@ -205,7 +205,7 @@ func (m Model) updateMain(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if m.modals.Open() { if m.ModalOpen {
return m, nil return m, nil
} }
var cmd tea.Cmd var cmd tea.Cmd
@@ -213,20 +213,20 @@ func (m Model) updateMain(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
func (m Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m Model) updateList(msg tea.KeyPressMsg) (Model, tea.Cmd) {
if msg.String() == "ctrl+c" { if msg.String() == "ctrl+c" {
return m, tea.Quit return m, app.Quit()
} }
if !m.list.SettingFilter() { if !m.list.SettingFilter() {
dev, hasSelection := m.selectedDevice() dev, hasSelection := m.selectedDevice()
switch { switch {
case key.Matches(msg, listKeys.Quit): case key.Matches(msg, listKeys.Quit):
return m, tea.Quit return m, app.Quit()
case key.Matches(msg, listKeys.Refresh): case key.Matches(msg, listKeys.Refresh):
return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy) return m, tea.Batch(fetchDevices, fetchDaemonStatus, fetchDefaultPolicy)
case key.Matches(msg, listKeys.Help): case key.Matches(msg, listKeys.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
m.list.SetSize(m.width, m.listHeight()) m.resizeList()
return m, nil return m, nil
case key.Matches(msg, listKeys.Open): case key.Matches(msg, listKeys.Open):
if hasSelection { if hasSelection {
@@ -240,8 +240,15 @@ func (m Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, queueNixOSRules(m.visibleDevices(), guard.Allowed) return m, queueNixOSRules(m.visibleDevices(), guard.Allowed)
} }
return m, doBulkAction(m.visibleDeviceIDs(), guard.AllowDevice, true) return m, doBulkAction(m.visibleDeviceIDs(), guard.AllowDevice, true)
case key.Matches(msg, listKeys.PrintAll): case key.Matches(msg, listKeys.CopyRules):
return m, queueCurrentStateRules(m.visibleDevices()) 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 hasSelection {
if cmd := m.deviceActionCmd(msg, dev); cmd != nil { if cmd := m.deviceActionCmd(msg, dev); cmd != nil {
@@ -254,30 +261,28 @@ func (m Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
func (m Model) View() tea.View { func (m Model) View() string {
return tea.View{
Content: m.renderContent(),
AltScreen: true,
WindowTitle: "USBGuard TUI",
MouseMode: tea.MouseModeCellMotion,
}
}
func (m Model) renderContent() string {
header := m.renderHeader() header := m.renderHeader()
listView := strings.TrimRight(m.list.View(), "\n") listView := strings.TrimRight(m.list.View(), "\n")
helpView := strings.TrimRight(m.help.View(), "\n") helpView := strings.TrimRight(m.help.View(m.helpBindings()...), "\n")
bg := strings.Join([]string{header, listView, helpView}, "\n") return strings.Join([]string{header, listView, helpView}, "\n")
}
bg = m.modals.Render(bg) func (m Model) helpBindings() []key.Binding {
bg = m.notif.Render(bg) if m.help.ShowAll {
return bg return listKeys.globalBindings()
}
return listKeys.shortHelpBindings()
} }
func (m Model) renderHeader() string { func (m Model) renderHeader() string {
title := headerStyle.Render("USBGuard-tui") title := headerStyle.Render("USBGuard TUI")
if style.S.NerdFonts {
title = "󱊟 " + title
}
lines := []string{ lines := []string{
title, title,
"",
m.renderServiceLine(), m.renderServiceLine(),
m.renderPolicyLine(), m.renderPolicyLine(),
m.renderDevicesLine(), m.renderDevicesLine(),
@@ -367,15 +372,20 @@ func (m Model) renderPendingRulesLine() string {
noun = "rules" noun = "rules"
} }
label := infoLabelStyle.Render("Pending rules") label := infoLabelStyle.Render("Pending rules")
return label + warnStyle.Render(fmt.Sprintf("%d %s queued (printed on exit)", count, noun)) return label + warnStyle.Render(fmt.Sprintf("%d %s queued (press %s to copy)", count, noun, listKeys.CopyRules.Help().Key))
} }
func (m Model) listHeight() int { func (m Model) listHeight() int {
headerH := lipgloss.Height(m.renderHeader()) headerH := lipgloss.Height(m.renderHeader())
helpH := m.help.Height() helpH := m.help.Height(m.helpBindings()...)
return m.height - headerH - helpH 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) { func (m Model) selectedDevice() (guard.Device, bool) {
if item := m.list.SelectedItem(); item != nil { if item := m.list.SelectedItem(); item != nil {
return item.(guard.Device), true return item.(guard.Device), true
@@ -451,19 +461,58 @@ func doBulkAction(ids []int, fn func(int, bool) error, permanent bool) tea.Cmd {
func queueNixOSRules(devices []guard.Device, status guard.Status) tea.Cmd { func queueNixOSRules(devices []guard.Device, status guard.Status) tea.Cmd {
cmds := make([]tea.Cmd, len(devices)) cmds := make([]tea.Cmd, len(devices))
for i, d := range devices { for i, d := range devices {
key := d.VidPid
rule := guard.NixOSRule(d, status) rule := guard.NixOSRule(d, status)
cmds[i] = func() tea.Msg { return nixRuleMsg{rule: rule} } cmds[i] = func() tea.Msg { return nixRuleMsg{key: key, rule: rule} }
} }
return tea.Batch(cmds...) return tea.Batch(cmds...)
} }
func queueCurrentStateRules(devices []guard.Device) tea.Cmd { func mergeCurrentStateRules(pending []pendingRule, devices []guard.Device) []pendingRule {
cmds := make([]tea.Cmd, len(devices)) for _, d := range devices {
for i, d := range devices { key := d.VidPid
rule := guard.NixOSRule(d, d.Status) rule := guard.NixOSRule(d, d.Status)
cmds[i] = func() tea.Msg { return nixRuleMsg{rule: rule} } 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 tea.Batch(cmds...) }
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 { type actionBinding struct {
@@ -5,6 +5,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/app"
"github.com/anotherhadi/ilovetui/modal" "github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/style" "github.com/anotherhadi/ilovetui/style"
) )
@@ -25,7 +26,7 @@ func (permissionModal) Init() tea.Cmd { return nil }
func (m permissionModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m permissionModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) { switch msg.(type) {
case tea.KeyPressMsg, tea.MouseClickMsg: case tea.KeyPressMsg, tea.MouseClickMsg:
return m, tea.Quit return m, app.Quit()
} }
return m, nil return m, nil
} }
@@ -35,8 +36,7 @@ func (permissionModal) View() tea.View {
hintStyle := lipgloss.NewStyle().Foreground(style.S.Muted) hintStyle := lipgloss.NewStyle().Foreground(style.S.Muted)
parts := []string{ parts := []string{
textStyle.Render("Can't reach the usbguard daemon"), textStyle.Render("Can't reach the usbguard daemon (permission denied)."),
textStyle.Render("(permission denied)."),
textStyle.Render("Join the usbguard group, or use sudo."), textStyle.Render("Join the usbguard group, or use sudo."),
hintStyle.Render("press any key to quit"), hintStyle.Render("press any key to quit"),
} }