diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index ad3888a..39c961e 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -15,12 +15,12 @@ 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 -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/README.md b/README.md index 209f056..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) @@ -38,16 +40,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 +78,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 @@ -165,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/cli_setup.go b/cmd/cli/cli_setup.go index aebf14b..1706728 100644 --- a/cmd/cli/cli_setup.go +++ b/cmd/cli/cli_setup.go @@ -1,21 +1,23 @@ package main import ( + "github.com/jms-guy/timekeep/internal/config" "github.com/jms-guy/timekeep/internal/repository" mysql "github.com/jms-guy/timekeep/sql" ) +var Version = "dev" + type CLIService struct { PrRepo repository.ProgramRepository AsRepo repository.ActiveRepository HsRepo repository.HistoryRepository ServiceCmd ServiceCommander CmdExe CommandExecutor + Config *config.Config Version string } -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 +26,7 @@ func CreateCLIService(pr repository.ProgramRepository, ar repository.ActiveRepos HsRepo: hr, ServiceCmd: sc, CmdExe: cmdE, - Version: currentVersion, + Version: Version, } } @@ -38,6 +40,13 @@ func CLIServiceSetup() (*CLIService, error) { service := CreateCLIService(store, store, store, &realServiceCommander{}, &realCommandExecutor{}) + config, err := config.Load() + 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..3b03a69 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -11,14 +11,20 @@ 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 { - var addedPrograms []string +func (s *CLIService) AddPrograms(ctx context.Context, args []string, category string) error { + 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) } err := s.ServiceCmd.WriteToService() @@ -26,7 +32,6 @@ func (s *CLIService) AddPrograms(ctx context.Context, args []string) error { 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 } @@ -43,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() @@ -61,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 } @@ -73,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) } @@ -93,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 } @@ -126,7 +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("Statistics for %s:\n", program.Name) + 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") @@ -141,7 +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("Statistics for %s:\n", program.Name) + fmt.Printf(" • Category: %s", program.Category.String) s.formatDuration(" • Current Lifetime: ", duration) fmt.Printf(" • Total sessions to date: %d\n", sessionCount) @@ -184,16 +182,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) } @@ -208,7 +199,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 { @@ -223,12 +213,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 @@ -279,11 +268,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) @@ -299,3 +286,61 @@ func (s *CLIService) GetVersion() error { fmt.Println(s.Version) return nil } + +// Changes config to enable WakaTime with API key +func (s *CLIService) EnableWakaTime(apiKey, path string) error { + if s.Config.WakaTime.Enabled { + return nil + } + + if apiKey != "" { + s.Config.WakaTime.APIKey = apiKey + } + + if s.Config.WakaTime.APIKey == "" { + 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 + + if err := s.saveAndNotify(); err != nil { + return err + } + + return nil +} + +// Disables WakaTime in config +func (s *CLIService) DisableWakaTime() error { + if !s.Config.WakaTime.Enabled { + return nil + } + + s.Config.WakaTime.Enabled = false + + if err := s.saveAndNotify(); err != nil { + return err + } + + 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/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/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/cli/root.go b/cmd/cli/root.go index 7a27a72..2018b12 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -14,11 +14,17 @@ 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) { }, } + wCmd := s.wakatimeIntegration() + wCmd.AddCommand(s.wakatimeEnable()) + wCmd.AddCommand(s.wakatimeDisable()) + wCmd.AddCommand(s.wakatimeSetCLIPath()) + + 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..2e8d6b9 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 { @@ -37,7 +43,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 +141,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 +183,53 @@ 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") + path, _ := cmd.Flags().GetString("set-path") + + 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 +} + +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() + }, + } +} + +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/event_controller.go b/cmd/service/internal/events/event_controller.go index 7b91fcd..7c084d3 100644 --- a/cmd/service/internal/events/event_controller.go +++ b/cmd/service/internal/events/event_controller.go @@ -8,11 +8,16 @@ import ( "net" "os/exec" "strings" + "sync" + "time" "github.com/jms-guy/timekeep/cmd/service/internal/sessions" + "github.com/jms-guy/timekeep/internal/config" "github.com/jms-guy/timekeep/internal/repository" ) +var Version = "dev" + // Command details communicated by pipe type Command struct { Action string `json:"action"` @@ -21,16 +26,20 @@ 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 + 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 -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.") @@ -47,19 +56,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 { @@ -68,21 +81,43 @@ 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) { - programs, err := pr.GetAllProgramNames(context.Background()) +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(ctx) if err != nil { logger.Printf("ERROR: Failed to get programs: %s", err) return } - e.StopProcessMonitor() - 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(ctx, logger, sm, pr, a, h, toTrack) + } + + 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(ctx, logger, sm) + } 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_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 new file mode 100644 index 0000000..91ec4f0 --- /dev/null +++ b/cmd/service/internal/events/events_wakatime.go @@ -0,0 +1,105 @@ +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(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() + + logger.Println("INFO: Starting WakaTime heartbeats") + + go func() { + defer func() { + e.heartbeatMu.Lock() + if e.wakaHeartbeatTicker != nil { + e.wakaHeartbeatTicker.Stop() + e.wakaHeartbeatTicker = nil + } + e.heartbeatMu.Unlock() + }() + + errorCount := 0 + for { + select { + case <-ctx.Done(): + logger.Println("INFO: Stopping WakaTime heartbeats") + return + + case <-ticker.C: + if errorCount >= 5 { + logger.Println("ERROR: WakaTime heartbeats failed 5 times consecutively, stopping") + return + } + + if err := e.sendHeartbeats(ctx, logger, 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(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, logger, program, tracked.Category); err != nil { + return err + } + } + } + } + + return nil +} + +// Call the wakatime-cli heartbeat command +func (e *EventController) sendWakaHeartbeat(ctx context.Context, logger *log.Logger, program, category string) error { + cliPath := e.Config.WakaTime.CLIPath + + if cliPath == "" { + logger.Println("ERROR: wakatime-cli path not set") + } + + 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()), + } + + cmd := exec.CommandContext(ctx, cliPath, args...) + return cmd.Run() +} + +// Stops WakaTime heartbeat ticker after disabling integration +func (e *EventController) StopHeartbeats() { + 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..99b6a7f 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") @@ -77,6 +78,14 @@ 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/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/cmd/service/internal/sessions/sessions.go b/cmd/service/internal/sessions/sessions.go index e7dc2c6..91fafe9 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,13 +38,13 @@ 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{})} } } // 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] @@ -70,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 } @@ -82,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] @@ -105,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 @@ -125,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, }) @@ -139,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 62cbac2..997c5a1 100644 --- a/cmd/service/service_linux.go +++ b/cmd/service/service_linux.go @@ -47,29 +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) } - go s.transport.Listen(s.logger.Logger, s.eventCtrl, s.sessions, s.prRepo, s.asRepo, s.hsRepo) + if s.eventCtrl.Config.WakaTime.Enabled { + s.eventCtrl.StartHeartbeats(ctx, s.logger.Logger, s.sessions) + } - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) + go s.transport.Listen(ctx, s.logger.Logger, s.eventCtrl, s.sessions, s.prRepo, s.asRepo, s.hsRepo) - killSignal := <-interrupt - s.logger.Logger.Printf("Got signal: %v", killSignal) - s.closeService() + <-ctx.Done() - 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 a498a16..3b6029c 100644 --- a/cmd/service/service_setup.go +++ b/cmd/service/service_setup.go @@ -1,11 +1,14 @@ 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" "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 +49,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 } @@ -81,15 +91,17 @@ func NewTimekeepService(pr repository.ProgramRepository, ar repository.ActiveRep } // Service shutdown function -func (s *timekeepService) closeService() { +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 b54c855..e59cf91 100644 --- a/cmd/service/service_windows.go +++ b/cmd/service/service_windows.go @@ -40,21 +40,38 @@ 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()) + 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} 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(ctx, s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, toTrack) + } + + if s.eventCtrl.Config.WakaTime.Enabled { + 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} // Service mainloop, handles only SCM signals loop: @@ -64,13 +81,29 @@ 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.closeService() + s.logger.Logger.Println("INFO: Received stop signal") + 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 { + 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(ctx, s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo) + if s.eventCtrl.Config.WakaTime.Enabled { + 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) } @@ -78,5 +111,6 @@ loop: } status <- svc.Status{State: svc.StopPending} + return false, 0 } 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/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 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..cbdd1c0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,81 @@ +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"` + CLIPath string `json:"cli_path,omitempty"` +} + +const defaultConfig = `{ + "wakatime": { + "enabled": false, + "api_key": "" + } +}` + +func Load() (*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), 0o600) + 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/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/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 } 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/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