commit f1103e1978c549d0940dff511d06d8c51348590d Author: Stanislav N. aka pztrn Date: Fri Nov 3 19:54:04 2023 +0500 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..706fd07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +.vscode diff --git a/README.md b/README.md new file mode 100644 index 0000000..7343f19 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Evolved + +Standalone application for updating Evolution PIM badge with number of unread emails. + +It has no GUI and the only purpose of it - to run as user daemon, check for Evolution files changes, get unread messages count from it's database and send dbus event with new count, so you'll get exact number of unread messages in all your mailboxes on task bar. + +## Dependencies + +Golang 1.21+ and C compiler present in system and found in `PATH`, as Evolution database is in sqlite3. + +## Installation + +Just: + +```text +go install go.dev.pztrn.name/evolved@latest +``` + +## Using + +### Via systemd + +Put this in `~/.config/systemd/user/evolved.service`: + +```ini +[Unit] +Description=Evolution badge count. + +[Service] +Type=simple +ExecStart=/path/to/evolved + +[Install] +WantedBy=default.target +``` +Replace `/path/to/evolved` for real path to binary. + +After that do: + +```text +systemctl --user daemon-reload +systemctl --user enable evolved.service +systemctl --user start evolved.service +``` + +### Via i3/sway/etc. configuration file for autostart + +For i3 and sway there is `exec` thing that launches commands on startup. Feel free to use this. + +Look for equivalent thing for other WMs. + +## Caveat about desktop file name + +Evolved using `org.gnome.Evolution.desktop` as launcher file name by default. Your distribution might name it differently, check `/usr/share/applications` or `~/.local/share/applications` for actual file name and pass `-desktop-file` parameter to Evolved binary. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d4436f1 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module go.dev.pztrn.name/evolved + +go 1.21.0 + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/jmoiron/sqlx v1.3.5 // indirect + github.com/mattn/go-sqlite3 v1.14.17 // indirect + golang.org/x/sys v0.13.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..013aafc --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/main.go b/main.go new file mode 100644 index 0000000..36085d3 --- /dev/null +++ b/main.go @@ -0,0 +1,207 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "os/signal" + "os/user" + "path" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/godbus/dbus/v5" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +var ( + cliFlagIsDebug = flag.Bool("debug", false, "Enable debug output.") + cliFlagDesktopFileName = flag.String("desktop-file", "org.gnome.Evolution.desktop", "Desktop file name from /usr/share/applications or ~/.local/share/applications which is used for launching Evolution.") + databasesPaths = make([]string, 0) + + dbusSession *dbus.Conn +) + +func main() { + // CTRL+C handler. + signalHandler := make(chan os.Signal, 1) + shutdownDone := make(chan bool, 1) + + signal.Notify(signalHandler, os.Interrupt, syscall.SIGTERM) + + slog.Info("Starting Evolved...") + + flag.Parse() + if *cliFlagIsDebug { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))) + + slog.Debug("Debug output enabled!") + } + + dbusConn, err := dbus.ConnectSessionBus() + if err != nil { + slog.Error("Failed to connect to dbus session bus!", "error", err) + + os.Exit(3) + } + + dbusSession = dbusConn + + watcher, err := fsnotify.NewWatcher() + if err != nil { + slog.Error("Failed to start filesystem changes watcher!", "error", err) + os.Exit(1) + } + + if err := getEvolutionMailDatabasesPaths(); err != nil { + slog.Error("Failed to get Evolution mail databases paths!", "error", err) + os.Exit(2) + } + + for _, databasePath := range databasesPaths { + slog.Info("Starting listening for filesystem changes in Evolution directory...", "path", databasePath) + + _ = watcher.Add(databasePath) + } + + go watchFSNotifications(watcher) + + unreadCount, err := getEvolutionUnreadMailsCount() + if err != nil { + slog.Error("Failed to get unread counts!", "error", err) + + os.Exit(4) + } + + emitDBusSignal(unreadCount) + + slog.Info("Evolved started.") + + go func() { + <-signalHandler + + slog.Info("Shutting down Evolved...") + + if err := watcher.Close(); err != nil { + slog.Error("Failed to stop filesystem watcher!", "error", err) + } + + emitDBusSignal(0) + + shutdownDone <- true + }() + + <-shutdownDone + + slog.Info("Evolved stopped.") + + os.Exit(0) +} + +func emitDBusSignal(unreadCount uint) { + params := make(map[string]dbus.Variant) + params["count"] = dbus.MakeVariant(unreadCount) + params["count-visible"] = dbus.MakeVariant(unreadCount > 0) + + if err := dbusSession.Emit( + "/", + "com.canonical.Unity.LauncherEntry.Update", + "application://"+*cliFlagDesktopFileName, + params, + ); err != nil { + slog.Error("Failed to emit badge data via dbus!", "error", err) + } +} + +func getEvolutionMailDatabasesPaths() error { + userData, err := user.Current() + if err != nil { + return fmt.Errorf("getEvolutionMailDatabasesPaths: get current user: %w", err) + } + + if err := filepath.Walk(path.Join(userData.HomeDir, ".cache", "evolution", "mail"), func(path string, info os.FileInfo, err error) error { + if err == nil && strings.HasSuffix(info.Name(), "folders.db") { + databasesPaths = append(databasesPaths, path) + } + + return nil + }); err != nil { + return fmt.Errorf("getEvolutionMailDatabasesPaths: get databases paths: %w", err) + } + + return nil +} + +func getEvolutionUnreadMailsCount() (uint, error) { + unreadCount := uint(0) + + ctx, _ := context.WithTimeout(context.Background(), time.Second*5) + + for _, dbFile := range databasesPaths { + db, err := sqlx.Connect("sqlite3", dbFile) + if err != nil { + slog.Error("Failed to open Evolution database file!", "file", dbFile, "error", err) + + continue + } + + counts := make([]uint, 0) + + if err := db.SelectContext(ctx, &counts, "SELECT unread_count FROM folders"); err != nil { + slog.Error("Failed to get unread counts!", "file", dbFile, "error", err) + + continue + } + + for _, count := range counts { + unreadCount += count + } + + if err := db.Close(); err != nil { + slog.Error("Failed to close database file for reading! Expect unexpected!", "file", dbFile, "error", err) + + continue + } + } + + return unreadCount, nil +} + +func watchFSNotifications(watcher *fsnotify.Watcher) { + slog.Debug("Starting filesystem watcher goroutine...") + + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + + slog.Debug("Got filesystem event", "event", event.Name, "op", event.Op.String()) + + unreadCount, err := getEvolutionUnreadMailsCount() + if err != nil { + slog.Error("Failed to get unread count in watcher goroutine!", "error", err) + + continue + } + + slog.Info("Got unread count", "count", unreadCount) + + emitDBusSignal(unreadCount) + + case err, ok := <-watcher.Errors: + if !ok { + return + } + + slog.Error("Got error from filesystem watcher!", "error", err) + } + } +}