goGoFetch/main.go
2026-06-25 18:59:31 +03:00

614 lines
14 KiB
Go

package main
import (
"bufio"
"flag"
"fmt"
_ "image/png"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/BurntSushi/toml"
)
const (
Reset = "\033[0m"
Blue = "\033[1;34m"
)
var dist string
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
type Config struct {
Ascii bool `toml:"ascii"`
Layout []string `toml:"layout"`
CustomLogo string `toml:"custom_logo"`
LogoPosition string `toml:"logo_position"`
}
func appendMissingConfigKeys(pathConf string, md toml.MetaData) error {
var missing []string
if !md.IsDefined("ascii") {
missing = append(missing, "# Set this to \"true\" if you like ascii more than images\nascii = false")
}
if !md.IsDefined("layout") {
missing = append(missing, `# Stats layout: reorder or remove items to customize your fetch
# Available: "dist", "host", "cpu", "kernel", "ram", "gpu", "de/wm", "pkgs", "shell", "terminal", "uptime"
layout = [
"host",
"dist",
"cpu",
"kernel",
"ram",
"gpu",
"de/wm",
"pkgs",
"shell",
"terminal",
"uptime",
]`)
}
if !md.IsDefined("custom_logo") {
missing = append(missing, "# Absolute path to a custom image or .txt file. Leave empty to use auto-detection.\ncustom_logo = \"\"")
}
if !md.IsDefined("logo_position") {
missing = append(missing, "# Position of the logo block relative to the stats block: \"left\" or \"right\".\nlogo_position = \"left\"")
}
if len(missing) == 0 {
return nil
}
f, err := os.OpenFile(pathConf, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer func(f *os.File) {
_ = f.Close()
}(f)
block := "\n# Added automatically after update\n\n" + strings.Join(missing, "\n\n") + "\n"
_, err = f.WriteString(block)
return err
}
func getConfigPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
directoryName := "gogofetch"
fullPath := filepath.Join(configDir, directoryName, "config.toml")
return fullPath, nil
}
func initConfig() (Config, string) {
var conf Config
pathConf, err := getConfigPath()
if err != nil {
return conf, ""
}
err = os.MkdirAll(filepath.Dir(pathConf), 0755)
if err != nil {
return Config{}, ""
}
if _, err := os.Stat(pathConf); os.IsNotExist(err) {
defaultConfig := []byte(`
# Set this to "true" if you like ascii more than images
ascii = false
# Stats layout: reorder or remove items to customize your fetch
# Available: "dist", "host", "cpu", "kernel", "ram", "gpu", "de/wm", "pkgs", "shell", "terminal", "uptime"
layout = [
"host",
"dist",
"cpu",
"kernel",
"ram",
"gpu",
"de/wm",
"pkgs",
"shell",
"terminal",
"uptime",
]
# Absolute path to a custom image or .txt file. Leave empty to use auto-detection.
custom_logo = ""
# Position of the logo block relative to the stats block: "left" or "right".
logo_position = "left"
`)
err := os.WriteFile(pathConf, defaultConfig, 0644)
if err != nil {
return Config{}, ""
}
}
md, err := toml.DecodeFile(pathConf, &conf)
if err != nil {
fmt.Printf("Error loading config at %s: %v\n", pathConf, err)
return conf, pathConf
}
if err := appendMissingConfigKeys(pathConf, md); err != nil {
fmt.Printf("Warning: Could not update config at %s: %v\n", pathConf, err)
} else {
if _, err := toml.DecodeFile(pathConf, &conf); err != nil {
fmt.Printf("Error reloading updated config at %s: %v\n", pathConf, err)
}
}
return conf, pathConf
}
func getDist() string {
f, _ := os.Open("/etc/os-release")
defer func(f *os.File) {
err := f.Close()
if err != nil {
fmt.Println("Error closing file:", err)
}
}(f)
s := bufio.NewScanner(f)
for s.Scan() {
t := s.Text()
if strings.HasPrefix(t, "PRETTY_NAME") {
distro := strings.TrimPrefix(t, "PRETTY_NAME=")
dist = strings.Trim(distro, "\"")
dist = strings.TrimSpace(dist)
return dist
}
}
return "no /etc/os-release file found"
}
func getRam() string {
f, err := os.Open("/proc/meminfo")
if err != nil {
return "error"
}
var total, available int
s := bufio.NewScanner(f)
for s.Scan() {
line := s.Text()
if strings.HasPrefix(line, "MemTotal:") {
_, err2 := fmt.Sscanf(line, "MemTotal: %d kB", &total)
if err2 != nil {
return ""
}
}
if strings.HasPrefix(line, "MemAvailable:") {
_, err := fmt.Sscanf(line, "MemAvailable: %d kB", &available)
if err != nil {
return ""
}
}
}
totalMB := total / 1024
availableMB := available / 1024
usedMB := totalMB - availableMB
ram := fmt.Sprintf("%d / %d MiB (%d MiB available)", usedMB, totalMB, availableMB)
ram = strings.TrimSpace(ram)
return ram
}
func getCpu() string {
f, err := os.Open("/proc/cpuinfo")
if err != nil {
return "error"
}
var cpu string
s := bufio.NewScanner(f)
for s.Scan() {
line := s.Text()
if strings.HasPrefix(line, "model name") {
parts := strings.SplitN(line, ":", 2)
if len(parts) > 1 {
cpu := strings.TrimSpace(parts[1])
return cpu
}
}
}
return strings.TrimSpace(cpu)
}
func getGpu() string {
out, err := exec.Command("sh", "-c", `lspci -mm | awk -F'"' '$2=="VGA compatible controller" || $2=="3D controller" || $2=="Display controller"'`).Output()
if err != nil || len(out) == 0 {
return "Unknown GPU"
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
var gpus []string
for _, line := range lines {
re := regexp.MustCompile(`"([^"]+)"`)
matches := re.FindAllStringSubmatch(line, -1)
if len(matches) >= 3 {
vendor := matches[1][1]
device := matches[2][1]
cleaned := cleanGpuString(vendor + " " + device)
gpus = append(gpus, cleaned)
}
}
if len(gpus) > 0 {
return strings.Join(gpus, " / ")
}
return "GPU not found"
}
func cleanGpuString(raw string) string {
prettyNameRe := regexp.MustCompile(`\[(.*?)]`)
match := prettyNameRe.FindStringSubmatch(raw)
res := raw
if len(match) > 1 {
vendor := ""
if strings.Contains(strings.ToLower(raw), "nvidia") {
vendor = "NVIDIA "
}
if strings.Contains(strings.ToLower(raw), "intel") {
vendor = "Intel "
}
if strings.Contains(strings.ToLower(raw), "amd") {
vendor = "AMD "
}
res = vendor + match[1]
}
re := regexp.MustCompile(`\(.*?\)|\[.*?]`)
res = re.ReplaceAllString(res, "")
fluff := []string{"Corporation", "Controller", "VGA compatible", "3D", "Graphics", "Device"}
for _, word := range fluff {
res = strings.ReplaceAll(res, word, "")
}
return strings.Join(strings.Fields(res), " ")
}
func getKernel() string {
out, err := exec.Command("sh", "-c", "uname -r").Output()
if err != nil {
return "Error getting kernel, how did we get there?"
}
return strings.TrimSpace(string(out))
}
func getHostname() string {
hostname, err := exec.Command("sh", "-c", "uname -n").Output()
if err != nil {
return "Error getting hostname, awful"
}
username, errUser := exec.Command("sh", "-c", "whoami").Output()
if errUser != nil {
return "Error getting username, nobody here but us chickens!"
}
fullHostname := fmt.Sprintf("%s@%s", strings.TrimSpace(string(username)), strings.TrimSpace(string(hostname)))
return fullHostname
}
func checkNix() bool {
_, err := exec.Command("sh", "-c", "command -v nix-env >/dev/null 2>&1").Output()
if err != nil {
return false
}
return true
}
func addNix() string {
out, _ := exec.Command("sh", "-c", "nix-env -q | wc -l").Output()
nixPkgs := strings.TrimSpace(string(out)) + " (nix)"
return nixPkgs
}
func getPkgs() string {
var pkgs string
haveNix := checkNix()
if strings.HasPrefix(dist, "Arch") {
out, _ := exec.Command("sh", "-c", "pacman -Qq | wc -l").Output()
pkgs = strings.TrimSpace(string(out)) + " (pacman)"
if haveNix {
pkgs = strings.TrimSpace(pkgs + ", " + addNix())
}
return pkgs
}
if strings.HasPrefix(dist, "Debian") {
out, _ := exec.Command("sh", "-c", "dpkg -l | grep ^ii | wc -l").Output()
pkgs = strings.TrimSpace(string(out)) + " (apt)"
if haveNix {
pkgs = strings.TrimSpace(pkgs + ", " + addNix())
}
return pkgs
}
if strings.HasPrefix(dist, "NixOS") {
entries, err := os.ReadDir("/run/current-system/sw/bin")
if err != nil {
return "can't get nix packages"
}
return strconv.Itoa(len(entries)) + " (nix)"
}
return "unknown"
}
func getLogo(dist string) string {
switch {
case strings.HasPrefix(dist, "Arch"):
return "logos/arch.png"
case strings.HasPrefix(dist, "NixOS"):
return "logos/nixos.png"
case strings.HasPrefix(dist, "Debian"):
return "logos/debian.png"
default:
return "logos/linux.png"
}
}
func getAscii(dist string) string {
switch {
case strings.HasPrefix(dist, "Arch"):
return "logos/arch.txt"
case strings.HasPrefix(dist, "NixOS"):
return "logos/nixos.txt"
case strings.HasPrefix(dist, "Debian"):
return "logos/debian.txt"
default:
return "logos/linux.txt"
}
}
func printAsciiLogo(path string) string {
content, err := os.ReadFile(path)
if err != nil {
return fmt.Sprintf("Error: Could not read ASCII file at %s\n", path)
}
return string(content)
}
func renderLogoChafa(path string, width uint) []string {
cols := width / 8
if cols == 0 {
cols = 1
}
sizeArg := fmt.Sprintf("--size=%dx%d", cols, cols/2)
cmd := exec.Command("chafa", path, "--format", "symbols", sizeArg, "--symbols=half")
out, err := cmd.Output()
if err != nil {
return []string{fmt.Sprintf("Chafa error: %v", err)}
}
return splitLines(string(out))
}
func normalizeLogoPosition(pos string) string {
value := strings.ToLower(strings.TrimSpace(pos))
if value == "left" || value == "right" {
return value
}
return "right"
}
func splitLines(content string) []string {
normalized := strings.ReplaceAll(content, "\r\n", "\n")
normalized = strings.TrimRight(normalized, "\n")
if normalized == "" {
return []string{}
}
return strings.Split(normalized, "\n")
}
func maxLineLen(lines []string) int {
maxLen := 0
for _, line := range lines {
lineLen := visibleWidth(line)
if lineLen > maxLen {
maxLen = lineLen
}
}
return maxLen
}
func visibleWidth(line string) int {
stripped := ansiEscapeRe.ReplaceAllString(line, "")
return utf8.RuneCountInString(stripped)
}
func padRightVisible(line string, targetWidth int) string {
pad := targetWidth - visibleWidth(line)
if pad <= 0 {
return line
}
return line + strings.Repeat(" ", pad)
}
func renderSideBySide(statsLines []string, logoLines []string, position string) {
const gap = " "
statsWidth := maxLineLen(statsLines)
logoWidth := maxLineLen(logoLines)
lineCount := len(statsLines)
if len(logoLines) > lineCount {
lineCount = len(logoLines)
}
for i := 0; i < lineCount; i++ {
stats := ""
logo := ""
if i < len(statsLines) {
stats = statsLines[i]
}
if i < len(logoLines) {
logo = logoLines[i]
}
if position == "left" {
fmt.Printf("%s%s%s\n", padRightVisible(logo, logoWidth), gap, stats)
continue
}
fmt.Printf("%s%s%s\n", padRightVisible(stats, statsWidth), gap, logo)
}
}
func getAbsLogoPath(relativePath string) string {
if _, err := os.Stat(relativePath); err == nil {
return relativePath
}
exePath, err := os.Executable()
if err == nil {
exeDir := filepath.Dir(exePath)
nixPath := filepath.Join(exeDir, "..", "share", "gogofetch", relativePath)
if _, err := os.Stat(nixPath); err == nil {
return nixPath
}
}
systemPath := filepath.Join("/usr/share/gogofetch", relativePath)
if _, err := os.Stat(systemPath); err == nil {
return systemPath
}
return relativePath
}
func getShell() string {
out, err := exec.Command("ps", "-p", strconv.Itoa(os.Getppid()), "-o", "comm=").Output()
if err != nil {
return path.Base(os.Getenv("SHELL"))
}
return strings.TrimSpace(string(out))
}
func getTerminal() string {
return os.Getenv("TERM")
}
func getDE() string {
return os.Getenv("XDG_CURRENT_DESKTOP")
}
func getUptime() string {
data, err := os.ReadFile("/proc/uptime")
if err != nil {
return "0"
}
uptimeStr := strings.Fields(string(data))[0]
seconds, err := strconv.ParseFloat(uptimeStr, 64)
if err != nil {
_, err := fmt.Fprintf(os.Stderr, "Error: %v\n", err)
if err != nil {
return "error printing uptime"
}
return ""
}
d := time.Duration(seconds) * time.Second
formatted := d.Round(time.Second).String()
spaced := strings.NewReplacer("h", "h ", "m", "m ", "s", "s ").Replace(formatted)
return spaced
}
func main() {
conf, _ := initConfig()
if len(conf.Layout) == 0 {
conf.Layout = []string{"host","dist", "cpu", "ram", "shell", "uptime"}
fmt.Println("Warning: No layout specified in config, using default")
}
dist := getDist()
var defaultLogo string
if conf.CustomLogo != "" {
defaultLogo = conf.CustomLogo
} else if conf.Ascii {
defaultLogo = getAscii(dist)
} else {
defaultLogo = getLogo(dist)
}
width := flag.Int("w", 190, "Logo width in lines")
logoPath := flag.String("l", getAbsLogoPath(defaultLogo), "Path to the logo")
logoPos := flag.String("p", normalizeLogoPosition(conf.LogoPosition), "Logo position: left or right")
flag.Parse()
position := normalizeLogoPosition(*logoPos)
type Info struct {
Label string
Value string
}
var stats []Info
for _, item := range conf.Layout {
switch strings.ToLower(item) {
case "host":
stats = append(stats, Info{"host", getHostname()})
case "dist":
stats = append(stats, Info{"OS", dist})
case "cpu":
stats = append(stats, Info{"cpu", getCpu()})
case "kernel", "krnl":
stats = append(stats, Info{"krnl", getKernel()})
case "ram":
stats = append(stats, Info{"ram", getRam()})
case "gpu":
stats = append(stats, Info{"gpu", getGpu()})
case "de/wm", "de", "wm":
stats = append(stats, Info{"de/wm", getDE()})
case "pkgs":
stats = append(stats, Info{"pkgs", getPkgs()})
case "shell":
stats = append(stats, Info{"shell", getShell()})
case "terminal", "term":
stats = append(stats, Info{"term", getTerminal()})
case "uptime":
stats = append(stats, Info{"uptime", getUptime()})
}
}
var statsLines []string
for _, info := range stats {
statsLines = append(statsLines, fmt.Sprintf("-> %s%s%s: %s", Blue, info.Label, Reset, info.Value))
}
var logoLines []string
if _, err := os.Stat(*logoPath); err == nil {
if conf.Ascii {
logoLines = splitLines(printAsciiLogo(*logoPath))
} else {
logoLines = renderLogoChafa(*logoPath, uint(*width))
}
} else {
logoLines = []string{fmt.Sprintf("Logo not found at: %s", *logoPath)}
}
renderSideBySide(statsLines, logoLines, position)
}