From 181f1abf97c6a3be9300e0d58445d2a10ff384cd Mon Sep 17 00:00:00 2001 From: jms-guy Date: Mon, 6 Oct 2025 13:52:42 -0400 Subject: [PATCH 01/10] added config --- cmd/cli/cli_setup.go | 13 ++-- cmd/cli/commands.go | 1 - cmd/cli/root.go | 1 + cmd/cli/timekeep.go | 12 ++++ cmd/cli/update_command_linux.go | 7 ++ cmd/cli/update_command_unsupported.go | 7 ++ cmd/cli/update_command_windows.go | 7 ++ config.json | 3 + internal/config/config.go | 79 ++++++++++++++++++++++ internal/config/config_path_linux.go | 18 +++++ internal/config/config_path_unsupported.go | 7 ++ internal/config/config_path_windows.go | 10 +++ sql/db_path_linux.go | 3 +- 13 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 cmd/cli/update_command_linux.go create mode 100644 cmd/cli/update_command_unsupported.go create mode 100644 cmd/cli/update_command_windows.go create mode 100644 config.json create mode 100644 internal/config/config.go create mode 100644 internal/config/config_path_linux.go create mode 100644 internal/config/config_path_unsupported.go create mode 100644 internal/config/config_path_windows.go diff --git a/cmd/cli/cli_setup.go b/cmd/cli/cli_setup.go index aebf14b..00f1260 100644 --- a/cmd/cli/cli_setup.go +++ b/cmd/cli/cli_setup.go @@ -1,6 +1,7 @@ package main import ( + "github.com/jms-guy/timekeep/internal/config" "github.com/jms-guy/timekeep/internal/repository" mysql "github.com/jms-guy/timekeep/sql" ) @@ -11,11 +12,9 @@ type CLIService struct { HsRepo repository.HistoryRepository ServiceCmd ServiceCommander CmdExe CommandExecutor - Version string + Config *config.Config } -var currentVersion = "v1.0.0" - // Creates new CLI service instance func CreateCLIService(pr repository.ProgramRepository, ar repository.ActiveRepository, hr repository.HistoryRepository, sc ServiceCommander, cmdE CommandExecutor) *CLIService { return &CLIService{ @@ -24,7 +23,6 @@ func CreateCLIService(pr repository.ProgramRepository, ar repository.ActiveRepos HsRepo: hr, ServiceCmd: sc, CmdExe: cmdE, - Version: currentVersion, } } @@ -38,6 +36,13 @@ func CLIServiceSetup() (*CLIService, error) { service := CreateCLIService(store, store, store, &realServiceCommander{}, &realCommandExecutor{}) + config, err := config.LoadConfig() + if err != nil { + return nil, err + } + + service.Config = config + return service, nil } diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index ed53d64..6a8ee2f 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -296,6 +296,5 @@ func (s *CLIService) GetActiveSessions(ctx context.Context) error { // Basic function to print the current Timekeep version func (s *CLIService) GetVersion() error { - fmt.Println(s.Version) return nil } diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 7a27a72..2472f7f 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -29,6 +29,7 @@ func (s *CLIService) RootCmd() *cobra.Command { rootCmd.AddCommand(s.statusServiceCmd()) rootCmd.AddCommand(s.getActiveSessionsCmd()) rootCmd.AddCommand(s.getVersionCmd()) + rootCmd.AddCommand(s.updateCmd()) return rootCmd } diff --git a/cmd/cli/timekeep.go b/cmd/cli/timekeep.go index 39c2e44..011eb57 100644 --- a/cmd/cli/timekeep.go +++ b/cmd/cli/timekeep.go @@ -177,3 +177,15 @@ func (s *CLIService) getVersionCmd() *cobra.Command { }, } } + +func (s *CLIService) updateCmd() *cobra.Command { + return &cobra.Command{ + Use: "update", + Aliases: []string{"Update", "UPDATE"}, + Short: "Update to latest version of Timekeep", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + return s.UpdateTimekeep() + }, + } +} diff --git a/cmd/cli/update_command_linux.go b/cmd/cli/update_command_linux.go new file mode 100644 index 0000000..8b0d561 --- /dev/null +++ b/cmd/cli/update_command_linux.go @@ -0,0 +1,7 @@ +//go:build linux + +package main + +func (s *CLIService) UpdateTimekeep() error { + return nil +} diff --git a/cmd/cli/update_command_unsupported.go b/cmd/cli/update_command_unsupported.go new file mode 100644 index 0000000..b5449d0 --- /dev/null +++ b/cmd/cli/update_command_unsupported.go @@ -0,0 +1,7 @@ +//go:build !windows && !linux + +package main + +func (s *CLIService) UpdateTimekeep() error { + return nil +} diff --git a/cmd/cli/update_command_windows.go b/cmd/cli/update_command_windows.go new file mode 100644 index 0000000..d64789c --- /dev/null +++ b/cmd/cli/update_command_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package main + +func (s *CLIService) UpdateTimekeep() error { + return nil +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..cb7ad94 --- /dev/null +++ b/config.json @@ -0,0 +1,3 @@ +{ + "version": "1.1.1" +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..cb6a6cf --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,79 @@ +package config + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +type Config struct { + WakaTime WakaTimeConfig `json:"wakatime"` +} + +type WakaTimeConfig struct { + Enabled bool `json:"enabled"` + APIKey string `json:"api_key,omitempty"` +} + +const defaultConfig = `{ + "wakatime": { + "enabled": false + } +}` + +func LoadConfig() (*Config, error) { + configFile, err := getConfigLocation() + if err != nil { + return nil, err + } + + if err := os.MkdirAll(filepath.Dir(configFile), 0o750); err != nil { + return nil, fmt.Errorf("failed to create config directory: %w", err) + } + + if _, err := os.Stat(configFile); os.IsNotExist(err) { + err := os.WriteFile(configFile, []byte(defaultConfig), 0o644) + if err != nil { + return nil, fmt.Errorf("error generating default config: %w", err) + } + } + + file, err := os.Open(configFile) + if err != nil { + return nil, fmt.Errorf("failed to open config file: %w", err) + } + defer file.Close() + + bytes, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var config Config + err = json.Unmarshal(bytes, &config) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal config file: %w", err) + } + + return &config, nil +} + +func (c *Config) Save() error { + configFile, err := getConfigLocation() + if err != nil { + return err + } + + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configFile, data, 0o644); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + return nil +} diff --git a/internal/config/config_path_linux.go b/internal/config/config_path_linux.go new file mode 100644 index 0000000..64fbd13 --- /dev/null +++ b/internal/config/config_path_linux.go @@ -0,0 +1,18 @@ +//go:build linux + +package config + +import ( + "os" + "path/filepath" +) + +func getConfigLocation() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + path := filepath.Join(home, ".local", "config", "timekeep", "config.json") + + return path, nil +} diff --git a/internal/config/config_path_unsupported.go b/internal/config/config_path_unsupported.go new file mode 100644 index 0000000..08dc240 --- /dev/null +++ b/internal/config/config_path_unsupported.go @@ -0,0 +1,7 @@ +//go:build !windows && !linux + +package config + +func getConfigLocation() (string, error) { + return "", nil +} diff --git a/internal/config/config_path_windows.go b/internal/config/config_path_windows.go new file mode 100644 index 0000000..518b8b9 --- /dev/null +++ b/internal/config/config_path_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package config + +import "path/filepath" + +func getConfigLocation() (string, error) { + configDir := `C:\ProgramData\Timekeep\config` + return filepath.Join(configDir, "config.json"), nil +} diff --git a/sql/db_path_linux.go b/sql/db_path_linux.go index 1148455..1d3c775 100644 --- a/sql/db_path_linux.go +++ b/sql/db_path_linux.go @@ -3,7 +3,6 @@ package sql import ( - "log" "os" "path/filepath" ) @@ -15,6 +14,6 @@ func getDatabasePath() (string, error) { return "", err } dbPath := filepath.Join(home, ".local", "share", "timekeep", "timekeep.db") - log.Printf("SERVICE DEBUG: Database path: %s", dbPath) + return dbPath, nil } From e46804bcf4f20a73f04fc20b3fc255a20f95455c Mon Sep 17 00:00:00 2001 From: jms-guy Date: Tue, 7 Oct 2025 11:48:18 -0400 Subject: [PATCH 02/10] fixed versioning --- .github/workflows/CD.yml | 4 ++-- cmd/cli/cli_setup.go | 4 ++++ cmd/cli/commands.go | 1 + cmd/cli/root.go | 1 - cmd/cli/timekeep.go | 12 ------------ cmd/cli/update_command_linux.go | 7 ------- cmd/cli/update_command_unsupported.go | 7 ------- cmd/cli/update_command_windows.go | 7 ------- 8 files changed, 7 insertions(+), 36 deletions(-) delete mode 100644 cmd/cli/update_command_linux.go delete mode 100644 cmd/cli/update_command_unsupported.go delete mode 100644 cmd/cli/update_command_windows.go diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index ad3888a..099e02c 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -19,8 +19,8 @@ jobs: GOOS=linux go build -o timekeepd ./cmd/service # CLI binaries - GOOS=windows go build -o timekeep.exe ./cmd/cli - GOOS=linux go build -o timekeep ./cmd/cli + GOOS=windows go build -ldflags "-X main.Version=${{ github.ref_name }}" -o timekeep.exe ./cmd/cli + GOOS=linux go build -ldflags "-X main.Version=${{ github.ref_name }}" -o timekeep ./cmd/cli - name: Prepare release assets run: | diff --git a/cmd/cli/cli_setup.go b/cmd/cli/cli_setup.go index 00f1260..3e634c1 100644 --- a/cmd/cli/cli_setup.go +++ b/cmd/cli/cli_setup.go @@ -6,6 +6,8 @@ import ( mysql "github.com/jms-guy/timekeep/sql" ) +var Version = "dev" + type CLIService struct { PrRepo repository.ProgramRepository AsRepo repository.ActiveRepository @@ -13,6 +15,7 @@ type CLIService struct { ServiceCmd ServiceCommander CmdExe CommandExecutor Config *config.Config + Version string } // Creates new CLI service instance @@ -23,6 +26,7 @@ func CreateCLIService(pr repository.ProgramRepository, ar repository.ActiveRepos HsRepo: hr, ServiceCmd: sc, CmdExe: cmdE, + Version: Version, } } diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 6a8ee2f..ed53d64 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -296,5 +296,6 @@ func (s *CLIService) GetActiveSessions(ctx context.Context) error { // Basic function to print the current Timekeep version func (s *CLIService) GetVersion() error { + fmt.Println(s.Version) return nil } diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 2472f7f..7a27a72 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -29,7 +29,6 @@ func (s *CLIService) RootCmd() *cobra.Command { rootCmd.AddCommand(s.statusServiceCmd()) rootCmd.AddCommand(s.getActiveSessionsCmd()) rootCmd.AddCommand(s.getVersionCmd()) - rootCmd.AddCommand(s.updateCmd()) return rootCmd } diff --git a/cmd/cli/timekeep.go b/cmd/cli/timekeep.go index 011eb57..39c2e44 100644 --- a/cmd/cli/timekeep.go +++ b/cmd/cli/timekeep.go @@ -177,15 +177,3 @@ func (s *CLIService) getVersionCmd() *cobra.Command { }, } } - -func (s *CLIService) updateCmd() *cobra.Command { - return &cobra.Command{ - Use: "update", - Aliases: []string{"Update", "UPDATE"}, - Short: "Update to latest version of Timekeep", - Args: cobra.ExactArgs(0), - RunE: func(cmd *cobra.Command, args []string) error { - return s.UpdateTimekeep() - }, - } -} diff --git a/cmd/cli/update_command_linux.go b/cmd/cli/update_command_linux.go deleted file mode 100644 index 8b0d561..0000000 --- a/cmd/cli/update_command_linux.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build linux - -package main - -func (s *CLIService) UpdateTimekeep() error { - return nil -} diff --git a/cmd/cli/update_command_unsupported.go b/cmd/cli/update_command_unsupported.go deleted file mode 100644 index b5449d0..0000000 --- a/cmd/cli/update_command_unsupported.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows && !linux - -package main - -func (s *CLIService) UpdateTimekeep() error { - return nil -} diff --git a/cmd/cli/update_command_windows.go b/cmd/cli/update_command_windows.go deleted file mode 100644 index d64789c..0000000 --- a/cmd/cli/update_command_windows.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build windows - -package main - -func (s *CLIService) UpdateTimekeep() error { - return nil -} From 7df67f58a759cd4a3584fbc338570c56b50c3992 Mon Sep 17 00:00:00 2001 From: jms-guy Date: Tue, 7 Oct 2025 12:29:43 -0400 Subject: [PATCH 03/10] enable/disable wakatime functions --- cmd/cli/commands.go | 66 +++++++++++++++++++++++++++++++++++++++ cmd/cli/root.go | 5 +++ cmd/cli/timekeep.go | 40 ++++++++++++++++++++++-- internal/config/config.go | 3 +- 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index ed53d64..4f79d58 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -299,3 +299,69 @@ func (s *CLIService) GetVersion() error { fmt.Println(s.Version) return nil } + +// Changes config to enable WakaTime with API key +func (s *CLIService) EnableWakaTime(apiKey string) error { + if s.Config.WakaTime.Enabled { + fmt.Println("WakaTime integration already enabled") + return nil + } + + if apiKey != "" { + s.Config.WakaTime.APIKey = apiKey + s.Config.WakaTime.Enabled = true + + err := s.Config.Save() + if err != nil { + return err + } + err = s.ServiceCmd.WriteToService() + if err != nil { + return err + } + + fmt.Println("WakaTime integration enabled") + return nil + } + + if s.Config.WakaTime.APIKey != "" { + s.Config.WakaTime.Enabled = true + + err := s.Config.Save() + if err != nil { + return err + } + err = s.ServiceCmd.WriteToService() + if err != nil { + return err + } + + fmt.Println("WakaTime integration enabled") + return nil + } + + fmt.Println("User must provide a WakaTime API key") + return nil +} + +// Disables WakaTime in config +func (s *CLIService) DisableWakaTime() error { + if !s.Config.WakaTime.Enabled { + fmt.Println("WakaTime integration disabled") + return nil + } + + s.Config.WakaTime.Enabled = false + + err := s.Config.Save() + if err != nil { + return err + } + err = s.ServiceCmd.WriteToService() + if err != nil { + return err + } + + fmt.Println("WakaTime integration disabled") + return nil +} diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 7a27a72..7e3de0f 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -19,6 +19,11 @@ func (s *CLIService) RootCmd() *cobra.Command { }, } + wCmd := s.wakatimeIntegration() + wCmd.AddCommand(s.wakatimeEnable()) + wCmd.AddCommand(s.wakatimeDisable()) + + rootCmd.AddCommand(wCmd) rootCmd.AddCommand(s.addProgramsCmd()) rootCmd.AddCommand(s.removeProgramsCmd()) rootCmd.AddCommand(s.getListcmd()) diff --git a/cmd/cli/timekeep.go b/cmd/cli/timekeep.go index 39c2e44..613dab4 100644 --- a/cmd/cli/timekeep.go +++ b/cmd/cli/timekeep.go @@ -37,7 +37,7 @@ func (s *CLIService) removeProgramsCmd() *cobra.Command { }, } - cmd.Flags().Bool("all", false, "When provided removes all currently tracked programs") + cmd.Flags().Bool("all", false, "Removes all currently tracked programs") return cmd } @@ -135,7 +135,7 @@ func (s *CLIService) resetStatsCmd() *cobra.Command { }, } - cmd.Flags().Bool("all", false, "If flag is provided, resets all currently tracked program data. Does not remove programs from tracking") + cmd.Flags().Bool("all", false, "Resets all currently tracked program data. Does not remove programs from tracking") return cmd } @@ -177,3 +177,39 @@ func (s *CLIService) getVersionCmd() *cobra.Command { }, } } + +func (s *CLIService) wakatimeIntegration() *cobra.Command { + return &cobra.Command{ + Use: "wakatime", + Aliases: []string{"WakaTime", "WAKATIME"}, + Short: "Enable/disable integration with WakaTime", + } +} + +func (s *CLIService) wakatimeEnable() *cobra.Command { + cmd := &cobra.Command{ + Use: "enable", + Aliases: []string{"Enable", "ENABLE"}, + Short: "Enable WakaTime integration", + RunE: func(cmd *cobra.Command, args []string) error { + apiKey, _ := cmd.Flags().GetString("api-key") + + return s.EnableWakaTime(apiKey) + }, + } + + cmd.Flags().String("api-key", "", "User's WakaTime API key") + + return cmd +} + +func (s *CLIService) wakatimeDisable() *cobra.Command { + return &cobra.Command{ + Use: "disable", + Aliases: []string{"Disable", "DISABLE"}, + Short: "Disable WakaTime integration", + RunE: func(cmd *cobra.Command, args []string) error { + return s.DisableWakaTime() + }, + } +} diff --git a/internal/config/config.go b/internal/config/config.go index cb6a6cf..95f51d3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,7 +19,8 @@ type WakaTimeConfig struct { const defaultConfig = `{ "wakatime": { - "enabled": false + "enabled": false, + "api_key": "" } }` From c49cb738a3747cbe07b811a40fbaead345a9a7be Mon Sep 17 00:00:00 2001 From: jms-guy Date: Tue, 7 Oct 2025 13:33:32 -0400 Subject: [PATCH 04/10] updated tracked_programs db table to include categories, updated add functions. service now loads config in eventctrl, began wakatime functions --- cmd/cli/cli_setup.go | 2 +- cmd/cli/commands.go | 14 ++++++-- cmd/cli/timekeep.go | 10 ++++-- .../internal/events/event_controller.go | 26 +++++++++++--- .../internal/events/events_wakatime.go | 35 +++++++++++++++++++ cmd/service/service_linux.go | 4 +++ cmd/service/service_setup.go | 8 +++++ cmd/service/service_windows.go | 4 +++ internal/config/config.go | 2 +- internal/database/models.go | 2 ++ internal/database/tracked_programs.sql.go | 32 ++++++++++++----- internal/repository/repo.go | 6 ++-- sql/queries/tracked_programs.sql | 4 +-- sql/schema/001_tracked_programs.sql | 1 + 14 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 cmd/service/internal/events/events_wakatime.go diff --git a/cmd/cli/cli_setup.go b/cmd/cli/cli_setup.go index 3e634c1..1706728 100644 --- a/cmd/cli/cli_setup.go +++ b/cmd/cli/cli_setup.go @@ -40,7 +40,7 @@ func CLIServiceSetup() (*CLIService, error) { service := CreateCLIService(store, store, store, &realServiceCommander{}, &realCommandExecutor{}) - config, err := config.LoadConfig() + config, err := config.Load() if err != nil { return nil, err } diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 4f79d58..5f3bab5 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -11,13 +11,23 @@ import ( ) // Adds programs into the database, and sends communication to service to being tracking them -func (s *CLIService) AddPrograms(ctx context.Context, args []string) error { +func (s *CLIService) AddPrograms(ctx context.Context, args []string, category string) error { var addedPrograms []string + + categoryNull := sql.NullString{ + String: category, + Valid: category != "", + } + for _, program := range args { - err := s.PrRepo.AddProgram(ctx, strings.ToLower(program)) + err := s.PrRepo.AddProgram(ctx, database.AddProgramParams{ + Name: strings.ToLower(program), + Category: categoryNull, + }) if err != nil { return fmt.Errorf("error adding program %s: %w", program, err) } + addedPrograms = append(addedPrograms, program) } diff --git a/cmd/cli/timekeep.go b/cmd/cli/timekeep.go index 613dab4..342bfce 100644 --- a/cmd/cli/timekeep.go +++ b/cmd/cli/timekeep.go @@ -7,7 +7,7 @@ import ( ) func (s *CLIService) addProgramsCmd() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "add", Aliases: []string{"Add", "ADD"}, Short: "Add a program to begin tracking", @@ -16,9 +16,15 @@ func (s *CLIService) addProgramsCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - return s.AddPrograms(ctx, args) + category, _ := cmd.Flags().GetString("category") + + return s.AddPrograms(ctx, args, category) }, } + + cmd.Flags().String("category", "", "Add category to tracked program(s). Category provided will be applied to all programs passed as arguments. (required for WakaTime integration)") + + return cmd } func (s *CLIService) removeProgramsCmd() *cobra.Command { diff --git a/cmd/service/internal/events/event_controller.go b/cmd/service/internal/events/event_controller.go index 7b91fcd..3cbd07f 100644 --- a/cmd/service/internal/events/event_controller.go +++ b/cmd/service/internal/events/event_controller.go @@ -8,8 +8,10 @@ import ( "net" "os/exec" "strings" + "time" "github.com/jms-guy/timekeep/cmd/service/internal/sessions" + "github.com/jms-guy/timekeep/internal/config" "github.com/jms-guy/timekeep/internal/repository" ) @@ -21,8 +23,10 @@ type Command struct { } type EventController struct { - PsProcess *exec.Cmd // Powershell process for Windows event monitoring - cancel context.CancelFunc + PsProcess *exec.Cmd // Powershell process for Windows event monitoring + cancel context.CancelFunc // Event monitoring cancel context + Config *config.Config // Struct built from config file + wakaHeartbeatTicker *time.Ticker // Ticker for WakaTime enabled heartbeats } func NewEventController() *EventController { @@ -69,14 +73,14 @@ func (e *EventController) HandleConnection(logger *log.Logger, s *sessions.Sessi // Stops the currently running process monitoring script, and starts a new one with updated program list func (e *EventController) RefreshProcessMonitor(logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository) { + e.StopProcessMonitor() + programs, err := pr.GetAllProgramNames(context.Background()) if err != nil { logger.Printf("ERROR: Failed to get programs: %s", err) return } - e.StopProcessMonitor() - if len(programs) > 0 { for _, program := range programs { s.EnsureProgram(program) @@ -84,5 +88,19 @@ func (e *EventController) RefreshProcessMonitor(logger *log.Logger, s *sessions. go e.MonitorProcesses(logger, s, pr, a, h, programs) } + newConfig, err := config.Load() + if err != nil { + logger.Printf("ERROR: Failed to load config: %s", err) + return + } + + if newConfig.WakaTime.Enabled && !e.Config.WakaTime.Enabled { + e.StartHeartbeats(s) + } else if !newConfig.WakaTime.Enabled && e.Config.WakaTime.Enabled { + e.StopHeartbeats() + } + + e.Config = newConfig + logger.Printf("INFO: Process monitor refresh with %d programs", len(programs)) } diff --git a/cmd/service/internal/events/events_wakatime.go b/cmd/service/internal/events/events_wakatime.go new file mode 100644 index 0000000..3771503 --- /dev/null +++ b/cmd/service/internal/events/events_wakatime.go @@ -0,0 +1,35 @@ +package events + +import ( + "time" + + "github.com/jms-guy/timekeep/cmd/service/internal/sessions" +) + +// Start WakaTime heartbeat ticker +func (e *EventController) StartHeartbeats(s *sessions.SessionManager) { + e.wakaHeartbeatTicker = time.NewTicker(1 * time.Minute) + + go func() { + for range e.wakaHeartbeatTicker.C { + e.sendHeartbeats(s) + } + }() +} + +// Send specified heartbeats to WakaTime +func (e *EventController) sendHeartbeats(s *sessions.SessionManager) { + s.Mu.Lock() + defer s.Mu.Unlock() + + for program, tracked := range s.Programs { + if len(tracked.PIDs) > 0 { + e.sendWakaHeartbeat() + } + } +} + +// Stops WakaTime heartbeat ticker after disabling integration +func (e *EventController) StopHeartbeats() { + e.wakaHeartbeatTicker.Stop() +} diff --git a/cmd/service/service_linux.go b/cmd/service/service_linux.go index 62cbac2..f1bb8fd 100644 --- a/cmd/service/service_linux.go +++ b/cmd/service/service_linux.go @@ -58,6 +58,10 @@ func (s *timekeepService) Manage() (string, error) { go s.eventCtrl.MonitorProcesses(s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, programs) } + if s.eventCtrl.Config.WakaTime.Enabled { + s.eventCtrl.StartHeartbeats(s.sessions) + } + go s.transport.Listen(s.logger.Logger, s.eventCtrl, s.sessions, s.prRepo, s.asRepo, s.hsRepo) interrupt := make(chan os.Signal, 1) diff --git a/cmd/service/service_setup.go b/cmd/service/service_setup.go index a498a16..d37bba5 100644 --- a/cmd/service/service_setup.go +++ b/cmd/service/service_setup.go @@ -6,6 +6,7 @@ import ( "github.com/jms-guy/timekeep/cmd/service/internal/logs" "github.com/jms-guy/timekeep/cmd/service/internal/sessions" "github.com/jms-guy/timekeep/cmd/service/internal/transport" + "github.com/jms-guy/timekeep/internal/config" "github.com/jms-guy/timekeep/internal/repository" mysql "github.com/jms-guy/timekeep/sql" ) @@ -46,6 +47,13 @@ func ServiceSetup() (*timekeepService, error) { service := NewTimekeepService(store, store, store, logger, eventCtrl, sessions, ts, d) + config, err := config.Load() + if err != nil { + return nil, err + } + + service.eventCtrl.Config = config + return service, nil } diff --git a/cmd/service/service_windows.go b/cmd/service/service_windows.go index b54c855..5521d14 100644 --- a/cmd/service/service_windows.go +++ b/cmd/service/service_windows.go @@ -54,6 +54,10 @@ func (s *timekeepService) Execute(args []string, r <-chan svc.ChangeRequest, sta s.eventCtrl.MonitorProcesses(s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, programs) } + if s.eventCtrl.Config.WakaTime.Enabled { + s.eventCtrl.StartHeartbeats(s.sessions) + } + go s.transport.Listen(s.logger.Logger, s.eventCtrl, s.sessions, s.prRepo, s.asRepo, s.hsRepo) // Service mainloop, handles only SCM signals diff --git a/internal/config/config.go b/internal/config/config.go index 95f51d3..57b7c50 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,7 +24,7 @@ const defaultConfig = `{ } }` -func LoadConfig() (*Config, error) { +func Load() (*Config, error) { configFile, err := getConfigLocation() if err != nil { return nil, err diff --git a/internal/database/models.go b/internal/database/models.go index 78bb4de..3ed65a0 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -5,6 +5,7 @@ package database import ( + "database/sql" "time" ) @@ -25,5 +26,6 @@ type SessionHistory struct { type TrackedProgram struct { ID int64 Name string + Category sql.NullString LifetimeSeconds int64 } diff --git a/internal/database/tracked_programs.sql.go b/internal/database/tracked_programs.sql.go index e446832..89eb431 100644 --- a/internal/database/tracked_programs.sql.go +++ b/internal/database/tracked_programs.sql.go @@ -7,15 +7,21 @@ package database import ( "context" + "database/sql" ) const addProgram = `-- name: AddProgram :exec -INSERT OR IGNORE INTO tracked_programs (name) -VALUES (?) +INSERT OR IGNORE INTO tracked_programs (name, category) +VALUES (?, ?) ` -func (q *Queries) AddProgram(ctx context.Context, name string) error { - _, err := q.db.ExecContext(ctx, addProgram, name) +type AddProgramParams struct { + Name string + Category sql.NullString +} + +func (q *Queries) AddProgram(ctx context.Context, arg AddProgramParams) error { + _, err := q.db.ExecContext(ctx, addProgram, arg.Name, arg.Category) return err } @@ -47,7 +53,7 @@ func (q *Queries) GetAllProgramNames(ctx context.Context) ([]string, error) { } const getAllPrograms = `-- name: GetAllPrograms :many -SELECT id, name, lifetime_seconds FROM tracked_programs +SELECT id, name, category, lifetime_seconds FROM tracked_programs ` func (q *Queries) GetAllPrograms(ctx context.Context) ([]TrackedProgram, error) { @@ -59,7 +65,12 @@ func (q *Queries) GetAllPrograms(ctx context.Context) ([]TrackedProgram, error) var items []TrackedProgram for rows.Next() { var i TrackedProgram - if err := rows.Scan(&i.ID, &i.Name, &i.LifetimeSeconds); err != nil { + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Category, + &i.LifetimeSeconds, + ); err != nil { return nil, err } items = append(items, i) @@ -74,14 +85,19 @@ func (q *Queries) GetAllPrograms(ctx context.Context) ([]TrackedProgram, error) } const getProgramByName = `-- name: GetProgramByName :one -SELECT id, name, lifetime_seconds FROM tracked_programs +SELECT id, name, category, lifetime_seconds FROM tracked_programs WHERE name = ? ` func (q *Queries) GetProgramByName(ctx context.Context, name string) (TrackedProgram, error) { row := q.db.QueryRowContext(ctx, getProgramByName, name) var i TrackedProgram - err := row.Scan(&i.ID, &i.Name, &i.LifetimeSeconds) + err := row.Scan( + &i.ID, + &i.Name, + &i.Category, + &i.LifetimeSeconds, + ) return i, err } diff --git a/internal/repository/repo.go b/internal/repository/repo.go index 70ff4f0..f48e51c 100644 --- a/internal/repository/repo.go +++ b/internal/repository/repo.go @@ -10,7 +10,7 @@ import ( // Repository abstraction interfaces type ProgramRepository interface { - AddProgram(ctx context.Context, name string) error + AddProgram(ctx context.Context, arg database.AddProgramParams) error GetAllProgramNames(ctx context.Context) ([]string, error) GetAllPrograms(ctx context.Context) ([]database.TrackedProgram, error) GetProgramByName(ctx context.Context, name string) (database.TrackedProgram, error) @@ -52,8 +52,8 @@ func NewSqliteStore(queries *database.Queries) *sqliteStore { } // //////////////// Program Repository ////////////////// -func (s *sqliteStore) AddProgram(ctx context.Context, name string) error { - return s.db.AddProgram(ctx, name) +func (s *sqliteStore) AddProgram(ctx context.Context, arg database.AddProgramParams) error { + return s.db.AddProgram(ctx, arg) } func (s *sqliteStore) GetAllProgramNames(ctx context.Context) ([]string, error) { diff --git a/sql/queries/tracked_programs.sql b/sql/queries/tracked_programs.sql index e3a05f2..cb21b39 100644 --- a/sql/queries/tracked_programs.sql +++ b/sql/queries/tracked_programs.sql @@ -9,8 +9,8 @@ SELECT name FROM tracked_programs; SELECT * FROM tracked_programs; -- name: AddProgram :exec -INSERT OR IGNORE INTO tracked_programs (name) -VALUES (?); +INSERT OR IGNORE INTO tracked_programs (name, category) +VALUES (?, ?); -- name: RemoveProgram :exec DELETE FROM tracked_programs diff --git a/sql/schema/001_tracked_programs.sql b/sql/schema/001_tracked_programs.sql index 20deca1..60e748b 100644 --- a/sql/schema/001_tracked_programs.sql +++ b/sql/schema/001_tracked_programs.sql @@ -2,6 +2,7 @@ CREATE TABLE tracked_programs ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, + category TEXT, lifetime_seconds INTEGER NOT NULL DEFAULT 0 ); From e52b3a3f580adfbc1213e5ebc706c0c91061d127 Mon Sep 17 00:00:00 2001 From: jms-guy Date: Tue, 7 Oct 2025 13:56:08 -0400 Subject: [PATCH 05/10] updating service to include program categories --- .../internal/events/event_controller.go | 18 ++++++++---- .../internal/events/events_wakatime.go | 12 ++++---- cmd/service/internal/sessions/sessions.go | 5 ++-- cmd/service/service_setup.go | 3 ++ cmd/service/service_windows.go | 29 ++++++++++++++++--- 5 files changed, 50 insertions(+), 17 deletions(-) diff --git a/cmd/service/internal/events/event_controller.go b/cmd/service/internal/events/event_controller.go index 3cbd07f..18bf735 100644 --- a/cmd/service/internal/events/event_controller.go +++ b/cmd/service/internal/events/event_controller.go @@ -72,20 +72,28 @@ func (e *EventController) HandleConnection(logger *log.Logger, s *sessions.Sessi } // Stops the currently running process monitoring script, and starts a new one with updated program list -func (e *EventController) RefreshProcessMonitor(logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository) { +func (e *EventController) RefreshProcessMonitor(logger *log.Logger, sm *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository) { e.StopProcessMonitor() - programs, err := pr.GetAllProgramNames(context.Background()) + programs, err := pr.GetAllPrograms(context.Background()) if err != nil { logger.Printf("ERROR: Failed to get programs: %s", err) return } if len(programs) > 0 { + toTrack := []string{} for _, program := range programs { - s.EnsureProgram(program) + category := "" + if program.Category.Valid { + category = program.Category.String + } + sm.EnsureProgram(program.Name, category) + + toTrack = append(toTrack, program.Name) } - go e.MonitorProcesses(logger, s, pr, a, h, programs) + + go e.MonitorProcesses(logger, sm, pr, a, h, toTrack) } newConfig, err := config.Load() @@ -95,7 +103,7 @@ func (e *EventController) RefreshProcessMonitor(logger *log.Logger, s *sessions. } if newConfig.WakaTime.Enabled && !e.Config.WakaTime.Enabled { - e.StartHeartbeats(s) + e.StartHeartbeats(sm) } else if !newConfig.WakaTime.Enabled && e.Config.WakaTime.Enabled { e.StopHeartbeats() } diff --git a/cmd/service/internal/events/events_wakatime.go b/cmd/service/internal/events/events_wakatime.go index 3771503..be91374 100644 --- a/cmd/service/internal/events/events_wakatime.go +++ b/cmd/service/internal/events/events_wakatime.go @@ -7,22 +7,22 @@ import ( ) // Start WakaTime heartbeat ticker -func (e *EventController) StartHeartbeats(s *sessions.SessionManager) { +func (e *EventController) StartHeartbeats(sm *sessions.SessionManager) { e.wakaHeartbeatTicker = time.NewTicker(1 * time.Minute) go func() { for range e.wakaHeartbeatTicker.C { - e.sendHeartbeats(s) + e.sendHeartbeats(sm) } }() } // Send specified heartbeats to WakaTime -func (e *EventController) sendHeartbeats(s *sessions.SessionManager) { - s.Mu.Lock() - defer s.Mu.Unlock() +func (e *EventController) sendHeartbeats(sm *sessions.SessionManager) { + sm.Mu.Lock() + defer sm.Mu.Unlock() - for program, tracked := range s.Programs { + for program, tracked := range sm.Programs { if len(tracked.PIDs) > 0 { e.sendWakaHeartbeat() } diff --git a/cmd/service/internal/sessions/sessions.go b/cmd/service/internal/sessions/sessions.go index e7dc2c6..97d0ce8 100644 --- a/cmd/service/internal/sessions/sessions.go +++ b/cmd/service/internal/sessions/sessions.go @@ -12,6 +12,7 @@ import ( ) type Tracked struct { + Category string PIDs map[int]struct{} StartAt time.Time LastSeen time.Time @@ -27,7 +28,7 @@ func NewSessionManager() *SessionManager { } // Make sure map is initialized, add program to map if not already present -func (sm *SessionManager) EnsureProgram(name string) { +func (sm *SessionManager) EnsureProgram(name, category string) { sm.Mu.Lock() defer sm.Mu.Unlock() @@ -37,7 +38,7 @@ func (sm *SessionManager) EnsureProgram(name string) { name = strings.ToLower(name) if _, ok := sm.Programs[name]; !ok { - sm.Programs[name] = &Tracked{PIDs: make(map[int]struct{})} + sm.Programs[name] = &Tracked{Category: category, PIDs: make(map[int]struct{})} } } diff --git a/cmd/service/service_setup.go b/cmd/service/service_setup.go index d37bba5..63c81c7 100644 --- a/cmd/service/service_setup.go +++ b/cmd/service/service_setup.go @@ -90,6 +90,9 @@ func NewTimekeepService(pr repository.ProgramRepository, ar repository.ActiveRep // Service shutdown function func (s *timekeepService) closeService() { + if s.eventCtrl.Config.WakaTime.Enabled { + s.eventCtrl.StopHeartbeats() + } s.eventCtrl.StopProcessMonitor() // Stop any current monitoring function close(s.transport.Shutdown) // Close open IPC s.logger.FileCleanup() // Close open logging file diff --git a/cmd/service/service_windows.go b/cmd/service/service_windows.go index 5521d14..36c15e7 100644 --- a/cmd/service/service_windows.go +++ b/cmd/service/service_windows.go @@ -40,18 +40,26 @@ func (s *timekeepService) Execute(args []string, r <-chan svc.ChangeRequest, sta const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown | svc.AcceptPauseAndContinue status <- svc.Status{State: svc.StartPending} - status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} - programs, err := s.prRepo.GetAllProgramNames(context.Background()) + programs, err := s.prRepo.GetAllPrograms(context.Background()) if err != nil { s.logger.Logger.Printf("ERROR: Failed to get programs: %s", err) + status <- svc.Status{State: svc.Stopped} return false, 1 } if len(programs) > 0 { + toTrack := []string{} for _, program := range programs { - s.sessions.EnsureProgram(program) + category := "" + if program.Category.Valid { + category = program.Category.String + } + s.sessions.EnsureProgram(program.Name, category) + + toTrack = append(toTrack, program.Name) } - s.eventCtrl.MonitorProcesses(s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, programs) + + s.eventCtrl.MonitorProcesses(s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, toTrack) } if s.eventCtrl.Config.WakaTime.Enabled { @@ -60,6 +68,8 @@ func (s *timekeepService) Execute(args []string, r <-chan svc.ChangeRequest, sta go s.transport.Listen(s.logger.Logger, s.eventCtrl, s.sessions, s.prRepo, s.asRepo, s.hsRepo) + status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + // Service mainloop, handles only SCM signals loop: for { @@ -69,11 +79,22 @@ loop: case svc.Interrogate: // Check current status of service status <- c.CurrentStatus case svc.Stop, svc.Shutdown: // Service needs to be stopped or shutdown + s.logger.Logger.Println("INFO: Received stop signal") s.closeService() break loop case svc.Pause: // Service needs to be paused, without shutdown + s.logger.Logger.Println("INFO: Pausing service") + if s.eventCtrl.Config.WakaTime.Enabled { + s.eventCtrl.StopHeartbeats() + } + s.eventCtrl.StopProcessMonitor() status <- svc.Status{State: svc.Paused, Accepts: cmdsAccepted} case svc.Continue: // Resume paused execution state of service + s.logger.Logger.Println("INFO: Resuming service") + s.eventCtrl.RefreshProcessMonitor(s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo) + if s.eventCtrl.Config.WakaTime.Enabled { + s.eventCtrl.StartHeartbeats(s.sessions) + } status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} default: s.logger.Logger.Printf("ERROR: Unexpected service control request #%d", c) From a8f64e49508e1cbeaa2565ce8ad32b41c92f8679 Mon Sep 17 00:00:00 2001 From: jms-guy Date: Wed, 8 Oct 2025 13:20:00 -0400 Subject: [PATCH 06/10] restructured service context --- cmd/cli/commands_test.go | 4 +- .../internal/events/event_controller.go | 22 ++++--- cmd/service/internal/events/events_linux.go | 34 ++++++----- .../internal/events/events_unsupported.go | 3 +- .../internal/events/events_wakatime.go | 57 +++++++++++++++++-- cmd/service/internal/events/events_windows.go | 16 ++++-- cmd/service/internal/sessions/sessions.go | 18 +++--- .../internal/transport/transport_linux.go | 8 ++- .../transport/transport_unsupported.go | 3 +- .../internal/transport/transport_windows.go | 8 ++- cmd/service/internal/transport/transporter.go | 6 +- cmd/service/service_linux.go | 35 +++++++----- cmd/service/service_setup.go | 9 +-- cmd/service/service_windows.go | 23 +++++--- 14 files changed, 161 insertions(+), 85 deletions(-) diff --git a/cmd/cli/commands_test.go b/cmd/cli/commands_test.go index 4e8e191..31d10bd 100644 --- a/cmd/cli/commands_test.go +++ b/cmd/cli/commands_test.go @@ -18,7 +18,7 @@ func setupTestServiceWithPrograms(t *testing.T, programNames ...string) (*cli.CL } for _, name := range programNames { - err = s.PrRepo.AddProgram(context.Background(), name) + err = s.PrRepo.AddProgram(context.Background(), database.AddProgramParams{Name: name}) if err != nil { t.Fatalf("Failed to add program '%s': %v", name, err) } @@ -48,7 +48,7 @@ func TestAddPrograms(t *testing.T) { } programsToAdd := []string{"notepad.exe", "code.exe"} - err = s.AddPrograms(t.Context(), programsToAdd) + err = s.AddPrograms(t.Context(), programsToAdd, "") assert.Nil(t, err, "AddPrograms should not return error") addedPrograms, err := s.PrRepo.GetAllProgramNames(t.Context()) diff --git a/cmd/service/internal/events/event_controller.go b/cmd/service/internal/events/event_controller.go index 18bf735..490fba9 100644 --- a/cmd/service/internal/events/event_controller.go +++ b/cmd/service/internal/events/event_controller.go @@ -8,6 +8,7 @@ import ( "net" "os/exec" "strings" + "sync" "time" "github.com/jms-guy/timekeep/cmd/service/internal/sessions" @@ -27,6 +28,7 @@ type EventController struct { cancel context.CancelFunc // Event monitoring cancel context Config *config.Config // Struct built from config file wakaHeartbeatTicker *time.Ticker // Ticker for WakaTime enabled heartbeats + heartbeatMu sync.Mutex // Mutex for WakaTime heartbeat ticker } func NewEventController() *EventController { @@ -34,7 +36,7 @@ func NewEventController() *EventController { } // Handles service commands read from pipe/socket connection -func (e *EventController) HandleConnection(logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, conn net.Conn) { +func (e *EventController) HandleConnection(serviceCtx context.Context, logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, conn net.Conn) { defer conn.Close() logger.Println("INFO: Starting to read from connection.") @@ -51,19 +53,23 @@ func (e *EventController) HandleConnection(logger *log.Logger, s *sessions.Sessi cmd.ProcessName = strings.ToLower(cmd.ProcessName) + cmdCtx, cancel := context.WithTimeout(serviceCtx, 5*time.Second) + switch cmd.Action { case "process_start": - s.CreateSession(logger, a, cmd.ProcessName, cmd.ProcessID) + s.CreateSession(cmdCtx, logger, a, cmd.ProcessName, cmd.ProcessID) logger.Printf("INFO: Called createSession for %s (PID: %d)", cmd.ProcessName, cmd.ProcessID) case "process_stop": - s.EndSession(logger, pr, a, h, cmd.ProcessName, cmd.ProcessID) + s.EndSession(cmdCtx, logger, pr, a, h, cmd.ProcessName, cmd.ProcessID) logger.Printf("INFO: Called endSession for %s (PID: %d)", cmd.ProcessName, cmd.ProcessID) case "refresh": - e.RefreshProcessMonitor(logger, s, pr, a, h) + e.RefreshProcessMonitor(cmdCtx, logger, s, pr, a, h) logger.Println("INFO: Called refreshProcessMonitor") default: logger.Printf("WARN: Received unknown command action: %s", cmd.Action) } + + cancel() } if err := scanner.Err(); err != nil { @@ -72,10 +78,10 @@ func (e *EventController) HandleConnection(logger *log.Logger, s *sessions.Sessi } // Stops the currently running process monitoring script, and starts a new one with updated program list -func (e *EventController) RefreshProcessMonitor(logger *log.Logger, sm *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository) { +func (e *EventController) RefreshProcessMonitor(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository) { e.StopProcessMonitor() - programs, err := pr.GetAllPrograms(context.Background()) + programs, err := pr.GetAllPrograms(ctx) if err != nil { logger.Printf("ERROR: Failed to get programs: %s", err) return @@ -93,7 +99,7 @@ func (e *EventController) RefreshProcessMonitor(logger *log.Logger, sm *sessions toTrack = append(toTrack, program.Name) } - go e.MonitorProcesses(logger, sm, pr, a, h, toTrack) + go e.MonitorProcesses(ctx, logger, sm, pr, a, h, toTrack) } newConfig, err := config.Load() @@ -103,7 +109,7 @@ func (e *EventController) RefreshProcessMonitor(logger *log.Logger, sm *sessions } if newConfig.WakaTime.Enabled && !e.Config.WakaTime.Enabled { - e.StartHeartbeats(sm) + e.StartHeartbeats(ctx, logger, sm) } else if !newConfig.WakaTime.Enabled && e.Config.WakaTime.Enabled { e.StopHeartbeats() } diff --git a/cmd/service/internal/events/events_linux.go b/cmd/service/internal/events/events_linux.go index 845af5f..7c8e3d0 100644 --- a/cmd/service/internal/events/events_linux.go +++ b/cmd/service/internal/events/events_linux.go @@ -19,13 +19,11 @@ import ( ) // Main process monitoring function for Linux version -func (e *EventController) MonitorProcesses(logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) { +func (e *EventController) MonitorProcesses(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) { if e.cancel != nil { e.cancel() e.cancel = nil } - ctx, cancel := context.WithCancel(context.Background()) - e.cancel = cancel ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() @@ -35,14 +33,14 @@ func (e *EventController) MonitorProcesses(logger *log.Logger, s *sessions.Sessi case <-ctx.Done(): return case <-ticker.C: - livePIDS := e.checkForProcessStartEvents(logger, s, a, programs) - e.checkForProcessStopEvents(logger, s, pr, a, h, livePIDS) + livePIDS := e.checkForProcessStartEvents(ctx, logger, sm, a, programs) + e.checkForProcessStopEvents(ctx, logger, sm, pr, a, h, livePIDS) } } } // Polls /proc and loops over PID entries, looking for any new PIDS belonging to tracked programs -func (e *EventController) checkForProcessStartEvents(logger *log.Logger, s *sessions.SessionManager, a repository.ActiveRepository, programs []string) map[int]struct{} { +func (e *EventController) checkForProcessStartEvents(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager, a repository.ActiveRepository, programs []string) map[int]struct{} { entries, err := os.ReadDir("/proc") // Read /proc if err != nil { logger.Printf("ERROR: Couldn't read /proc: %s", err) @@ -65,23 +63,23 @@ func (e *EventController) checkForProcessStartEvents(logger *log.Logger, s *sess continue } - s.Mu.Lock() - _, match := s.Programs[identity] // Is program being tracked? + sm.Mu.Lock() + _, match := sm.Programs[identity] // Is program being tracked? if !match { - s.Mu.Unlock() + sm.Mu.Unlock() continue } - if t := s.Programs[identity]; t != nil { + if t := sm.Programs[identity]; t != nil { if _, exists := t.PIDs[pid]; exists { t.LastSeen = time.Now() - s.Mu.Unlock() + sm.Mu.Unlock() continue } } - s.Mu.Unlock() + sm.Mu.Unlock() - s.CreateSession(logger, a, identity, pid) + sm.CreateSession(ctx, logger, a, identity, pid) } return live @@ -89,12 +87,12 @@ func (e *EventController) checkForProcessStartEvents(logger *log.Logger, s *sess // Takes the PID entries found in the previous check function, and compares them against map of active PIDs, to determine if // any active sessions need ending -func (e *EventController) checkForProcessStopEvents(logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, livePIDs map[int]struct{}) { +func (e *EventController) checkForProcessStopEvents(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, livePIDs map[int]struct{}) { if livePIDs == nil { livePIDs = map[int]struct{}{} } - s.Mu.Lock() + sm.Mu.Lock() type toEnd struct { program string pid int @@ -104,7 +102,7 @@ func (e *EventController) checkForProcessStopEvents(logger *log.Logger, s *sessi now := time.Now() // Loop tracked programs. For each PID currently being tracked, check if it exists in the live map. If it does, update last seen value, // else schedule the PID to be removed from tracking - for program, t := range s.Programs { + for program, t := range sm.Programs { if t == nil { continue } @@ -118,10 +116,10 @@ func (e *EventController) checkForProcessStopEvents(logger *log.Logger, s *sessi ends = append(ends, toEnd{program, pid}) } } - s.Mu.Unlock() + sm.Mu.Unlock() for _, eend := range ends { - s.EndSession(logger, pr, a, h, eend.program, eend.pid) + sm.EndSession(ctx, logger, pr, a, h, eend.program, eend.pid) } } diff --git a/cmd/service/internal/events/events_unsupported.go b/cmd/service/internal/events/events_unsupported.go index 75b8505..52b8fe0 100644 --- a/cmd/service/internal/events/events_unsupported.go +++ b/cmd/service/internal/events/events_unsupported.go @@ -3,13 +3,14 @@ package events import ( + "context" "log" "github.com/jms-guy/timekeep/cmd/service/internal/sessions" "github.com/jms-guy/timekeep/internal/repository" ) -func (e *EventController) MonitorProcesses(logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) { +func (e *EventController) MonitorProcesses(ctx context.Context, logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) { return } diff --git a/cmd/service/internal/events/events_wakatime.go b/cmd/service/internal/events/events_wakatime.go index be91374..804248a 100644 --- a/cmd/service/internal/events/events_wakatime.go +++ b/cmd/service/internal/events/events_wakatime.go @@ -1,35 +1,80 @@ package events import ( + "context" + "fmt" + "log" + "os/exec" "time" "github.com/jms-guy/timekeep/cmd/service/internal/sessions" ) // Start WakaTime heartbeat ticker -func (e *EventController) StartHeartbeats(sm *sessions.SessionManager) { +func (e *EventController) StartHeartbeats(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager) { e.wakaHeartbeatTicker = time.NewTicker(1 * time.Minute) go func() { - for range e.wakaHeartbeatTicker.C { - e.sendHeartbeats(sm) + defer e.wakaHeartbeatTicker.Stop() + + errorCount := 0 + for { + select { + case <-ctx.Done(): + logger.Println("INFO: Stopping WakaTime heartbeats") + return + + case <-e.wakaHeartbeatTicker.C: + if errorCount >= 5 { + logger.Println("ERROR: WakaTime heartbeats failed 5 times consecutively, stopping") + return + } + + if err := e.sendHeartbeats(ctx, sm); err != nil { + logger.Printf("ERROR: Failed to send WakaTime heartbeat: %s", err) + errorCount++ + continue + } + + errorCount = 0 + } } }() } // Send specified heartbeats to WakaTime -func (e *EventController) sendHeartbeats(sm *sessions.SessionManager) { +func (e *EventController) sendHeartbeats(ctx context.Context, sm *sessions.SessionManager) error { sm.Mu.Lock() defer sm.Mu.Unlock() for program, tracked := range sm.Programs { if len(tracked.PIDs) > 0 { - e.sendWakaHeartbeat() + if err := e.sendWakaHeartbeat(ctx, program, tracked.Category); err != nil { + return err + } } } + + return nil +} + +// Call the wakatime-cli heartbeat command +func (e *EventController) sendWakaHeartbeat(ctx context.Context, program, category string) error { + cmd := exec.CommandContext(ctx, "wakatime-cli", "--entity", program, "--category", category, "--time", fmt.Sprintf("%d", time.Now().Unix())) + if err := cmd.Run(); err != nil { + return err + } + + return nil } // Stops WakaTime heartbeat ticker after disabling integration func (e *EventController) StopHeartbeats() { - e.wakaHeartbeatTicker.Stop() + e.heartbeatMu.Lock() + defer e.heartbeatMu.Unlock() + + if e.wakaHeartbeatTicker != nil { + e.wakaHeartbeatTicker.Stop() + e.wakaHeartbeatTicker = nil + } } diff --git a/cmd/service/internal/events/events_windows.go b/cmd/service/internal/events/events_windows.go index 47b8dba..31fe212 100644 --- a/cmd/service/internal/events/events_windows.go +++ b/cmd/service/internal/events/events_windows.go @@ -4,6 +4,7 @@ package events import ( "bytes" + "context" _ "embed" "log" "os" @@ -20,12 +21,12 @@ import ( var monitorScript string // Main process monitoring function for Windows version -func (e *EventController) MonitorProcesses(logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) { - e.startProcessMonitor(logger, programs) +func (e *EventController) MonitorProcesses(ctx context.Context, logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) { + e.startProcessMonitor(ctx, logger, programs) } // Runs the powershell WMI script, to monitor process events -func (e *EventController) startProcessMonitor(logger *log.Logger, programs []string) { +func (e *EventController) startProcessMonitor(ctx context.Context, logger *log.Logger, programs []string) { programList := strings.Join(programs, ",") scriptTempDir := filepath.Join("C:\\", "ProgramData", "TimeKeep", "scripts_temp") @@ -58,7 +59,7 @@ func (e *EventController) startProcessMonitor(logger *log.Logger, programs []str time.Sleep(100 * time.Millisecond) // Pause to allow tempfile to finish writing before it attempts to execute args := []string{"-ExecutionPolicy", "Bypass", "-File", tempFile.Name(), "-Programs", programList} - cmd := exec.Command("powershell", args...) + cmd := exec.CommandContext(ctx, "powershell", args...) e.PsProcess = cmd var stderr bytes.Buffer @@ -77,6 +78,13 @@ func (e *EventController) startProcessMonitor(logger *log.Logger, programs []str defer os.Remove(tempFile.Name()) err := cmd.Wait() + + select { + case <-ctx.Done(): + logger.Println("INFO: PowerShell monitor stopped due to context cancellation") + return + default: + } if err != nil { logger.Printf("ERROR: PowerShell monitor process exited with error: %s", err) } else { diff --git a/cmd/service/internal/sessions/sessions.go b/cmd/service/internal/sessions/sessions.go index 97d0ce8..91fafe9 100644 --- a/cmd/service/internal/sessions/sessions.go +++ b/cmd/service/internal/sessions/sessions.go @@ -44,7 +44,7 @@ func (sm *SessionManager) EnsureProgram(name, category string) { // If no process is running with given name, will create a new active session in database. // If there is already a process running with given name, new PID will be added to active session -func (sm *SessionManager) CreateSession(logger *log.Logger, a repository.ActiveRepository, processName string, pid int) { +func (sm *SessionManager) CreateSession(ctx context.Context, logger *log.Logger, a repository.ActiveRepository, processName string, pid int) { sm.Mu.Lock() t := sm.Programs[processName] @@ -71,7 +71,7 @@ func (sm *SessionManager) CreateSession(logger *log.Logger, a repository.ActiveR if len(t.PIDs) == 1 { params := database.CreateActiveSessionParams{ProgramName: processName, StartTime: now} - if err := a.CreateActiveSession(context.Background(), params); err != nil { + if err := a.CreateActiveSession(ctx, params); err != nil { logger.Printf("ERROR: creating active session for %s: %v", processName, err) return } @@ -83,7 +83,7 @@ func (sm *SessionManager) CreateSession(logger *log.Logger, a repository.ActiveR // Removes PID from sessions map, if there are still processes running with given name, session will not end. // If last process for given name ends, the active session is terminated, and session is moved into session history. -func (sm *SessionManager) EndSession(logger *log.Logger, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, processName string, pid int) { +func (sm *SessionManager) EndSession(ctx context.Context, logger *log.Logger, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, processName string, pid int) { sm.Mu.Lock() t, ok := sm.Programs[processName] @@ -106,13 +106,13 @@ func (sm *SessionManager) EndSession(logger *log.Logger, pr repository.ProgramRe sm.Mu.Unlock() if len(t.PIDs) == 0 { - sm.MoveSessionToHistory(logger, pr, a, h, processName) + sm.MoveSessionToHistory(ctx, logger, pr, a, h, processName) } } // Takes an active session and moves it into session history, ending active status -func (sm *SessionManager) MoveSessionToHistory(logger *log.Logger, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, processName string) { - startTime, err := a.GetActiveSession(context.Background(), processName) +func (sm *SessionManager) MoveSessionToHistory(ctx context.Context, logger *log.Logger, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, processName string) { + startTime, err := a.GetActiveSession(ctx, processName) if err != nil { logger.Printf("ERROR: Error getting active session from database: %s", err) return @@ -126,13 +126,13 @@ func (sm *SessionManager) MoveSessionToHistory(logger *log.Logger, pr repository EndTime: endTime, DurationSeconds: duration, } - err = h.AddToSessionHistory(context.Background(), archivedSession) + err = h.AddToSessionHistory(ctx, archivedSession) if err != nil { logger.Printf("ERROR: Error creating session history for %s: %s", processName, err) return } - err = pr.UpdateLifetime(context.Background(), database.UpdateLifetimeParams{ + err = pr.UpdateLifetime(ctx, database.UpdateLifetimeParams{ Name: processName, LifetimeSeconds: duration, }) @@ -140,7 +140,7 @@ func (sm *SessionManager) MoveSessionToHistory(logger *log.Logger, pr repository logger.Printf("ERROR: Error updating lifetime for %s: %s", processName, err) } - err = a.RemoveActiveSession(context.Background(), processName) + err = a.RemoveActiveSession(ctx, processName) if err != nil { logger.Printf("ERROR: Error removing active session for %s: %s", processName, err) } diff --git a/cmd/service/internal/transport/transport_linux.go b/cmd/service/internal/transport/transport_linux.go index e38c293..81ba170 100644 --- a/cmd/service/internal/transport/transport_linux.go +++ b/cmd/service/internal/transport/transport_linux.go @@ -3,6 +3,7 @@ package transport import ( + "context" "log" "net" "os" @@ -12,7 +13,7 @@ import ( "github.com/jms-guy/timekeep/internal/repository" ) -func (t *Transporter) Listen(logger *log.Logger, eventCtrl *events.EventController, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository) { +func (t *Transporter) Listen(ctx context.Context, logger *log.Logger, eventCtrl *events.EventController, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository) { socketDir := "/var/run/timekeep" socketName := socketDir + "/timekeep.sock" @@ -40,7 +41,8 @@ func (t *Transporter) Listen(logger *log.Logger, eventCtrl *events.EventControll for { select { - case <-t.Shutdown: + case <-ctx.Done(): + logger.Println("INFO: Closing socket connection") return default: conn, err := listener.Accept() @@ -48,7 +50,7 @@ func (t *Transporter) Listen(logger *log.Logger, eventCtrl *events.EventControll logger.Printf("ERROR: Failed to accept connection: %s", err) continue } - go eventCtrl.HandleConnection(logger, s, pr, a, h, conn) + go eventCtrl.HandleConnection(ctx, logger, s, pr, a, h, conn) } } } diff --git a/cmd/service/internal/transport/transport_unsupported.go b/cmd/service/internal/transport/transport_unsupported.go index abee0b3..3933663 100644 --- a/cmd/service/internal/transport/transport_unsupported.go +++ b/cmd/service/internal/transport/transport_unsupported.go @@ -3,6 +3,7 @@ package transport import ( + "context" "log" "github.com/jms-guy/timekeep/cmd/service/internal/events" @@ -10,6 +11,6 @@ import ( "github.com/jms-guy/timekeep/internal/repository" ) -func (t *Transporter) Listen(logger *log.Logger, eventCtrl *events.EventController, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository) { +func (t *Transporter) Listen(ctx context.Context, logger *log.Logger, eventCtrl *events.EventController, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository) { return } diff --git a/cmd/service/internal/transport/transport_windows.go b/cmd/service/internal/transport/transport_windows.go index ec9861a..d33f169 100644 --- a/cmd/service/internal/transport/transport_windows.go +++ b/cmd/service/internal/transport/transport_windows.go @@ -3,6 +3,7 @@ package transport import ( + "context" "log" "github.com/Microsoft/go-winio" @@ -12,7 +13,7 @@ import ( ) // Opens a Windows named pipe connection, to listen for commands -func (t *Transporter) Listen(logger *log.Logger, eventCtrl *events.EventController, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository) { +func (t *Transporter) Listen(ctx context.Context, logger *log.Logger, eventCtrl *events.EventController, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository) { pipeName := "\\\\.\\pipe\\Timekeep" pipe, err := winio.ListenPipe(pipeName, nil) @@ -24,7 +25,8 @@ func (t *Transporter) Listen(logger *log.Logger, eventCtrl *events.EventControll for { select { - case <-t.Shutdown: + case <-ctx.Done(): + logger.Println("INFO: Stopping pipe listener") return default: conn, err := pipe.Accept() @@ -32,7 +34,7 @@ func (t *Transporter) Listen(logger *log.Logger, eventCtrl *events.EventControll logger.Printf("ERROR: Failed to accept connection: %s", err) continue } - go eventCtrl.HandleConnection(logger, s, pr, a, h, conn) + go eventCtrl.HandleConnection(ctx, logger, s, pr, a, h, conn) } } } diff --git a/cmd/service/internal/transport/transporter.go b/cmd/service/internal/transport/transporter.go index 2721466..9da86f4 100644 --- a/cmd/service/internal/transport/transporter.go +++ b/cmd/service/internal/transport/transporter.go @@ -1,9 +1,7 @@ package transport -type Transporter struct { - Shutdown chan struct{} -} +type Transporter struct{} func NewTransporter() *Transporter { - return &Transporter{Shutdown: make(chan struct{})} + return &Transporter{} } diff --git a/cmd/service/service_linux.go b/cmd/service/service_linux.go index f1bb8fd..997c5a1 100644 --- a/cmd/service/service_linux.go +++ b/cmd/service/service_linux.go @@ -47,33 +47,38 @@ func (s *timekeepService) Manage() (string, error) { } } - programs, err := s.prRepo.GetAllProgramNames(context.Background()) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + programs, err := s.prRepo.GetAllPrograms(ctx) if err != nil { return "ERROR: Failed to get programs", err } if len(programs) > 0 { + toTrack := []string{} for _, program := range programs { - s.sessions.EnsureProgram(program) + category := "" + if program.Category.Valid { + category = program.Category.String + } + s.sessions.EnsureProgram(program.Name, category) + + toTrack = append(toTrack, program.Name) } - go s.eventCtrl.MonitorProcesses(s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, programs) + + go s.eventCtrl.MonitorProcesses(ctx, s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, toTrack) } if s.eventCtrl.Config.WakaTime.Enabled { - s.eventCtrl.StartHeartbeats(s.sessions) + s.eventCtrl.StartHeartbeats(ctx, s.logger.Logger, s.sessions) } - go s.transport.Listen(s.logger.Logger, s.eventCtrl, s.sessions, s.prRepo, s.asRepo, s.hsRepo) + go s.transport.Listen(ctx, s.logger.Logger, s.eventCtrl, s.sessions, s.prRepo, s.asRepo, s.hsRepo) - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) + <-ctx.Done() - killSignal := <-interrupt - s.logger.Logger.Printf("Got signal: %v", killSignal) - s.closeService() - - if killSignal == os.Interrupt { - return "INFO: Daemon was interrupted by system signal.", nil - } + s.logger.Logger.Println("INFO: Received shutdown signal") + s.closeService(ctx) - return "INFO: Daemon was killed.", nil + return "INFO: Daemon stopped.", nil } diff --git a/cmd/service/service_setup.go b/cmd/service/service_setup.go index 63c81c7..3b6029c 100644 --- a/cmd/service/service_setup.go +++ b/cmd/service/service_setup.go @@ -1,6 +1,8 @@ package main import ( + "context" + "github.com/jms-guy/timekeep/cmd/service/internal/daemons" "github.com/jms-guy/timekeep/cmd/service/internal/events" "github.com/jms-guy/timekeep/cmd/service/internal/logs" @@ -89,18 +91,17 @@ func NewTimekeepService(pr repository.ProgramRepository, ar repository.ActiveRep } // Service shutdown function -func (s *timekeepService) closeService() { - if s.eventCtrl.Config.WakaTime.Enabled { +func (s *timekeepService) closeService(ctx context.Context) { + if s.eventCtrl.Config.WakaTime.Enabled { // Stop WakaTime heartbeats s.eventCtrl.StopHeartbeats() } s.eventCtrl.StopProcessMonitor() // Stop any current monitoring function - close(s.transport.Shutdown) // Close open IPC s.logger.FileCleanup() // Close open logging file s.sessions.Mu.Lock() for program, tracked := range s.sessions.Programs { // End any active sessions if len(tracked.PIDs) != 0 { - s.sessions.MoveSessionToHistory(s.logger.Logger, s.prRepo, s.asRepo, s.hsRepo, program) + s.sessions.MoveSessionToHistory(ctx, s.logger.Logger, s.prRepo, s.asRepo, s.hsRepo, program) } } s.sessions.Mu.Unlock() diff --git a/cmd/service/service_windows.go b/cmd/service/service_windows.go index 36c15e7..e59cf91 100644 --- a/cmd/service/service_windows.go +++ b/cmd/service/service_windows.go @@ -41,7 +41,10 @@ func (s *timekeepService) Execute(args []string, r <-chan svc.ChangeRequest, sta status <- svc.Status{State: svc.StartPending} - programs, err := s.prRepo.GetAllPrograms(context.Background()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + programs, err := s.prRepo.GetAllPrograms(ctx) if err != nil { s.logger.Logger.Printf("ERROR: Failed to get programs: %s", err) status <- svc.Status{State: svc.Stopped} @@ -59,14 +62,14 @@ func (s *timekeepService) Execute(args []string, r <-chan svc.ChangeRequest, sta toTrack = append(toTrack, program.Name) } - s.eventCtrl.MonitorProcesses(s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, toTrack) + s.eventCtrl.MonitorProcesses(ctx, s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, toTrack) } if s.eventCtrl.Config.WakaTime.Enabled { - s.eventCtrl.StartHeartbeats(s.sessions) + s.eventCtrl.StartHeartbeats(ctx, s.logger.Logger, s.sessions) } - go s.transport.Listen(s.logger.Logger, s.eventCtrl, s.sessions, s.prRepo, s.asRepo, s.hsRepo) + go s.transport.Listen(ctx, s.logger.Logger, s.eventCtrl, s.sessions, s.prRepo, s.asRepo, s.hsRepo) status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} @@ -78,10 +81,13 @@ loop: switch c.Cmd { case svc.Interrogate: // Check current status of service status <- c.CurrentStatus + case svc.Stop, svc.Shutdown: // Service needs to be stopped or shutdown s.logger.Logger.Println("INFO: Received stop signal") - s.closeService() + cancel() + s.closeService(ctx) break loop + case svc.Pause: // Service needs to be paused, without shutdown s.logger.Logger.Println("INFO: Pausing service") if s.eventCtrl.Config.WakaTime.Enabled { @@ -89,13 +95,15 @@ loop: } s.eventCtrl.StopProcessMonitor() status <- svc.Status{State: svc.Paused, Accepts: cmdsAccepted} + case svc.Continue: // Resume paused execution state of service s.logger.Logger.Println("INFO: Resuming service") - s.eventCtrl.RefreshProcessMonitor(s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo) + s.eventCtrl.RefreshProcessMonitor(ctx, s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo) if s.eventCtrl.Config.WakaTime.Enabled { - s.eventCtrl.StartHeartbeats(s.sessions) + s.eventCtrl.StartHeartbeats(ctx, s.logger.Logger, s.sessions) } status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + default: s.logger.Logger.Printf("ERROR: Unexpected service control request #%d", c) } @@ -103,5 +111,6 @@ loop: } status <- svc.Status{State: svc.StopPending} + return false, 0 } From aafd86b86342ffd3b09aac7c963ab48474f02713 Mon Sep 17 00:00:00 2001 From: jms-guy Date: Wed, 8 Oct 2025 13:40:23 -0400 Subject: [PATCH 07/10] base wakatime heartbeat functions complete, insert version into service --- .github/workflows/CD.yml | 4 +-- .../internal/events/event_controller.go | 5 ++- .../internal/events/events_wakatime.go | 33 ++++++++++++++----- sql/schema/001_tracked_programs.sql | 1 - .../004_tracked_programs_category_add.sql | 7 ++++ 5 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 sql/schema/004_tracked_programs_category_add.sql diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 099e02c..39c961e 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -15,8 +15,8 @@ jobs: - name: Build binaries run: | # Service binaries - GOOS=windows go build -o timekeep-service.exe ./cmd/service - GOOS=linux go build -o timekeepd ./cmd/service + GOOS=windows go build -ldflags "-X github.com/jms-guy/timekeep/cmd/service/internal/events.Version=${{ github.ref_name }}" -o timekeep-service.exe ./cmd/service + GOOS=linux go build -ldflags "-X github.com/jms-guy/timekeep/cmd/service/internal/events.Version=${{ github.ref_name }}" -o timekeepd ./cmd/service # CLI binaries GOOS=windows go build -ldflags "-X main.Version=${{ github.ref_name }}" -o timekeep.exe ./cmd/cli diff --git a/cmd/service/internal/events/event_controller.go b/cmd/service/internal/events/event_controller.go index 490fba9..7c084d3 100644 --- a/cmd/service/internal/events/event_controller.go +++ b/cmd/service/internal/events/event_controller.go @@ -16,6 +16,8 @@ import ( "github.com/jms-guy/timekeep/internal/repository" ) +var Version = "dev" + // Command details communicated by pipe type Command struct { Action string `json:"action"` @@ -29,10 +31,11 @@ type EventController struct { Config *config.Config // Struct built from config file wakaHeartbeatTicker *time.Ticker // Ticker for WakaTime enabled heartbeats heartbeatMu sync.Mutex // Mutex for WakaTime heartbeat ticker + version string // Timekeep version } func NewEventController() *EventController { - return &EventController{} + return &EventController{version: Version} } // Handles service commands read from pipe/socket connection diff --git a/cmd/service/internal/events/events_wakatime.go b/cmd/service/internal/events/events_wakatime.go index 804248a..0300d47 100644 --- a/cmd/service/internal/events/events_wakatime.go +++ b/cmd/service/internal/events/events_wakatime.go @@ -12,10 +12,20 @@ import ( // Start WakaTime heartbeat ticker func (e *EventController) StartHeartbeats(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager) { + e.heartbeatMu.Lock() e.wakaHeartbeatTicker = time.NewTicker(1 * time.Minute) + ticker := e.wakaHeartbeatTicker + e.heartbeatMu.Unlock() go func() { - defer e.wakaHeartbeatTicker.Stop() + defer func() { + e.heartbeatMu.Lock() + if e.wakaHeartbeatTicker != nil { + e.wakaHeartbeatTicker.Stop() + e.wakaHeartbeatTicker = nil + } + e.heartbeatMu.Unlock() + }() errorCount := 0 for { @@ -24,7 +34,7 @@ func (e *EventController) StartHeartbeats(ctx context.Context, logger *log.Logge logger.Println("INFO: Stopping WakaTime heartbeats") return - case <-e.wakaHeartbeatTicker.C: + case <-ticker.C: if errorCount >= 5 { logger.Println("ERROR: WakaTime heartbeats failed 5 times consecutively, stopping") return @@ -49,8 +59,10 @@ func (e *EventController) sendHeartbeats(ctx context.Context, sm *sessions.Sessi for program, tracked := range sm.Programs { if len(tracked.PIDs) > 0 { - if err := e.sendWakaHeartbeat(ctx, program, tracked.Category); err != nil { - return err + if tracked.Category != "" { + if err := e.sendWakaHeartbeat(ctx, program, tracked.Category); err != nil { + return err + } } } } @@ -60,12 +72,15 @@ func (e *EventController) sendHeartbeats(ctx context.Context, sm *sessions.Sessi // Call the wakatime-cli heartbeat command func (e *EventController) sendWakaHeartbeat(ctx context.Context, program, category string) error { - cmd := exec.CommandContext(ctx, "wakatime-cli", "--entity", program, "--category", category, "--time", fmt.Sprintf("%d", time.Now().Unix())) - if err := cmd.Run(); err != nil { - return err - } + cmd := exec.CommandContext(ctx, + "wakatime-cli", + "--entity", program, + "--entity-type", "app", + "--plugin", "timekeep/"+e.version, + "--category", category, + "--time", fmt.Sprintf("%d", time.Now().Unix())) - return nil + return cmd.Run() } // Stops WakaTime heartbeat ticker after disabling integration diff --git a/sql/schema/001_tracked_programs.sql b/sql/schema/001_tracked_programs.sql index 60e748b..20deca1 100644 --- a/sql/schema/001_tracked_programs.sql +++ b/sql/schema/001_tracked_programs.sql @@ -2,7 +2,6 @@ CREATE TABLE tracked_programs ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, - category TEXT, lifetime_seconds INTEGER NOT NULL DEFAULT 0 ); diff --git a/sql/schema/004_tracked_programs_category_add.sql b/sql/schema/004_tracked_programs_category_add.sql new file mode 100644 index 0000000..1ba0a3d --- /dev/null +++ b/sql/schema/004_tracked_programs_category_add.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE tracked_programs +ADD category TEXT; + +-- +goose Down +ALTER TABLE tracked_programs +DROP COLUMN category; \ No newline at end of file From e21da968b4b957cdb12a2c02c0406368fb49194c Mon Sep 17 00:00:00 2001 From: jms-guy Date: Thu, 9 Oct 2025 13:50:04 -0400 Subject: [PATCH 08/10] working out waka integration, context bug --- cmd/cli/commands.go | 69 +++---------------- cmd/cli/commands_helpers.go | 11 +++ cmd/cli/root.go | 2 +- .../internal/events/events_wakatime.go | 48 ++++++++++--- internal/config/config.go | 2 +- 5 files changed, 59 insertions(+), 73 deletions(-) diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 5f3bab5..f6800a5 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -12,8 +12,6 @@ import ( // Adds programs into the database, and sends communication to service to being tracking them func (s *CLIService) AddPrograms(ctx context.Context, args []string, category string) error { - var addedPrograms []string - categoryNull := sql.NullString{ String: category, Valid: category != "", @@ -27,8 +25,6 @@ func (s *CLIService) AddPrograms(ctx context.Context, args []string, category st if err != nil { return fmt.Errorf("error adding program %s: %w", program, err) } - - addedPrograms = append(addedPrograms, program) } err := s.ServiceCmd.WriteToService() @@ -36,7 +32,6 @@ func (s *CLIService) AddPrograms(ctx context.Context, args []string, category st return fmt.Errorf("programs added but failed to notify service: %w", err) } - fmt.Printf("Added %d program(s) to track\n", len(addedPrograms)) return nil } @@ -53,17 +48,14 @@ func (s *CLIService) RemovePrograms(ctx context.Context, args []string, all bool return fmt.Errorf("error alerting service of program removal: %w", err) } - fmt.Println("All programs removed from tracking") return nil } - var removedPrograms []string for _, program := range args { err := s.PrRepo.RemoveProgram(ctx, strings.ToLower(program)) if err != nil { return fmt.Errorf("error removing program %s: %w", program, err) } - removedPrograms = append(removedPrograms, program) } err := s.ServiceCmd.WriteToService() @@ -71,7 +63,6 @@ func (s *CLIService) RemovePrograms(ctx context.Context, args []string, all bool return fmt.Errorf("programs removed but failed to notify service: %w", err) } - fmt.Printf("Removed %d program(s) from tracking\n", len(removedPrograms)) return nil } @@ -83,11 +74,9 @@ func (s *CLIService) GetList(ctx context.Context) error { } if len(programs) == 0 { - fmt.Println("No programs are currently being tracked") return nil } - fmt.Println("Programs currently being tracked:") for _, program := range programs { fmt.Printf(" • %s\n", program) } @@ -103,7 +92,6 @@ func (s *CLIService) GetAllInfo(ctx context.Context) error { } if len(programs) == 0 { - fmt.Println("No programs are currently being tracked") return nil } @@ -136,7 +124,6 @@ func (s *CLIService) GetInfo(ctx context.Context, args []string) error { lastSession, err := s.HsRepo.GetLastSessionForProgram(ctx, program.Name) if err != nil { if err == sql.ErrNoRows { - fmt.Printf("Statistics for %s:\n", program.Name) s.formatDuration(" • Current Lifetime: ", duration) fmt.Printf(" • Total sessions to date: 0\n") fmt.Printf(" • Last Session: No sessions recorded yet\n") @@ -151,7 +138,6 @@ func (s *CLIService) GetInfo(ctx context.Context, args []string) error { return fmt.Errorf("error getting history count for %s: %w", program.Name, err) } - fmt.Printf("Statistics for %s:\n", program.Name) s.formatDuration(" • Current Lifetime: ", duration) fmt.Printf(" • Total sessions to date: %d\n", sessionCount) @@ -194,16 +180,9 @@ func (s *CLIService) GetSessionHistory(ctx context.Context, args []string, date, } if len(history) == 0 { - fmt.Println("No session history present") return nil } - if programName != "" { - fmt.Printf("Session history for %s: \n", programName) - } else { - fmt.Println("Session history: ") - } - for _, session := range history { printSession(session) } @@ -218,7 +197,6 @@ func (s *CLIService) ResetStats(ctx context.Context, args []string, all bool) er if err != nil { return err } - fmt.Println("All session records reset") } else { if len(args) == 0 { @@ -233,12 +211,11 @@ func (s *CLIService) ResetStats(ctx context.Context, args []string, all bool) er } } - fmt.Printf("Session records for %d programs reset\n", len(args)) } err := s.ServiceCmd.WriteToService() if err != nil { - fmt.Printf("Warning: Failed to notify service of reset: %v\n", err) + fmt.Printf("Warning: Failed to notify service: %v\n", err) } return nil @@ -289,11 +266,9 @@ func (s *CLIService) GetActiveSessions(ctx context.Context) error { return fmt.Errorf("error getting active sessions: %w", err) } if len(activeSessions) == 0 { - fmt.Println("No active sessions.") return nil } - fmt.Println("Active sessions: ") for _, session := range activeSessions { duration := time.Since(session.StartTime) sessionDetails := fmt.Sprintf(" • %s - ", session.ProgramName) @@ -313,65 +288,37 @@ func (s *CLIService) GetVersion() error { // Changes config to enable WakaTime with API key func (s *CLIService) EnableWakaTime(apiKey string) error { if s.Config.WakaTime.Enabled { - fmt.Println("WakaTime integration already enabled") return nil } if apiKey != "" { s.Config.WakaTime.APIKey = apiKey - s.Config.WakaTime.Enabled = true - - err := s.Config.Save() - if err != nil { - return err - } - err = s.ServiceCmd.WriteToService() - if err != nil { - return err - } - - fmt.Println("WakaTime integration enabled") - return nil } - if s.Config.WakaTime.APIKey != "" { - s.Config.WakaTime.Enabled = true + if s.Config.WakaTime.APIKey == "" { + return fmt.Errorf("WakaTime API key required. Use: timekeep wakatime enable --api-key ") + } - err := s.Config.Save() - if err != nil { - return err - } - err = s.ServiceCmd.WriteToService() - if err != nil { - return err - } + s.Config.WakaTime.Enabled = true - fmt.Println("WakaTime integration enabled") - return nil + if err := s.saveAndNotify(); err != nil { + return err } - fmt.Println("User must provide a WakaTime API key") return nil } // Disables WakaTime in config func (s *CLIService) DisableWakaTime() error { if !s.Config.WakaTime.Enabled { - fmt.Println("WakaTime integration disabled") return nil } s.Config.WakaTime.Enabled = false - err := s.Config.Save() - if err != nil { - return err - } - err = s.ServiceCmd.WriteToService() - if err != nil { + if err := s.saveAndNotify(); err != nil { return err } - fmt.Println("WakaTime integration disabled") return nil } diff --git a/cmd/cli/commands_helpers.go b/cmd/cli/commands_helpers.go index 35169cd..1d83c50 100644 --- a/cmd/cli/commands_helpers.go +++ b/cmd/cli/commands_helpers.go @@ -141,3 +141,14 @@ func printSession(session database.SessionHistory) { fmt.Printf("%dh %dm\n", hours, minutes) } } + +// Helper to save config and send refresh command to service +func (s *CLIService) saveAndNotify() error { + if err := s.Config.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + if err := s.ServiceCmd.WriteToService(); err != nil { + return fmt.Errorf("config saved but failed to notify service: %w", err) + } + return nil +} diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 7e3de0f..62e138c 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -14,7 +14,7 @@ import ( func (s *CLIService) RootCmd() *cobra.Command { rootCmd := &cobra.Command{ Use: "timekeep", - Short: "Timekeep is a process tracking service", + Short: "Timekeep is a process activity tracker", Run: func(cmd *cobra.Command, args []string) { }, } diff --git a/cmd/service/internal/events/events_wakatime.go b/cmd/service/internal/events/events_wakatime.go index 0300d47..3d2bc8a 100644 --- a/cmd/service/internal/events/events_wakatime.go +++ b/cmd/service/internal/events/events_wakatime.go @@ -1,10 +1,13 @@ package events import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "log" - "os/exec" + "net/http" "time" "github.com/jms-guy/timekeep/cmd/service/internal/sessions" @@ -17,6 +20,8 @@ func (e *EventController) StartHeartbeats(ctx context.Context, logger *log.Logge ticker := e.wakaHeartbeatTicker e.heartbeatMu.Unlock() + logger.Println("INFO: Starting WakaTime heartbeats") + go func() { defer func() { e.heartbeatMu.Lock() @@ -72,15 +77,38 @@ func (e *EventController) sendHeartbeats(ctx context.Context, sm *sessions.Sessi // Call the wakatime-cli heartbeat command func (e *EventController) sendWakaHeartbeat(ctx context.Context, program, category string) error { - cmd := exec.CommandContext(ctx, - "wakatime-cli", - "--entity", program, - "--entity-type", "app", - "--plugin", "timekeep/"+e.version, - "--category", category, - "--time", fmt.Sprintf("%d", time.Now().Unix())) - - return cmd.Run() + heartbeat := map[string]interface{}{ + "entity": program, + "type": "app", + "category": category, + "time": float64(time.Now().Unix()), + } + + body, _ := json.Marshal([]map[string]interface{}{heartbeat}) + + req, err := http.NewRequestWithContext(ctx, "POST", + "https://api.wakatime.com/api/v1/users/current/heartbeats.bulk", + bytes.NewBuffer(body)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+e.Config.WakaTime.APIKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("wakatime API error (status %d): %s", resp.StatusCode, string(bodyBytes)) + } + + return nil } // Stops WakaTime heartbeat ticker after disabling integration diff --git a/internal/config/config.go b/internal/config/config.go index 57b7c50..a8fbbc8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,7 +35,7 @@ func Load() (*Config, error) { } if _, err := os.Stat(configFile); os.IsNotExist(err) { - err := os.WriteFile(configFile, []byte(defaultConfig), 0o644) + err := os.WriteFile(configFile, []byte(defaultConfig), 0o600) if err != nil { return nil, fmt.Errorf("error generating default config: %w", err) } From ef02892bb9fb12e4cef523a4bbd8b1d4e320bf27 Mon Sep 17 00:00:00 2001 From: jms-guy Date: Fri, 10 Oct 2025 14:35:03 -0400 Subject: [PATCH 09/10] waka heartbeats working --- README.md | 5 +- cmd/cli/commands.go | 12 +++++ cmd/cli/root.go | 1 + cmd/cli/timekeep.go | 12 +++++ .../internal/events/events_wakatime.go | 52 ++++++------------- cmd/service/internal/events/events_windows.go | 5 +- internal/config/config.go | 1 + 7 files changed, 47 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 209f056..67fcf75 100644 --- a/README.md +++ b/README.md @@ -38,16 +38,13 @@ A process activity tracker, it runs as a background service recording start/stop ```powershell timekeep add notepad.exe # Add notepad timekeep ls # List currently tracked programs -Programs currently being tracked: • notepad.exe timekeep info notepad.exe # Basic info for program sessions -Statistics for notepad.exe: • Current Lifetime: 19h 41m • Total sessions to date: 4 • Last Session: 2025-09-26 11:25 - 2025-09-26 11:26 (21 seconds) • Average session length: 4h 55m timekeep history notepad.exe # Session history for program -Session history for notepad.exe: notepad.exe | 2025-09-26 11:25 - 2025-09-26 11:26 | Duration: 21 seconds notepad.exe | 2025-09-24 13:49 - 2025-09-24 13:50 | Duration: 39 seconds notepad.exe | 2025-09-23 11:18 - 2025-09-23 11:19 | Duration: 56 seconds @@ -79,7 +76,7 @@ GOOS=windows go build -o timekeep-service.exe ./cmd/service GOOS=windows go build -o timekeep.exe ./cmd/cli # Install and start service (Run as Administrator) -sc.exe create timekeep binPath= "C:\Program Files\Timekeep\timekeep-service.exe" start= auto +sc.exe create timekeep binPath= "C:\Program Files\Timekeep\timekeep-service.exe" start= auto # Assuming this is the location of service binary sc.exe start timekeep # Verify service is running diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index f6800a5..508b929 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -322,3 +322,15 @@ func (s *CLIService) DisableWakaTime() error { return nil } + +// Sets wakatime-cli file path +func (s *CLIService) SetCLIPath(args []string) error { + newPath := args[0] + s.Config.WakaTime.CLIPath = newPath + + if err := s.saveAndNotify(); err != nil { + return err + } + + return nil +} diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 62e138c..2018b12 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -22,6 +22,7 @@ func (s *CLIService) RootCmd() *cobra.Command { wCmd := s.wakatimeIntegration() wCmd.AddCommand(s.wakatimeEnable()) wCmd.AddCommand(s.wakatimeDisable()) + wCmd.AddCommand(s.wakatimeSetCLIPath()) rootCmd.AddCommand(wCmd) rootCmd.AddCommand(s.addProgramsCmd()) diff --git a/cmd/cli/timekeep.go b/cmd/cli/timekeep.go index 342bfce..93ce3e0 100644 --- a/cmd/cli/timekeep.go +++ b/cmd/cli/timekeep.go @@ -219,3 +219,15 @@ func (s *CLIService) wakatimeDisable() *cobra.Command { }, } } + +func (s *CLIService) wakatimeSetCLIPath() *cobra.Command { + return &cobra.Command{ + Use: "set-path", + Aliases: []string{"SET-PATH"}, + Short: "Set absolute path for wakatime-cli", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return s.SetCLIPath(args) + }, + } +} diff --git a/cmd/service/internal/events/events_wakatime.go b/cmd/service/internal/events/events_wakatime.go index 3d2bc8a..91ec4f0 100644 --- a/cmd/service/internal/events/events_wakatime.go +++ b/cmd/service/internal/events/events_wakatime.go @@ -1,13 +1,10 @@ package events import ( - "bytes" "context" - "encoding/json" "fmt" - "io" "log" - "net/http" + "os/exec" "time" "github.com/jms-guy/timekeep/cmd/service/internal/sessions" @@ -45,7 +42,7 @@ func (e *EventController) StartHeartbeats(ctx context.Context, logger *log.Logge return } - if err := e.sendHeartbeats(ctx, sm); err != nil { + if err := e.sendHeartbeats(ctx, logger, sm); err != nil { logger.Printf("ERROR: Failed to send WakaTime heartbeat: %s", err) errorCount++ continue @@ -58,14 +55,14 @@ func (e *EventController) StartHeartbeats(ctx context.Context, logger *log.Logge } // Send specified heartbeats to WakaTime -func (e *EventController) sendHeartbeats(ctx context.Context, sm *sessions.SessionManager) error { +func (e *EventController) sendHeartbeats(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager) error { sm.Mu.Lock() defer sm.Mu.Unlock() for program, tracked := range sm.Programs { if len(tracked.PIDs) > 0 { if tracked.Category != "" { - if err := e.sendWakaHeartbeat(ctx, program, tracked.Category); err != nil { + if err := e.sendWakaHeartbeat(ctx, logger, program, tracked.Category); err != nil { return err } } @@ -76,39 +73,24 @@ func (e *EventController) sendHeartbeats(ctx context.Context, sm *sessions.Sessi } // Call the wakatime-cli heartbeat command -func (e *EventController) sendWakaHeartbeat(ctx context.Context, program, category string) error { - heartbeat := map[string]interface{}{ - "entity": program, - "type": "app", - "category": category, - "time": float64(time.Now().Unix()), - } - - body, _ := json.Marshal([]map[string]interface{}{heartbeat}) - - req, err := http.NewRequestWithContext(ctx, "POST", - "https://api.wakatime.com/api/v1/users/current/heartbeats.bulk", - bytes.NewBuffer(body)) - if err != nil { - return err - } - - req.Header.Set("Authorization", "Bearer "+e.Config.WakaTime.APIKey) - req.Header.Set("Content-Type", "application/json") +func (e *EventController) sendWakaHeartbeat(ctx context.Context, logger *log.Logger, program, category string) error { + cliPath := e.Config.WakaTime.CLIPath - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Do(req) - if err != nil { - return err + if cliPath == "" { + logger.Println("ERROR: wakatime-cli path not set") } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("wakatime API error (status %d): %s", resp.StatusCode, string(bodyBytes)) + args := []string{ + "--key", e.Config.WakaTime.APIKey, + "--entity", program, + "--entity-type", "app", + "--plugin", "timekeep/" + e.version, + "--category", category, + "--time", fmt.Sprintf("%d", time.Now().Unix()), } - return nil + cmd := exec.CommandContext(ctx, cliPath, args...) + return cmd.Run() } // Stops WakaTime heartbeat ticker after disabling integration diff --git a/cmd/service/internal/events/events_windows.go b/cmd/service/internal/events/events_windows.go index 31fe212..99b6a7f 100644 --- a/cmd/service/internal/events/events_windows.go +++ b/cmd/service/internal/events/events_windows.go @@ -59,7 +59,7 @@ func (e *EventController) startProcessMonitor(ctx context.Context, logger *log.L time.Sleep(100 * time.Millisecond) // Pause to allow tempfile to finish writing before it attempts to execute args := []string{"-ExecutionPolicy", "Bypass", "-File", tempFile.Name(), "-Programs", programList} - cmd := exec.CommandContext(ctx, "powershell", args...) + cmd := exec.Command("powershell", args...) e.PsProcess = cmd var stderr bytes.Buffer @@ -81,10 +81,11 @@ func (e *EventController) startProcessMonitor(ctx context.Context, logger *log.L select { case <-ctx.Done(): - logger.Println("INFO: PowerShell monitor stopped due to context cancellation") + logger.Println("INFO: Powershell monitor stopped due to context cancellation") return default: } + if err != nil { logger.Printf("ERROR: PowerShell monitor process exited with error: %s", err) } else { diff --git a/internal/config/config.go b/internal/config/config.go index a8fbbc8..cbdd1c0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ type Config struct { type WakaTimeConfig struct { Enabled bool `json:"enabled"` APIKey string `json:"api_key,omitempty"` + CLIPath string `json:"cli_path,omitempty"` } const defaultConfig = `{ From 4736993c6c5591e0b1cea8c8d43d1950b6bb76fb Mon Sep 17 00:00:00 2001 From: jms-guy Date: Fri, 10 Oct 2025 15:37:14 -0400 Subject: [PATCH 10/10] updated readme + docs for wakatime --- README.md | 54 ++++++++++++++++++++++ cmd/cli/commands.go | 14 +++++- cmd/cli/timekeep.go | 4 +- cmd/service/internal/logs/logging_linux.go | 2 +- docs/commands.md | 41 +++++++++------- 5 files changed, 94 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 67fcf75..d7e9f7c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ A process activity tracker, it runs as a background service recording start/stop - [How It Works](#how-it-works) - [Usage](#usage) - [Installation](#installation) +- [WakaTime](#wakatime) +- [File Locations](#file-locations) - [Current Limitations](#current-limitations) - [To-Do](#to-do) - [Contributing & Issues](#contributing--issues) @@ -162,6 +164,58 @@ sudo rm /usr/local/bin/timekeepd /usr/local/bin/timekeep sudo systemctl daemon-reload ``` +## WakaTime +Timekeep now integrates with [WakaTime](https://wakatime.com), allowing users to track external program usage alongside their IDE and web-browsing stats. To enable WakaTime integration, users must: + 1. Have a WakaTime account + 2. Have [wakatime-cli](https://github.com/wakatime/wakatime-cli) installed on their machine + +Enable integration through timekeep. Set your WakaTime API key and wakatime-cli path either directly in the Timekeep [config](https://github.com/jms-guy/timekeep/blob/waka_integration/README.md#file-locations) file, or provide them through flags: +`timekeep wakatime enable --api-key "KEY" --set-path "PATH"` + +```json +{ + "wakatime": { + "enabled": true, + "api_key": "APIKEY", + "cli_path": "PATH" + } +} +``` + +**The wakatime-cli path must be an absolute path.** + +After enabling, wakatime-cli heartbeats will be sent containing tracking data for given programs. Note, that only programs added to Timekeep with a given category will have data sent to WakaTime. + +`timekeep add notepad.exe --category notes` + +If no category is set for a program, it will still be tracked locally, but no data for it will be sent out. + +List of categories accepted(defined [here](https://github.com/wakatime/wakatime-cli/blob/75ed1c3d905fc77a5039817458298c9ac44853a3/cmd/root.go#L74)): +```bash +"Category of this heartbeat activity. Can be \"coding\", \"ai coding\","+ + " \"building\", \"indexing\", \"debugging\", \"learning\", \"notes\","+ + " \"meeting\", \"planning\", \"researching\", \"communicating\", \"supporting\","+ + " \"advising\", \"running tests\", \"writing tests\", \"manual testing\","+ + " \"writing docs\", \"code reviewing\", \"browsing\","+ + " \"translating\", or \"designing\". +``` + +Disable integration with: +`timekeep wakatime disable` + +## File Locations +- **Logs** + - **Windows**: *C:\ProgramData\Timekeep\logs* + - **Linux**: */var/log/timekeep* + +- **Config** + - **Windows**: *C:\ProgramData\Timekeep\config* + - **Linux**: *~/.local/config/timekeep* + +- **Database** + - **Windows**: *C:\ProgramData\Timekeep* + - **Linux**: *~/.local/share/timekeep* + ## Current Limitations - Linux - Very short-lived processes can be missed by polling (poll interval currently default 1s) - Linux - Program basenames may collide (different binaries with same name are treated as same program) diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 508b929..3b03a69 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -124,6 +124,7 @@ func (s *CLIService) GetInfo(ctx context.Context, args []string) error { lastSession, err := s.HsRepo.GetLastSessionForProgram(ctx, program.Name) if err != nil { if err == sql.ErrNoRows { + fmt.Printf(" • Category: %s", program.Category.String) s.formatDuration(" • Current Lifetime: ", duration) fmt.Printf(" • Total sessions to date: 0\n") fmt.Printf(" • Last Session: No sessions recorded yet\n") @@ -138,6 +139,7 @@ func (s *CLIService) GetInfo(ctx context.Context, args []string) error { return fmt.Errorf("error getting history count for %s: %w", program.Name, err) } + fmt.Printf(" • Category: %s", program.Category.String) s.formatDuration(" • Current Lifetime: ", duration) fmt.Printf(" • Total sessions to date: %d\n", sessionCount) @@ -286,7 +288,7 @@ func (s *CLIService) GetVersion() error { } // Changes config to enable WakaTime with API key -func (s *CLIService) EnableWakaTime(apiKey string) error { +func (s *CLIService) EnableWakaTime(apiKey, path string) error { if s.Config.WakaTime.Enabled { return nil } @@ -296,7 +298,15 @@ func (s *CLIService) EnableWakaTime(apiKey string) error { } if s.Config.WakaTime.APIKey == "" { - return fmt.Errorf("WakaTime API key required. Use: timekeep wakatime enable --api-key ") + return fmt.Errorf("WakaTime API key required. Use flag: --api-key ") + } + + if path != "" { + s.Config.WakaTime.CLIPath = path + } + + if s.Config.WakaTime.CLIPath == "" { + return fmt.Errorf("wakatime-cli path required. Use flag: --set-path ") } s.Config.WakaTime.Enabled = true diff --git a/cmd/cli/timekeep.go b/cmd/cli/timekeep.go index 93ce3e0..2e8d6b9 100644 --- a/cmd/cli/timekeep.go +++ b/cmd/cli/timekeep.go @@ -199,12 +199,14 @@ func (s *CLIService) wakatimeEnable() *cobra.Command { Short: "Enable WakaTime integration", RunE: func(cmd *cobra.Command, args []string) error { apiKey, _ := cmd.Flags().GetString("api-key") + path, _ := cmd.Flags().GetString("set-path") - return s.EnableWakaTime(apiKey) + return s.EnableWakaTime(apiKey, path) }, } cmd.Flags().String("api-key", "", "User's WakaTime API key") + cmd.Flags().String("set-path", "", "Set absolute path for wakatime-cli") return cmd } diff --git a/cmd/service/internal/logs/logging_linux.go b/cmd/service/internal/logs/logging_linux.go index dd784a9..2bee6ce 100644 --- a/cmd/service/internal/logs/logging_linux.go +++ b/cmd/service/internal/logs/logging_linux.go @@ -6,6 +6,6 @@ import "path/filepath" // Get path for logging file func getLogPath() (string, error) { - logDir := "/tmp/Timekeep/logs" + logDir := "/var/log/timekeep/logs" return filepath.Join(logDir, "timekeep.log"), nil } diff --git a/docs/commands.md b/docs/commands.md index 6110e4e..885a09d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,25 +1,13 @@ ## Commands for CLI Use -- `add` - - Add a program to begin tracking. Add name of program's executable file name. May specify any number of programs to track in a single command, seperated by spaces in between - - `timekeep add notepad.exe`, `timekeep add notepad.exe code.exe chrome.exe` - -- `rm` - - Remove a program from tracking list. May specify any number of programs to remove in a single command, seperated by spaces in between. Takes `--all` flag to clear program list completely - - `timekeep rm notepad.exe`, `timekeep rm --all` - -- `ls` - - Lists programs being tracked by service - - `timekeep ls` - -- `info` - - Shows basic info for currently tracked programs. Accepts program name as argument to show in-depth stats for that program, else shows basic stats for all programs - - `timekeep info`, `timekeep info notepad.exe` - - `active` - Display list of current active sessions being tracked by service - `timekeep active` +- `add` + - Add a program to begin tracking. Add name of program's executable file name. May specify any number of programs to track in a single command, seperated by spaces in between + - `timekeep add notepad.exe`, `timekeep add notepad.exe code.exe chrome.exe` + - `history` - Shows session history, may take program name as argument to filter sessions shown - `timekeep history`, `timekeep history notepad.exe` @@ -30,6 +18,13 @@ - `end` (2006-01-02) - If flag is given alongside `start`, will filter sessions open up-to given date - `limit` (25) - Will specify number of sessions to show at one time. Default 25 +- `info` + - Shows basic info for currently tracked programs. Accepts program name as argument to show in-depth stats for that program, else shows basic stats for all programs + - `timekeep info`, `timekeep info notepad.exe` + +- `ls` + - Lists programs being tracked by service + - `timekeep ls` - `refresh` - Sends a manual refresh command to the service @@ -39,9 +34,21 @@ - Reset tracking stats for given programs. Accepts multiple arguments seperated by space. Takes `--all` flag to reset all stats - `timekeep reset notepad.exe`, `timekeep reset --all` +- `rm` + - Remove a program from tracking list. May specify any number of programs to remove in a single command, seperated by spaces in between. Takes `--all` flag to clear program list completely + - `timekeep rm notepad.exe`, `timekeep rm --all` + - `status` - Gets current state of Timekeep service - `timekeep ping` - `version` - - Returns version of Timekeep user is running \ No newline at end of file + - Returns version of Timekeep user is running + +- `wakatime [enable|disable|set-path]` + - Enable WakaTime integration with `timekeep wakatime enable` + - Flags: + - `--api-key "KEY"` - Set WakaTime API key + - `--set-path "PATH"` - Set wakatime-cli path(absolute) + - Disable integration with `timekeep wakatime disable` + - Set wakatime-cli path with command `timekeep wakatime set-path "PATH"` \ No newline at end of file