mirror of
https://github.com/anotherhadi/usbguard-tui.git
synced 2026-05-11 22:02:34 +02:00
Init
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Everybody is invited and welcome to contribute. There is a lot to do... Check the issues!
|
||||||
|
|
||||||
|
The process is straight-forward.
|
||||||
|
|
||||||
|
- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1)
|
||||||
|
- Fork this git repository
|
||||||
|
- Write your changes (bug fixes, new features, ...).
|
||||||
|
- Create a Pull Request against the main branch.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hadi
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
```
|
||||||
|
▖▖▄▖▄ ▄▖ ▌ ▄▖▖▖▄▖
|
||||||
|
▌▌▚ ▙▘▌ ▌▌▀▌▛▘▛▌ ▐ ▌▌▐
|
||||||
|
▙▌▄▌▙▘▙▌▙▌█▌▌ ▙▌ ▐ ▙▌▟▖
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
# USBGuard TUI
|
||||||
|
|
||||||
|
A terminal UI for managing USB devices via [usbguard](https://usbguard.github.io/).
|
||||||
|
Built with [bubbletea](https://github.com/charmbracelet/bubbletea) & Goland!
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- usbguard installed and the daemon running
|
||||||
|
- Sufficient privileges to communicate with the usbguard daemon socket
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Go install</summary>
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install github.com/anotherhadi/usbguard-tui@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Build from source</summary>
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/anotherhadi/usbguard-tui
|
||||||
|
cd usbguard-tui
|
||||||
|
go build -o usbguard-tui .
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Nix run</summary>
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run github:anotherhadi/usbguard-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>NixOS: system installation</summary>
|
||||||
|
|
||||||
|
Add the flake input and include the package in your configuration:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
# flake.nix
|
||||||
|
inputs.usbguard-tui.url = "github:anotherhadi/usbguard-tui";
|
||||||
|
|
||||||
|
# configuration.nix / home.nix
|
||||||
|
environment.systemPackages = [ inputs.usbguard-tui.packages.${system}.default ];
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
usbguard-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
The device list refreshes automatically every 2 seconds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="https://github.com/anotherhadi/usbguard-tui">github</a> |
|
||||||
|
<a href="https://gitlab.com/anotherhadi_mirror/usbguard-tui">gitlab (mirror)</a> |
|
||||||
|
<a href="https://git.hadi.icu/anotherhadi/usbguard-tui">gitea (mirror)</a>
|
||||||
|
</div
|
||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1777268161,
|
||||||
|
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
description = "A terminal UI for managing USB devices via usbguard.";
|
||||||
|
|
||||||
|
inputs = {nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";};
|
||||||
|
|
||||||
|
outputs = {
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
}: let
|
||||||
|
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
||||||
|
|
||||||
|
forAllSystems = f:
|
||||||
|
nixpkgs.lib.genAttrs supportedSystems
|
||||||
|
(system: f system (import nixpkgs {inherit system;}));
|
||||||
|
|
||||||
|
pname = "usbguard-tui";
|
||||||
|
version = "1.0.0";
|
||||||
|
|
||||||
|
ldflags = ["-s" "-w"];
|
||||||
|
in {
|
||||||
|
packages = forAllSystems (system: pkgs: {
|
||||||
|
"${pname}" = pkgs.buildGoModule {
|
||||||
|
inherit pname version ldflags;
|
||||||
|
|
||||||
|
src = ./.;
|
||||||
|
outputs = ["out"];
|
||||||
|
|
||||||
|
vendorHash = "sha256-SMhllO87YlmySHroKfPq1pHb67CwHaZ3XMp3t983etc=";
|
||||||
|
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "A terminal UI for managing USB devices via usbguard.";
|
||||||
|
homepage = "https://github.com/anotherhadi/usbguard-tui";
|
||||||
|
platforms = platforms.unix;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
defaultPackage =
|
||||||
|
forAllSystems (system: pkgs: self.packages.${system}.${pname});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
module github.com/anotherhadi/usbguard-tui
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
golang.org/x/text v0.3.8 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
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.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package guard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Check() error {
|
||||||
|
_, err := exec.LookPath("usbguard")
|
||||||
|
if err != nil {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListDevices() ([]Device, error) {
|
||||||
|
out, err := exec.Command("usbguard", "list-devices").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapExecError(err)
|
||||||
|
}
|
||||||
|
var devices []Device
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d, err := parseLine(line)
|
||||||
|
if err == nil {
|
||||||
|
devices = append(devices, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllowDevice(id int, permanent bool) error { return applyPolicy("allow-device", id, permanent) }
|
||||||
|
func BlockDevice(id int, permanent bool) error { return applyPolicy("block-device", id, permanent) }
|
||||||
|
func RejectDevice(id int, permanent bool) error { return applyPolicy("reject-device", id, permanent) }
|
||||||
|
|
||||||
|
func DaemonStatus() string {
|
||||||
|
out, err := exec.Command("systemctl", "is-active", "usbguard").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPolicy(cmd string, id int, permanent bool) error {
|
||||||
|
args := []string{cmd}
|
||||||
|
if permanent {
|
||||||
|
args = append(args, "-p")
|
||||||
|
}
|
||||||
|
args = append(args, strconv.Itoa(id))
|
||||||
|
out, err := exec.Command("usbguard", args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return classifyError(string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapExecError(err error) error {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return classifyError(string(exitErr.Stderr))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyError(output string) error {
|
||||||
|
lower := strings.ToLower(output)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(lower, "permission denied"), strings.Contains(lower, "not authorized"):
|
||||||
|
return ErrPermission
|
||||||
|
case strings.Contains(lower, "read-only"), strings.Contains(lower, "immutable"):
|
||||||
|
return ErrReadOnly
|
||||||
|
default:
|
||||||
|
return errors.New(strings.TrimSpace(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package guard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Allowed Status = "allow"
|
||||||
|
Blocked Status = "block"
|
||||||
|
Rejected Status = "reject"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Device struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Status Status
|
||||||
|
VidPid string
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return Device{}, errors.New("invalid format")
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.Atoi(strings.TrimSpace(line[:colonIdx]))
|
||||||
|
if err != nil {
|
||||||
|
return Device{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := strings.TrimSpace(line[colonIdx+1:])
|
||||||
|
parts := strings.Fields(rest)
|
||||||
|
if len(parts) < 1 {
|
||||||
|
return Device{}, errors.New("missing status")
|
||||||
|
}
|
||||||
|
|
||||||
|
status := Status(parts[0])
|
||||||
|
name := extractField(rest, "name")
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("Unknown Device #%d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Device{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Status: status,
|
||||||
|
VidPid: extractUnquoted(rest, "id"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractField(rule, field string) string {
|
||||||
|
prefix := field + ` "`
|
||||||
|
idx := strings.Index(rule, prefix)
|
||||||
|
if idx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := rule[idx+len(prefix):]
|
||||||
|
end := strings.Index(rest, `"`)
|
||||||
|
if end < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return rest[:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractUnquoted(rule, field string) string {
|
||||||
|
prefix := field + " "
|
||||||
|
idx := strings.Index(rule, prefix)
|
||||||
|
if idx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := rule[idx+len(prefix):]
|
||||||
|
end := strings.IndexAny(rest, " \t\n")
|
||||||
|
if end < 0 {
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
return rest[:end]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package guard
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("usbguard not found in PATH")
|
||||||
|
ErrPermission = errors.New("insufficient permissions to manage devices")
|
||||||
|
ErrReadOnly = errors.New("rules file is read-only")
|
||||||
|
)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/anotherhadi/usbguard-tui/internal/guard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deviceDelegate renders device list items with status colors.
|
||||||
|
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()
|
||||||
|
|
||||||
|
var color lipgloss.Color
|
||||||
|
if selected {
|
||||||
|
var ok bool
|
||||||
|
color, ok = statusColorsSelected[dev.Status]
|
||||||
|
if !ok {
|
||||||
|
color = colorMuted
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var ok bool
|
||||||
|
color, ok = statusColors[dev.Status]
|
||||||
|
if !ok {
|
||||||
|
color = colorMuted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameStyle, descStyle lipgloss.Style
|
||||||
|
if selected {
|
||||||
|
nameStyle = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||||
|
BorderForeground(colorAccent).
|
||||||
|
Foreground(color).
|
||||||
|
Bold(true).
|
||||||
|
PaddingLeft(1)
|
||||||
|
descStyle = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||||
|
BorderForeground(colorAccent).
|
||||||
|
Foreground(colorMuted).
|
||||||
|
PaddingLeft(1)
|
||||||
|
} else {
|
||||||
|
nameStyle = lipgloss.NewStyle().Foreground(color).PaddingLeft(2)
|
||||||
|
descStyle = lipgloss.NewStyle().Foreground(colorMuted).PaddingLeft(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "%s\n%s",
|
||||||
|
nameStyle.Render(dev.Name),
|
||||||
|
descStyle.Render(fmt.Sprintf("id:%-3d %s %s", dev.ID, dev.VidPid, string(dev.Status))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionItem represents a device policy action in the select popup.
|
||||||
|
type actionItem struct {
|
||||||
|
label string
|
||||||
|
fn func(int, bool) error
|
||||||
|
permanent bool
|
||||||
|
status guard.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a actionItem) Title() string { return a.label }
|
||||||
|
func (a actionItem) Description() string { return "" }
|
||||||
|
func (a actionItem) FilterValue() string { return a.label }
|
||||||
|
|
||||||
|
// actionDelegate renders single-line action items.
|
||||||
|
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() {
|
||||||
|
color, ok := statusColorsSelected[a.status]
|
||||||
|
if !ok {
|
||||||
|
color = colorAccent
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " %s", lipgloss.NewStyle().Bold(true).Foreground(color).Render("> "+a.label))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, " %s", a.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
|
type listKeyMap struct {
|
||||||
|
Open key.Binding
|
||||||
|
Filter key.Binding
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var listKeys = listKeyMap{
|
||||||
|
Open: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "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", "ctrl+c"), key.WithHelp("q", "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("j"), key.WithHelp("j", "reject")),
|
||||||
|
RejectPerm: key.NewBinding(key.WithKeys("J"), key.WithHelp("J", "reject (perm)")),
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancelKey = key.NewBinding(key.WithKeys("esc", "q", "ctrl+c"), key.WithHelp("esc/q", "cancel"))
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/usbguard-tui/internal/guard"
|
||||||
|
"github.com/charmbracelet/bubbles/help"
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type state int
|
||||||
|
|
||||||
|
const (
|
||||||
|
stateList state = iota
|
||||||
|
statePopup
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
tickMsg time.Time
|
||||||
|
devicesMsg []guard.Device
|
||||||
|
daemonStatusMsg string
|
||||||
|
actionMsg struct{ err error }
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() Model {
|
||||||
|
l := list.New(nil, deviceDelegate{}, 0, 0)
|
||||||
|
l.SetShowHelp(false)
|
||||||
|
l.SetFilteringEnabled(true)
|
||||||
|
l.SetShowStatusBar(true)
|
||||||
|
l.SetShowTitle(false)
|
||||||
|
l.DisableQuitKeybindings()
|
||||||
|
// free j/k for our shortcuts
|
||||||
|
l.KeyMap.CursorUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"))
|
||||||
|
l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down"))
|
||||||
|
l.FilterInput.PromptStyle = lipgloss.NewStyle().Foreground(colorAccent)
|
||||||
|
l.FilterInput.Cursor.Style = lipgloss.NewStyle().Foreground(colorAccent)
|
||||||
|
|
||||||
|
return Model{
|
||||||
|
state: stateList,
|
||||||
|
list: l,
|
||||||
|
actionList: makeActionList(),
|
||||||
|
help: help.New(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeActionList() list.Model {
|
||||||
|
items := []list.Item{
|
||||||
|
actionItem{"allow", guard.AllowDevice, false, guard.Allowed},
|
||||||
|
actionItem{"allow (permanent)", guard.AllowDevice, true, guard.Allowed},
|
||||||
|
actionItem{"block", guard.BlockDevice, false, guard.Blocked},
|
||||||
|
actionItem{"block (permanent)", guard.BlockDevice, true, guard.Blocked},
|
||||||
|
actionItem{"reject", guard.RejectDevice, false, guard.Rejected},
|
||||||
|
actionItem{"reject (permanent)", guard.RejectDevice, true, guard.Rejected},
|
||||||
|
}
|
||||||
|
l := list.New(items, actionDelegate{}, 24, 6)
|
||||||
|
l.SetShowHelp(false)
|
||||||
|
l.SetShowTitle(false)
|
||||||
|
l.SetShowStatusBar(false)
|
||||||
|
l.DisableQuitKeybindings()
|
||||||
|
l.SetFilteringEnabled(false)
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
return tea.Batch(fetchDevices, fetchDaemonStatus, tickCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
m.help.Width = msg.Width
|
||||||
|
m.list.SetSize(msg.Width, m.listHeight())
|
||||||
|
m.updateActionListSize()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tickMsg:
|
||||||
|
return m, tea.Batch(fetchDevices, fetchDaemonStatus, tickCmd())
|
||||||
|
|
||||||
|
case devicesMsg:
|
||||||
|
items := make([]list.Item, len(msg))
|
||||||
|
for i, d := range msg {
|
||||||
|
items[i] = d
|
||||||
|
}
|
||||||
|
cmd := m.list.SetItems(items)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case daemonStatusMsg:
|
||||||
|
m.daemonStatus = string(msg)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case actionMsg:
|
||||||
|
m.state = stateList
|
||||||
|
m.selectedDev = nil
|
||||||
|
if msg.err != nil {
|
||||||
|
switch msg.err {
|
||||||
|
case guard.ErrReadOnly:
|
||||||
|
m.notice = "Read-only rules: applied temporarily. Add the rule to your config for persistence."
|
||||||
|
case guard.ErrPermission:
|
||||||
|
m.notice = "Permission denied. Run with appropriate privileges."
|
||||||
|
default:
|
||||||
|
m.notice = msg.err.Error()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.notice = ""
|
||||||
|
}
|
||||||
|
return m, fetchDevices
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if m.state == statePopup {
|
||||||
|
return m.updatePopup(msg)
|
||||||
|
}
|
||||||
|
return m.updateList(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.state == stateList {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.list, cmd = m.list.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if msg.String() == "ctrl+c" {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
if !m.list.SettingFilter() {
|
||||||
|
id := m.selectedDevID()
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, listKeys.Quit):
|
||||||
|
return m, tea.Quit
|
||||||
|
case key.Matches(msg, listKeys.Refresh):
|
||||||
|
m.notice = ""
|
||||||
|
return m, tea.Batch(fetchDevices, fetchDaemonStatus)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
case id >= 0 && key.Matches(msg, listKeys.Allow):
|
||||||
|
return m, doAction(id, guard.AllowDevice, false)
|
||||||
|
case id >= 0 && key.Matches(msg, listKeys.AllowPerm):
|
||||||
|
return m, doAction(id, guard.AllowDevice, true)
|
||||||
|
case id >= 0 && key.Matches(msg, listKeys.Block):
|
||||||
|
return m, doAction(id, guard.BlockDevice, false)
|
||||||
|
case id >= 0 && key.Matches(msg, listKeys.BlockPerm):
|
||||||
|
return m, doAction(id, guard.BlockDevice, true)
|
||||||
|
case id >= 0 && key.Matches(msg, listKeys.Reject):
|
||||||
|
return m, doAction(id, guard.RejectDevice, false)
|
||||||
|
case id >= 0 && key.Matches(msg, listKeys.RejectPerm):
|
||||||
|
return m, doAction(id, guard.RejectDevice, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.list, cmd = m.list.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updatePopup(msg tea.KeyMsg) (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)
|
||||||
|
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() 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")
|
||||||
|
|
||||||
|
if m.state == statePopup && m.selectedDev != nil {
|
||||||
|
return placeOverlay(bg, m.renderActionSelect(), m.width, m.height)
|
||||||
|
}
|
||||||
|
return bg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderHeader() string {
|
||||||
|
title := headerStyle.Render("USBGuard-tui")
|
||||||
|
switch m.daemonStatus {
|
||||||
|
case "active":
|
||||||
|
return title + mutedStyle.Render(" - ") + daemonActiveStyle.Render("active")
|
||||||
|
case "":
|
||||||
|
return title
|
||||||
|
default:
|
||||||
|
return title + mutedStyle.Render(" - ") + daemonOtherStyle.Render(m.daemonStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderNotice() string {
|
||||||
|
if m.notice == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return warnStyle.Render(m.notice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderActionSelect() string {
|
||||||
|
dev := m.selectedDev
|
||||||
|
color := statusColors[dev.Status]
|
||||||
|
innerW := m.actionListInnerWidth()
|
||||||
|
|
||||||
|
title := popupTitleStyle.Copy().Foreground(color).Width(innerW).Render(dev.Name)
|
||||||
|
hint := lipgloss.NewStyle().Foreground(colorMuted).Width(innerW).Render("↑↓ navigate enter confirm esc cancel")
|
||||||
|
|
||||||
|
content := strings.Join([]string{title, m.actionList.View(), "", hint}, "\n")
|
||||||
|
return popupStyle.Width(innerW).Render(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
const items = 6
|
||||||
|
innerW := m.actionListInnerWidth()
|
||||||
|
// popup overhead: border(2) + padding_v(2) + title(1) + blank(1) + hint(1) = 7
|
||||||
|
available := m.height - 7 - 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) listHeight() int {
|
||||||
|
helpH := lipgloss.Height(strings.TrimRight(m.help.View(listKeys), "\n"))
|
||||||
|
return m.height - 1 - helpH - 1 // header - help - notice
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) selectedDevID() int {
|
||||||
|
if item := m.list.SelectedItem(); item != nil {
|
||||||
|
return item.(guard.Device).ID
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
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 doAction(id int, fn func(int, bool) error, permanent bool) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
return actionMsg{err: fn(id, permanent)}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/anotherhadi/usbguard-tui/internal/guard"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
colorAllowed = lipgloss.Color("28")
|
||||||
|
colorAllowedSelected = lipgloss.Color("42")
|
||||||
|
colorBlocked = lipgloss.Color("124")
|
||||||
|
colorBlockedSelected = lipgloss.Color("196")
|
||||||
|
colorRejected = lipgloss.Color("130")
|
||||||
|
colorRejectedSelected = lipgloss.Color("214")
|
||||||
|
colorMuted = lipgloss.Color("240")
|
||||||
|
colorAccent = lipgloss.Color("99")
|
||||||
|
)
|
||||||
|
|
||||||
|
var statusColors = map[guard.Status]lipgloss.Color{
|
||||||
|
guard.Allowed: colorAllowed,
|
||||||
|
guard.Blocked: colorBlocked,
|
||||||
|
guard.Rejected: colorRejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusColorsSelected = map[guard.Status]lipgloss.Color{
|
||||||
|
guard.Allowed: colorAllowedSelected,
|
||||||
|
guard.Blocked: colorBlockedSelected,
|
||||||
|
guard.Rejected: colorRejectedSelected,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
headerStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(colorAccent).
|
||||||
|
PaddingLeft(1)
|
||||||
|
|
||||||
|
daemonActiveStyle = lipgloss.NewStyle().Foreground(colorAllowedSelected)
|
||||||
|
daemonOtherStyle = lipgloss.NewStyle().Foreground(colorMuted)
|
||||||
|
|
||||||
|
mutedStyle = lipgloss.NewStyle().Foreground(colorMuted)
|
||||||
|
|
||||||
|
popupStyle = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(colorAccent).
|
||||||
|
Padding(1, 3)
|
||||||
|
|
||||||
|
popupTitleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1)
|
||||||
|
|
||||||
|
keyHintStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
|
||||||
|
warnStyle = lipgloss.NewStyle().Foreground(colorRejected)
|
||||||
|
errStyle = lipgloss.NewStyle().Foreground(colorBlocked).Bold(true)
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/anotherhadi/usbguard-tui/internal/guard"
|
||||||
|
"github.com/anotherhadi/usbguard-tui/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := guard.Check(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := tea.NewProgram(ui.New(), tea.WithAltScreen())
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user