diff --git a/cmd/confirmation.go b/cmd/confirmation.go index 346b2f6d..abe378cb 100644 --- a/cmd/confirmation.go +++ b/cmd/confirmation.go @@ -67,7 +67,7 @@ var confirmationShowCmd = &cobra.Command{ } func confirmerShow(c *protobuf.Confirmer, for_ string) { - empty := false + empty := true if c.Confirmed != "" { empty = false fmt.Printf("Confirmation submitted for %s: %s\n", for_, c.Confirmed) diff --git a/cmd/desktop.go b/cmd/desktop.go index 3dde565c..c6993d62 100644 --- a/cmd/desktop.go +++ b/cmd/desktop.go @@ -2,20 +2,26 @@ package cmd import ( "fmt" - "log" "time" + "fyne.io/systray" + "github.com/dustin/go-humanize" "github.com/gen2brain/beeep" + "github.com/nlewo/comin/cmd/icons" "github.com/nlewo/comin/internal/builder" "github.com/nlewo/comin/internal/client" - "github.com/nlewo/comin/pkg/protobuf" + "github.com/nlewo/comin/internal/manager" "github.com/nlewo/comin/internal/store" + "github.com/nlewo/comin/pkg/protobuf" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) var title string +const buildTitle = "build" +const deployTitle = "deploy" + var desktopCmd = &cobra.Command{ Use: "desktop", Short: "Send desktop notifications ", @@ -42,8 +48,14 @@ var desktopCmd = &cobra.Command{ if err != nil { logrus.Fatal(err) } - err = client.Events(handler) - log.Fatalf("failed to consume to the event stream: %s", err) + tray, _ := (cmd.Flags().GetBool("tray")) + if tray { + logrus.Println("Loading tray.") + systray.Run(func() { onTrayReady(client) }, onTrayExit) + } else { + err = client.Events(desktopEventHandler) + logrus.Fatalf("failed to consume to the event stream: %s", err) + } } }, } @@ -55,33 +67,35 @@ func scenario() { SelectedBranchName: "main", } e := protobuf.Event{Type: &protobuf.Event_BuildStartedType{BuildStartedType: &protobuf.Event_BuildStarted{Generation: &g}}} - handler(&e) // nolint: errcheck + desktopEventHandler(&e) // nolint: errcheck time.Sleep(time.Second) d := protobuf.Deployment{ Status: store.StatusToString(store.Init), } e = protobuf.Event{Type: &protobuf.Event_DeploymentStartedType{DeploymentStartedType: &protobuf.Event_DeploymentStarted{Deployment: &d}}} - handler(&e) // nolint: errcheck + desktopEventHandler(&e) // nolint: errcheck time.Sleep(time.Second) d = protobuf.Deployment{ Status: store.StatusToString(store.Done), } e = protobuf.Event{Type: &protobuf.Event_DeploymentFinishedType{DeploymentFinishedType: &protobuf.Event_DeploymentFinished{Deployment: &d}}} - handler(&e) // nolint: errcheck + desktopEventHandler(&e) // nolint: errcheck time.Sleep(time.Second) e = protobuf.Event{Type: &protobuf.Event_RebootRequired_{RebootRequired: &protobuf.Event_RebootRequired{Deployment: &d}}} - handler(&e) // nolint: errcheck + desktopEventHandler(&e) // nolint: errcheck } -func handler(event *protobuf.Event) error { +func generateEventMessage(event *protobuf.Event) string { var message string logrus.Debugf("received event: %s", event) switch v := event.Type.(type) { default: logrus.Errorf("unexpected type %T", v) + case *protobuf.Event_Fetched_: + case *protobuf.Event_ManagerState_: case *protobuf.Event_Suspend_: message = "The agent is suspended." case *protobuf.Event_Resume_: @@ -124,19 +138,193 @@ func handler(event *protobuf.Event) error { } case *protobuf.Event_RebootRequired_: message = "The machine needs to be rebooted to take the deployment into account." + case *protobuf.Event_ConfirmationSubmittedType: + uuid := event.Type.(*protobuf.Event_ConfirmationSubmittedType).ConfirmationSubmittedType.GetUuid() + message = fmt.Sprintf("A confirmation request was submitted. %s", uuid) } + return message +} + +func desktopEventHandler(event *protobuf.Event) error { + message := generateEventMessage(event) + displayNotification(message) + return nil +} + +func displayNotification(message string) { if message != "" { + logrus.Println(fmt.Sprintf("Displaying notification: %s", message)) err := beeep.Notify(title, message, []byte{}) if err != nil { logrus.Errorf("failed to send the notification: %s", err) } } - return nil +} + +func onTrayReady(client client.Client) { + systray.SetIcon(icons.GetIcon(client)) + systray.SetTitle("comin") + systray.SetTooltip("comin") + go refreshAvailableConfirmationsLoop(client) + go client.Events(trayEventHandler(client)) // nolint: errcheck +} + +func onTrayExit() {} + +func trayEventHandler(client client.Client) func(*protobuf.Event) error { + return func(event *protobuf.Event) error { + // Reuse the notification handler + go desktopEventHandler(event) // nolint: errcheck + // Watch the events for specifically the confirmation events so we can update the tray icon. + switch event.Type.(type) { + case *protobuf.Event_ConfirmationSubmittedType: + systray.SetIcon(icons.GetIcon(client)) + case *protobuf.Event_ConfirmationConfirmedType: + systray.SetIcon(icons.GetIcon(client)) + case *protobuf.Event_ConfirmationCancelledType: + systray.SetIcon(icons.GetIcon(client)) + } + return nil + } +} + +func refreshAvailableConfirmationsLoop(client client.Client) { + buildItem, deployItem := initializeTrayMenu(client) + // Reload statuses when the tray is opened. + for range systray.TrayOpenedCh { + status, err := client.GetManagerState() + if err != nil { + logrus.Errorln("Failed to retrieve manager state.") + } + logrus.Debugln(fmt.Sprintf("status is %s", status)) + if status == nil { + updatePendingItem(nil, buildTitle, buildItem) + updatePendingItem(nil, deployTitle, deployItem) + } else { + updatePendingItem(status.BuildConfirmer, buildTitle, buildItem) + updatePendingItem(status.DeployConfirmer, deployTitle, deployItem) + } + // Also refresh the icon in case it's desynced for any reason. + systray.SetIcon(icons.GetIcon(client)) + } +} + +func generateTrayMessage(c *protobuf.Confirmer, for_ string) (string, bool) { + if c.Confirmed != "" { + return fmt.Sprintf("%s: Confirmed: %s.", for_, c.Confirmed), false + } else if c.Submitted != "" { + base := fmt.Sprintf("%s: Confirmation needed: %s.", for_, c.Submitted) + if c.Mode == int64(manager.Auto) { + return fmt.Sprintf("%s\nAuto confirmation in %s\n", + base, + humanize.Time(c.AutoconfirmStartedAt.AsTime().Add(time.Duration(c.AutoconfirmDuration*int64(time.Second))))), + true + } else { + return base, true + } + } else { + return fmt.Sprintf("%s: No confirmations needed.", for_), false + } +} + +// Initialize the items in the tray menu. +func initializeTrayMenu(client client.Client) (*systray.MenuItem, *systray.MenuItem) { + // Header + addMenuItem("comin", "", func() {}).Disable() + systray.AddSeparator() + + // Status items + var buildItem *systray.MenuItem + var deployItem *systray.MenuItem + status, err := client.GetManagerState() + if err != nil { + logrus.Errorln("Failed to retrieve manager state.") + } + var buildConfirmer *protobuf.Confirmer + var deployConfirmer *protobuf.Confirmer + // If the status can't be loaded, we still want to create the tray items. + if status != nil { + buildConfirmer = status.BuildConfirmer + deployConfirmer = status.DeployConfirmer + } + buildItem = makePendingItem(buildConfirmer, buildTitle, func() { + status, err := client.GetManagerState() + if err != nil { + displayNotification("Failed to confirm build.") + logrus.Errorln("Failed to retrieve manager state.") + } else { + if status.BuildConfirmer.Submitted != "" { + client.Confirm(status.BuildConfirmer.Submitted, buildTitle) // nolint: errcheck + } + } + }) + deployItem = makePendingItem(deployConfirmer, deployTitle, func() { + status, err := client.GetManagerState() + if err != nil { + displayNotification("Failed to confirm deployment.") + logrus.Errorln("Failed to retrieve manager state.") + } else { + if status.DeployConfirmer.Submitted != "" { + client.Confirm(status.DeployConfirmer.Submitted, deployTitle) // nolint: errcheck + } + } + }) + // Quit item + systray.AddSeparator() + addMenuItem("Quit", "Quit the tray application", func() { + logrus.Println("Quit was pressed. Closing.") + systray.Quit() + }) + return buildItem, deployItem +} + +func updatePendingItem(c *protobuf.Confirmer, for_ string, item *systray.MenuItem) { + if c == nil { + item.SetTitle(fmt.Sprintf("%s: Failed to connect to comin service.", for_)) + item.Disable() + return + } + title, shouldEnable := generateTrayMessage(c, for_) + item.SetTitle(title) + if shouldEnable { + item.Enable() + } else { + item.Disable() + } +} + +func makePendingItem(c *protobuf.Confirmer, for_ string, callback func()) *systray.MenuItem { + if c == nil { + item := addMenuItem(fmt.Sprintf("%s: Failed to connect to comin service.", for_), "", callback) + item.Disable() + return item + } + title, shouldEnable := generateTrayMessage(c, for_) + item := addMenuItem(title, "", callback) + if shouldEnable { + item.Enable() + } else { + item.Disable() + } + return item +} + +// Helper function to create a tray item with a callback. +// The goroutine is never stopped, so only use for items that will not be removed from the tray. +func addMenuItem(title string, tooltip string, callback func()) *systray.MenuItem { + item := systray.AddMenuItem(title, tooltip) + go func() { + for range item.ClickedCh { + callback() + } + }() + return item } func init() { desktopCmd.Flags().StringVarP(&title, "title", "", "comin", "the notification title") desktopCmd.Flags().StringP("unix-socket-path", "", "/var/lib/comin/grpc.sock", "the GRPC Unix socket path") desktopCmd.Flags().BoolP("test", "", false, "do not get events from the agent but from predefined scenari") + desktopCmd.Flags().BoolP("tray", "", false, "Show in the system tray") rootCmd.AddCommand(desktopCmd) } diff --git a/cmd/icons/bare.png b/cmd/icons/bare.png new file mode 100644 index 00000000..fbb1316d Binary files /dev/null and b/cmd/icons/bare.png differ diff --git a/cmd/icons/icons.go b/cmd/icons/icons.go new file mode 100644 index 00000000..0d459fdc --- /dev/null +++ b/cmd/icons/icons.go @@ -0,0 +1,29 @@ +package icons + +import ( + _ "embed" + + "github.com/nlewo/comin/internal/client" + "github.com/sirupsen/logrus" +) + +type Icon []byte + +//go:embed bare.png +var Bare Icon + +//go:embed notify.png +var Notify Icon + +func GetIcon(client client.Client) Icon { + status, err := client.GetManagerState() + if err != nil { + logrus.Errorln("Failed to retrieve manager state.") + return Notify + } + + if status.BuildConfirmer.Submitted != "" || status.DeployConfirmer.Submitted != "" { + return Notify + } + return Bare +} diff --git a/cmd/icons/notify.png b/cmd/icons/notify.png new file mode 100644 index 00000000..7226d5bb Binary files /dev/null and b/cmd/icons/notify.png differ diff --git a/docs/generated-module-options.md b/docs/generated-module-options.md index ae2bf298..eacb9454 100644 --- a/docs/generated-module-options.md +++ b/docs/generated-module-options.md @@ -252,6 +252,35 @@ string +## services\.comin\.desktop\.tray + + + +Whether to enable Whether to run the tray with the desktop service\. This tray item allows confirming deployments and builds… + + + +*Type:* +boolean + + + +*Default:* + +```nix +false +``` + + + +*Example:* + +```nix +true +``` + + + ## services\.comin\.exporter diff --git a/go.mod b/go.mod index a405b6c5..df080aa3 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( require ( dario.cat/mergo v1.0.0 // indirect + fyne.io/systray v1.12.0 // indirect git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect diff --git a/go.sum b/go.sum index 68b5548e..0c4b30c5 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= +fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= diff --git a/internal/manager/confirmer.go b/internal/manager/confirmer.go index fb0ad628..cb981885 100644 --- a/internal/manager/confirmer.go +++ b/internal/manager/confirmer.go @@ -117,6 +117,8 @@ func (c *Confirmer) start() { switch Mode(c.state.Mode) { case Manual: logrus.Infof("confirmer: generation %s has been submitted", command.uuid) + e := &protobuf.Event_ConfirmationSubmitted{Uuid: command.uuid} + c.broker.Publish(&protobuf.Event{Type: &protobuf.Event_ConfirmationSubmittedType{ConfirmationSubmittedType: e}}) case Without: logrus.Infof("confirmer: generation %s has been submitted and confirmed", command.uuid) c.state.Confirmed = command.uuid @@ -129,6 +131,8 @@ func (c *Confirmer) start() { timer = c.timer.C c.state.AutoconfirmStarted = wrapperspb.Bool(true) c.state.AutoconfirmStartedAt = timestamppb.New(time.Now().UTC()) + e := &protobuf.Event_ConfirmationSubmitted{Uuid: command.uuid} + c.broker.Publish(&protobuf.Event{Type: &protobuf.Event_ConfirmationSubmittedType{ConfirmationSubmittedType: e}}) } case "confirm": logrus.Infof("confirmer: generation %s has been confirmed", command.uuid) diff --git a/nix/module-options.nix b/nix/module-options.nix index c41b4fea..2690bbe3 100644 --- a/nix/module-options.nix +++ b/nix/module-options.nix @@ -347,6 +347,7 @@ in default = "comin"; description = "The notification title."; }; + tray = mkEnableOption "Whether to run the tray with the desktop service. This tray item allows confirming deployments and builds."; }; retention = mkOption { description = "The deployments and profiles retention policyes."; diff --git a/nix/module.nix b/nix/module.nix index 95c97617..f93d8f8b 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -43,7 +43,9 @@ in wantedBy = [ "graphical-session.target" ]; path = [ pkgs.libnotify ]; serviceConfig = { - ExecStart = ''${lib.getExe package} desktop --title "${cfg.services.comin.desktop.title}"''; + ExecStart = ''${lib.getExe package} desktop --title "${cfg.services.comin.desktop.title}"${ + if cfg.services.comin.desktop.tray then " --tray" else "" + }''; }; }; diff --git a/nix/package.nix b/nix/package.nix index 853f5d86..d71acca7 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -39,7 +39,7 @@ buildGoModule rec { ../main.go ]; }; - vendorHash = "sha256-M+0YUoMRnObCSUqnygPNiv1sKl3YB9Cb4nzK39zWwBg="; + vendorHash = "sha256-8dOmQ2tqUgiPH0+44rtu/nZgpP/O8dn3ULl/VB5dYw4="; ldflags = [ "-X github.com/nlewo/comin/cmd.version=${version}" ];