package main import ( "bytes" "encoding/base64" "encoding/json" "encoding/xml" "fmt" "log" "net/http" "os" "os/exec" "path" "sort" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/data/binding" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" ) var commitCache = make(map[string]commit) type commit struct { hash string date time.Time message string } type xinStatus struct { tabs *container.AppTabs cards []fyne.CanvasObject boundStrings []binding.ExternalString boundBools []binding.ExternalBool log *widget.TextGrid repoCommit commit config Config upgradeProgress *widget.ProgressBar hasReboot bool } type Status struct { card *widget.Card commit commit client *ssh.Client clientEstablished bool upToDate bool ConfigurationRevision string `json:"configurationRevision"` NeedsRestart bool `json:"needs_restart"` NixosVersion string `json:"nixosVersion"` NixpkgsRevision string `json:"nixpkgsRevision"` SystemDiff string `json:"system_diff"` Host string `json:"host"` Port int32 `json:"port"` } type Config struct { Statuses []*Status `json:"statuses"` Repo string `json:"repo"` PrivKeyPath string `json:"priv_key_path"` FlakeRSS string `json:"flake_rss"` } func (c *commit) getInfo(repo string) error { msgCmd := exec.Command("git", "log", "--format=%B", "-n", "1", c.hash) msgCmd.Dir = repo msg, err := msgCmd.Output() if err != nil { return err } c.message = trim(msg) dateCmd := exec.Command("git", "log", "--format=%ci", c.hash) dateCmd.Dir = repo d, err := dateCmd.Output() if err != nil { return err } dateStr := trim(d) date, err := time.Parse("2006-01-02 15:04:05 -0700", dateStr) if err != nil { return err } c.date = date return nil } func NewCommit(c string) *commit { return &commit{ hash: c, } } func trim(b []byte) string { head := bytes.Split(b, []byte("\n")) return string(head[0]) } func (x *xinStatus) aliveCount() float64 { alive := 0 x.hasReboot = false for _, s := range x.config.Statuses { if s.clientEstablished { alive = alive + 1 if s.NeedsRestart { x.hasReboot = true } } } return float64(alive) } func (x *xinStatus) uptodateCount() float64 { utd := 0 for _, s := range x.config.Statuses { if s.upToDate { utd = utd + 1 } } return float64(utd) } func (x *xinStatus) getCommit(c string) (*commit, error) { commit := &commit{ hash: c, } if c == "DIRTY" { return commit, nil } if commit, ok := commitCache[c]; ok { return &commit, nil } else { commit := NewCommit(c) err := commit.getInfo(x.config.Repo) if err != nil { return nil, err } commitCache[c] = *commit } return commit, nil } func (x *xinStatus) updateRepoInfo() error { switch { case (x.config.Repo != "" && x.config.FlakeRSS == ""): revCmd := exec.Command("git", "rev-parse", "HEAD") revCmd.Dir = x.config.Repo currentRev, err := revCmd.Output() if err != nil { return err } commit, err := x.getCommit(trim(currentRev)) if err != nil { return err } x.repoCommit = *commit default: resp := &Feed{} res, err := http.Get(x.config.FlakeRSS) if err != nil { return err } defer res.Body.Close() if err = xml.NewDecoder(res.Body).Decode(&resp); err != nil { return err } cmit, err := resp.LatestHash() if err != nil { return err } x.repoCommit = *cmit } return nil } func (x *xinStatus) updateHostInfo() error { khFile := path.Clean(path.Join(os.Getenv("HOME"), ".ssh/known_hosts")) hostKeyCB, err := knownhosts.New(khFile) if err != nil { return fmt.Errorf("can't parse %q: %q", khFile, err) } key, err := os.ReadFile(x.config.PrivKeyPath) if err != nil { return fmt.Errorf("can't load key %q: %q", x.config.PrivKeyPath, err) } signer, err := ssh.ParsePrivateKey(key) if err != nil { return fmt.Errorf("can't parse key: %q", err) } sshConf := &ssh.ClientConfig{ User: "root", HostKeyAlgorithms: []string{"ssh-ed25519"}, Auth: []ssh.AuthMethod{ ssh.PublicKeys(signer), }, Timeout: 2 * time.Second, HostKeyCallback: hostKeyCB, } upToDateCount := len(x.config.Statuses) for _, s := range x.config.Statuses { var err error ds := fmt.Sprintf("%s:%d", s.Host, s.Port) if !s.clientEstablished { s.client, err = ssh.Dial("tcp", ds, sshConf) if err != nil { s.card.Subtitle = "can't connect" upToDateCount = upToDateCount - 1 x.Log(fmt.Sprintf("can't Dial host %q (%q): %q", s.Host, ds, err)) s.card.Refresh() continue } s.clientEstablished = true } session, err := s.client.NewSession() if err != nil { x.Log(fmt.Sprintf("can't create session: %q", err)) upToDateCount = upToDateCount - 1 s.clientEstablished = false continue } defer session.Close() output, err := session.Output("xin-status") if err != nil { x.Log(fmt.Sprintf("can't run command: %q", err)) upToDateCount = upToDateCount - 1 continue } err = json.Unmarshal(output, s) if err != nil { x.Log(err.Error()) upToDateCount = upToDateCount - 1 continue } if s.ConfigurationRevision != x.repoCommit.hash { s.card.Subtitle = fmt.Sprintf("%.8s", s.ConfigurationRevision) upToDateCount = upToDateCount - 1 } else { s.card.Subtitle = "" } s.card.Refresh() commit, err := x.getCommit(s.ConfigurationRevision) if err != nil { x.Log(err.Error()) continue } s.commit = *commit s.upToDate = false if s.commit.hash == x.repoCommit.hash { s.upToDate = true } } x.upgradeProgress.SetValue(float64(upToDateCount)) return nil } func (x *xinStatus) Log(s string) { log.Println(s) /* text := x.log.Text() now := time.Now() log.Println(s) x.log.SetText(strings.Join([]string{ fmt.Sprintf("%s: %s", now.Format(time.RFC822), s), text, }, "\n")) */ } func (c *Config) Load(file string) error { data, err := os.ReadFile(file) if err != nil { return err } return json.Unmarshal(data, &c) } func (s *Status) ToTable() *widget.Table { t := widget.NewTable( // Length func() (int, int) { return 5, 2 }, // CreateCell func() fyne.CanvasObject { //ct := container.NewScroll(container.NewMax(widget.NewLabel(""))) ct := container.NewStack(container.NewVScroll(widget.NewLabel(""))) //ct := container.NewMax(widget.NewLabel("")) return ct }, // UpdateCell func(i widget.TableCellID, o fyne.CanvasObject) { ctnr := o.(*fyne.Container) content := ctnr.Objects[0].(*container.Scroll).Content.(*widget.Label) if i.Col == 0 { switch i.Row { case 0: content.SetText("NixOS Version") case 1: content.SetText("NixPkgs Revision") case 2: content.SetText("Configuration Revision") case 3: content.SetText("Restart?") case 4: content.SetText("System Diff") } } if i.Col == 1 { switch i.Row { case 0: content.SetText(s.NixosVersion) case 1: content.SetText(s.NixpkgsRevision) case 2: content.SetText(s.ConfigurationRevision) case 3: str := "No" if s.NeedsRestart { str = "Yes" } content.SetText(str) case 4: text, err := base64.StdEncoding.DecodeString(s.SystemDiff) if err != nil { fmt.Println("decode error:", err) return } content.SetText(string(text)) } } }, // OnSelected // func (i widget.TableCellID) {} // OnUnselected // func (i widget.TableCellID) {} ) t.Refresh() t.SetColumnWidth(0, 300.0) t.SetColumnWidth(1, 600.0) t.SetRowHeight(4, 600.0) return t } func buildCards(stat *xinStatus) fyne.CanvasObject { var cards []fyne.CanvasObject sort.Slice(stat.config.Statuses, func(i, j int) bool { return stat.config.Statuses[i].Host < stat.config.Statuses[j].Host }) for _, s := range stat.config.Statuses { commitBStr := binding.BindString(&s.commit.message) bsl := widget.NewLabelWithData(commitBStr) verBStr := binding.BindString(&s.NixosVersion) bvl := widget.NewLabelWithData(verBStr) restartBBool := binding.BindBool(&s.NeedsRestart) bbl := widget.NewCheckWithData("Reboot", restartBBool) bbl.Disable() stat.boundStrings = append(stat.boundStrings, commitBStr) stat.boundStrings = append(stat.boundStrings, verBStr) stat.boundBools = append(stat.boundBools, restartBBool) card := widget.NewCard(s.Host, "", container.NewVBox( container.NewHBox(bvl), container.NewHBox(bbl), container.NewHBox(bsl), ), ) s.card = card cards = append(cards, card) stat.cards = append(stat.cards, card) } stat.upgradeProgress = widget.NewProgressBar() stat.upgradeProgress.Min = 0 stat.upgradeProgress.Max = stat.aliveCount() stat.upgradeProgress.TextFormatter = func() string { return fmt.Sprintf("%.0f of %.0f hosts up-to-date", stat.upgradeProgress.Value, stat.upgradeProgress.Max) } bsCommitMsg := binding.BindString(&stat.repoCommit.message) bsCommitHash := binding.BindString(&stat.repoCommit.hash) stat.boundStrings = append(stat.boundStrings, bsCommitMsg) stat.boundStrings = append(stat.boundStrings, bsCommitHash) statusCard := widget.NewCard("Xin Status", "", container.NewVBox( widget.NewLabelWithData(bsCommitMsg), stat.upgradeProgress, )) stat.cards = append(cards, statusCard) return container.NewVBox( statusCard, container.NewGridWithColumns(3, cards...), ) } func main() { status := &xinStatus{} dataPath := path.Clean(path.Join(os.Getenv("HOME"), ".xin.json")) err := status.config.Load(dataPath) if err != nil { log.Fatal(err) } a := app.New() a.Settings().SetTheme(&xinTheme{}) w := a.NewWindow("xintray") ctrlQ := &desktop.CustomShortcut{KeyName: fyne.KeyQ, Modifier: fyne.KeyModifierControl} ctrlW := &desktop.CustomShortcut{KeyName: fyne.KeyW, Modifier: fyne.KeyModifierControl} w.Canvas().AddShortcut(ctrlQ, func(shortcut fyne.Shortcut) { a.Quit() }) w.Canvas().AddShortcut(ctrlW, func(shortcut fyne.Shortcut) { w.Hide() }) tabs := container.NewAppTabs( container.NewTabItemWithIcon("Status", theme.ComputerIcon(), buildCards(status)), ) status.tabs = tabs status.log = widget.NewTextGrid() err = status.updateRepoInfo() if err != nil { status.log.SetText(err.Error()) } go func() { for { err = status.updateRepoInfo() if err != nil { status.log.SetText(err.Error()) } err = status.updateHostInfo() if err != nil { status.log.SetText(err.Error()) } for _, s := range status.boundStrings { s.Reload() } for _, s := range status.boundBools { s.Reload() } time.Sleep(3 * time.Second) status.upgradeProgress.Max = status.aliveCount() } }() for _, s := range status.config.Statuses { tabs.Append(container.NewTabItem(s.Host, s.ToTable())) } tabs.SetTabLocation(container.TabLocationLeading) iconImg := buildImage(status) a.SetIcon(iconImg) if desk, ok := a.(desktop.App); ok { iconImg := buildImage(status) m := fyne.NewMenu("xintray", fyne.NewMenuItem("Show", func() { w.Show() })) desk.SetSystemTrayMenu(m) desk.SetSystemTrayIcon(iconImg) a.SetIcon(iconImg) go func() { for { img := buildImage(status) desk.SetSystemTrayIcon(img) a.SetIcon(img) time.Sleep(3 * time.Second) } }() } status.log.SetText("starting...") w.SetContent(container.NewAppTabs( container.NewTabItem("Hosts", tabs), container.NewTabItem("Config", container.NewStack(widget.NewCard("Config", "", nil))), container.NewTabItem("Logs", container.NewStack(status.log)), )) w.SetCloseIntercept(func() { w.Hide() }) w.ShowAndRun() }