diff --git a/README.md b/README.md index 39bb381..633b23e 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,6 @@ A process activity tracker, it runs as a background service recording start/stop events for select programs and aggregates active sessions, session history, and lifetime program usage. Now has [WakaTime](https://github.com/jms-guy/timekeep?tab=readme-ov-file#wakatime) integration. -2025/10/15 -- **Linux version currently not working** - ## Table of Contents - [Features](#features) - [How It Works](#how-it-works) @@ -84,7 +82,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 # Assuming this is the location of service binary +sc.exe create timekeep binPath= "Path to timekeep-service.exe binary" start= auto sc.exe start timekeep # Verify service is running @@ -123,11 +121,6 @@ sudo mkdir -p /var/run/timekeep sudo chown "$USER_NAME":"$GROUP_NAME" /var/run/timekeep sudo chmod 755 /var/run/timekeep -# Create and set permissions for log directory -sudo mkdir -p /var/log/timekeep -sudo chown "$USER_NAME":"$GROUP_NAME" /var/log/timekeep -sudo chmod 755 /var/log/timekeep - # Create systemd service sudo tee /etc/systemd/system/timekeep.service > /dev/null < 0 { toTrack := updateSessionsMapOnRefresh(sm, programs) - go e.MonitorProcesses(e.RunCtx, logger, sm, pr, a, h, toTrack) + e.StartMonitor(serviceCtx, logger, sm, pr, a, h, toTrack) } if e.Config.WakaTime.Enabled { - e.StartHeartbeats(e.RunCtx, logger, sm) + e.StartHeartbeats(serviceCtx, logger, sm) } 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 4fe8f14..fa62133 100644 --- a/cmd/service/internal/events/events_linux.go +++ b/cmd/service/internal/events/events_linux.go @@ -18,6 +18,21 @@ import ( "github.com/jms-guy/timekeep/internal/repository" ) +const grace = 3 * time.Second + +func (e *EventController) StartMonitor(parent context.Context, logger *log.Logger, sm *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) { + e.mu.Lock() + if e.MonCancel != nil { + e.MonCancel() + e.MonCancel = nil + } + ctx, cancel := context.WithCancel(parent) + e.MonCancel = cancel + e.mu.Unlock() + + go e.MonitorProcesses(ctx, logger, sm, pr, a, h, programs) +} + // Main process monitoring function for Linux version func (e *EventController) MonitorProcesses(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) { logger.Println("INFO: Executing main process monitor") @@ -111,7 +126,9 @@ func (e *EventController) checkForProcessStopEvents(logger *log.Logger, sm *sess continue } - ends = append(ends, toEnd{program, pid}) + if now.Sub(t.LastSeen) >= grace { + ends = append(ends, toEnd{program, pid}) + } } } sm.Mu.Unlock() @@ -121,7 +138,14 @@ func (e *EventController) checkForProcessStopEvents(logger *log.Logger, sm *sess } } -func (e *EventController) StopProcessMonitor() {} +func (e *EventController) StopProcessMonitor() { + e.mu.Lock() + if e.MonCancel != nil { + e.MonCancel() + e.MonCancel = nil + } + e.mu.Unlock() +} // Read process /proc/{pid}/exe path to get program name func readExePath(pid int) (string, error) { diff --git a/cmd/service/internal/events/events_wakatime.go b/cmd/service/internal/events/events_wakatime.go index 5b952fc..8731cf7 100644 --- a/cmd/service/internal/events/events_wakatime.go +++ b/cmd/service/internal/events/events_wakatime.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "os" "os/exec" "time" @@ -11,23 +12,23 @@ 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() +func (e *EventController) StartHeartbeats(parent context.Context, logger *log.Logger, sm *sessions.SessionManager) { + newCtx, newCancel := context.WithCancel(parent) + + e.mu.Lock() + oldCancel := e.WakaCancel + e.WakaCancel = newCancel + e.mu.Unlock() + + if oldCancel != nil { + oldCancel() + } 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() - }() + go func(ctx context.Context) { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() errorCount := 0 for { @@ -35,43 +36,41 @@ func (e *EventController) StartHeartbeats(ctx context.Context, logger *log.Logge 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 } } - }() + }(newCtx) } // 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() + type item struct{ program, category, project string } + items := []item{} - for program, tracked := range sm.Programs { - if len(tracked.PIDs) > 0 { - if tracked.Category != "" { - if err := e.sendWakaHeartbeat(ctx, logger, program, tracked.Category, tracked.Project); err != nil { - return err - } - logger.Printf("INFO: WakaTime heartbeat sent for %s, category %s", program, tracked.Category) - continue - } - logger.Printf("INFO: WakaTime heartbeat skipped for %s, no category set", program) + sm.Mu.Lock() + for p, t := range sm.Programs { + if len(t.PIDs) > 0 && t.Category != "" { + items = append(items, item{p, t.Category, t.Project}) } } + sm.Mu.Unlock() + for _, it := range items { + if err := e.sendWakaHeartbeat(ctx, logger, it.program, it.category, it.project); err != nil { + return err + } + logger.Printf("INFO: WakaTime heartbeat sent for %s, category %s", it.program, it.category) + } return nil } @@ -93,23 +92,39 @@ func (e *EventController) sendWakaHeartbeat(ctx context.Context, logger *log.Log "--key", e.Config.WakaTime.APIKey, "--entity", program, "--entity-type", "app", - "--plugin", "timekeep/" + e.version, - "--alternate-project", projectToUse, "--category", category, + "--alternate-project", projectToUse, "--time", fmt.Sprintf("%d", time.Now().Unix()), + "--verbose", + "--write", } - cmd := exec.CommandContext(ctx, cliPath, args...) - return cmd.Run() + logger.Printf("DEBUG: cli=%s args=%v", cliPath, args) + + execCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(execCtx, cliPath, args...) + cmd.Env = append(os.Environ(), + "HOME=/home/jamieguy", + "PATH=/usr/local/bin:/usr/bin", + ) + out, err := cmd.CombinedOutput() + if err != nil { + logger.Printf("ERROR: wakatime-cli failed: %v, output: %s", err, out) + return err + } + return nil } // Stops WakaTime heartbeat ticker after disabling integration func (e *EventController) StopHeartbeats() { - e.heartbeatMu.Lock() - defer e.heartbeatMu.Unlock() + e.mu.Lock() + cancel := e.WakaCancel + e.WakaCancel = nil + e.mu.Unlock() - if e.wakaHeartbeatTicker != nil { - e.wakaHeartbeatTicker.Stop() - e.wakaHeartbeatTicker = nil + if cancel != nil { + cancel() } } diff --git a/cmd/service/internal/events/events_windows.go b/cmd/service/internal/events/events_windows.go index d1f805f..e7259b9 100644 --- a/cmd/service/internal/events/events_windows.go +++ b/cmd/service/internal/events/events_windows.go @@ -24,7 +24,15 @@ var monitorScript string var premonitorScript string // Main process monitoring function for Windows version -func (e *EventController) MonitorProcesses(ctx context.Context, logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) { +func (e *EventController) StartMonitor(parent context.Context, logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) { + e.mu.Lock() + if e.MonCancel != nil { + e.MonCancel() + e.MonCancel = nil + } + ctx, cancel := context.WithCancel(parent) + e.MonCancel = cancel + e.mu.Unlock() e.startProcessMonitor(ctx, logger, programs) } @@ -114,6 +122,13 @@ func (e *EventController) startProcessMonitor(ctx context.Context, logger *log.L // Stops the WMI powershell script func (e *EventController) StopProcessMonitor() { + e.mu.Lock() + if e.MonCancel != nil { + e.MonCancel() + e.MonCancel = nil + } + e.mu.Unlock() + if e.PsProcess != nil { _ = e.PsProcess.Process.Kill() e.PsProcess = nil @@ -121,14 +136,7 @@ func (e *EventController) StopProcessMonitor() { } // Runs the pre-monitoring script, gathering PIDs for tracked programs that are already running on service start -func (e *EventController) StartPreMonitor(ctx context.Context, logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) { - select { - case <-ctx.Done(): - logger.Println("WARNING: Context already cancelled, not starting pre-monitor") - return - default: - } - +func (e *EventController) StartPreMonitor(logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) { programList := strings.Join(programs, ",") scriptTempDir := filepath.Join("C:\\", "ProgramData", "TimeKeep", "scripts_temp") @@ -161,7 +169,7 @@ func (e *EventController) StartPreMonitor(ctx context.Context, logger *log.Logge time.Sleep(100 * time.Millisecond) args := []string{"-ExecutionPolicy", "Bypass", "-File", tempFile.Name(), "-Programs", programList} - cmd := exec.CommandContext(ctx, "powershell", args...) + cmd := exec.Command("powershell", args...) var stderr bytes.Buffer cmd.Stderr = &stderr @@ -179,13 +187,6 @@ func (e *EventController) StartPreMonitor(ctx context.Context, logger *log.Logge err := cmd.Wait() - select { - case <-ctx.Done(): - logger.Println("INFO: Powershell pre-monitor stopped due to context cancellation") - return - default: - } - if err != nil { logger.Printf("ERROR: PowerShell pre-monitor process exited with error: %s", err) } else { diff --git a/cmd/service/internal/logs/logger.go b/cmd/service/internal/logs/logger.go index 7901a8f..53d4b4e 100644 --- a/cmd/service/internal/logs/logger.go +++ b/cmd/service/internal/logs/logger.go @@ -1,10 +1,8 @@ package logs import ( - "fmt" "log" "os" - "path/filepath" ) type Logs struct { @@ -19,19 +17,11 @@ func NewLogs() (*Logs, error) { return nil, err } - // #nosec G301 - if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { - return nil, fmt.Errorf("ERROR: failed to create log directory: %w", err) - } - - // #nosec -- Log file not security issue - f, err := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o666) + logger, f, err := CreateLogger(logPath) if err != nil { return nil, err } - logger := log.New(f, "", log.LstdFlags) - return &Logs{Logger: logger, LogFile: f}, nil } diff --git a/cmd/service/internal/logs/logging_linux.go b/cmd/service/internal/logs/logging_linux.go index a8b8f33..a6ca7f6 100644 --- a/cmd/service/internal/logs/logging_linux.go +++ b/cmd/service/internal/logs/logging_linux.go @@ -2,10 +2,19 @@ package logs -import "path/filepath" +import ( + "log" + "os" + "path/filepath" +) // Get path for logging file func getLogPath() (string, error) { logDir := "/var/log/timekeep" return filepath.Join(logDir, "timekeep.log"), nil } + +func CreateLogger(logPath string) (*log.Logger, *os.File, error) { + logger := log.Default() + return logger, nil, nil +} diff --git a/cmd/service/internal/logs/logging_unsupported.go b/cmd/service/internal/logs/logging_unsupported.go index 53755a1..2f8498c 100644 --- a/cmd/service/internal/logs/logging_unsupported.go +++ b/cmd/service/internal/logs/logging_unsupported.go @@ -2,6 +2,15 @@ package logs +import ( + "log" + "os" +) + func getLogPath() (string, error) { return "", nil } + +func CreateLogger(logPath string) (*log.Logger, *os.File, error) { + return nil, nil, nil +} diff --git a/cmd/service/internal/logs/logging_windows.go b/cmd/service/internal/logs/logging_windows.go index de96d0c..7132bae 100644 --- a/cmd/service/internal/logs/logging_windows.go +++ b/cmd/service/internal/logs/logging_windows.go @@ -2,10 +2,32 @@ package logs -import "path/filepath" +import ( + "fmt" + "log" + "os" + "path/filepath" +) // Get path for logging file func getLogPath() (string, error) { logDir := `C:\ProgramData\TimeKeep\logs` return filepath.Join(logDir, "timekeep.log"), nil } + +func CreateLogger(logPath string) (*log.Logger, *os.File, error) { + // #nosec G301 + if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + return nil, nil, fmt.Errorf("ERROR: failed to create log directory: %w", err) + } + + // #nosec -- Log file not security issue + f, err := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o666) + if err != nil { + return nil, nil, err + } + + logger := log.New(f, "", log.LstdFlags) + + return logger, f, nil +} diff --git a/cmd/service/service_linux.go b/cmd/service/service_linux.go index 388166b..8f1bebb 100644 --- a/cmd/service/service_linux.go +++ b/cmd/service/service_linux.go @@ -53,15 +53,10 @@ func (s *timekeepService) Manage() (string, error) { serviceCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - runCtx, runCancel := context.WithCancel(serviceCtx) - s.eventCtrl.RunCtx = runCtx - s.eventCtrl.Cancel = runCancel - programs, err := s.prRepo.GetAllPrograms(context.Background()) if err != nil { return "ERROR: Failed to get programs", err } - logger.Printf("DEBUG: Have %d programs", len(programs)) if len(programs) > 0 { toTrack := []string{} for _, program := range programs { @@ -73,19 +68,16 @@ func (s *timekeepService) Manage() (string, error) { if program.Project.Valid { project = program.Project.String } - logger.Printf("DEBUG: Tracking %s", program.Name) s.sessions.EnsureProgram(program.Name, category, project) toTrack = append(toTrack, program.Name) } - logger.Printf("DEBUG: Entering main Monitor function") - go s.eventCtrl.MonitorProcesses(runCtx, s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, toTrack) + s.eventCtrl.StartMonitor(serviceCtx, s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, toTrack) } - logger.Printf("DEBUG: Starting heartbeats") if s.eventCtrl.Config.WakaTime.Enabled { - s.eventCtrl.StartHeartbeats(runCtx, s.logger.Logger, s.sessions) + s.eventCtrl.StartHeartbeats(serviceCtx, s.logger.Logger, s.sessions) } go s.transport.Listen(serviceCtx, s.logger.Logger, s.eventCtrl, s.sessions, s.prRepo, s.asRepo, s.hsRepo) diff --git a/cmd/service/service_setup.go b/cmd/service/service_setup.go index 04f7c27..c708978 100644 --- a/cmd/service/service_setup.go +++ b/cmd/service/service_setup.go @@ -27,18 +27,18 @@ type timekeepService struct { } func ServiceSetup() (*timekeepService, error) { - db, err := mysql.OpenLocalDatabase() + logger, err := logs.NewLogs() if err != nil { return nil, err } - store := repository.NewSqliteStore(db) - - logger, err := logs.NewLogs() + db, err := mysql.OpenLocalDatabase() if err != nil { return nil, err } + store := repository.NewSqliteStore(db) + d, err := daemons.NewDaemonManager() if err != nil { return nil, err diff --git a/cmd/service/service_windows.go b/cmd/service/service_windows.go index dd4f64d..bc8e551 100644 --- a/cmd/service/service_windows.go +++ b/cmd/service/service_windows.go @@ -44,10 +44,6 @@ func (s *timekeepService) Execute(args []string, r <-chan svc.ChangeRequest, sta serviceCtx, cancel := context.WithCancel(context.Background()) defer cancel() - runCtx, runCancel := context.WithCancel(serviceCtx) - s.eventCtrl.RunCtx = runCtx - s.eventCtrl.Cancel = runCancel - programs, err := s.prRepo.GetAllPrograms(context.Background()) if err != nil { s.logger.Logger.Printf("ERROR: Failed to get programs: %s", err) @@ -72,12 +68,12 @@ func (s *timekeepService) Execute(args []string, r <-chan svc.ChangeRequest, sta toTrack = append(toTrack, program.Name) } - s.eventCtrl.StartPreMonitor(runCtx, s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, toTrack) - s.eventCtrl.MonitorProcesses(runCtx, s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, toTrack) + s.eventCtrl.StartPreMonitor(s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, toTrack) + s.eventCtrl.StartMonitor(serviceCtx, s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, toTrack) } if s.eventCtrl.Config.WakaTime.Enabled { - s.eventCtrl.StartHeartbeats(runCtx, s.logger.Logger, s.sessions) + s.eventCtrl.StartHeartbeats(serviceCtx, s.logger.Logger, s.sessions) } go s.transport.Listen(serviceCtx, s.logger.Logger, s.eventCtrl, s.sessions, s.prRepo, s.asRepo, s.hsRepo) @@ -97,7 +93,8 @@ loop: status <- svc.Status{State: svc.StopPending} s.logger.Logger.Println("INFO: Received stop signal") s.closeService(s.logger.Logger) - s.eventCtrl.Cancel() + s.eventCtrl.MonCancel() + s.eventCtrl.WakaCancel() cancel() break loop diff --git a/scripts/install.sh b/scripts/install.sh index 39214e8..18d5f7d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -33,10 +33,6 @@ sudo mkdir -p /var/run/timekeep sudo chown "$USER_NAME":"$GROUP_NAME" /var/run/timekeep sudo chmod 755 /var/run/timekeep -sudo mkdir -p /var/log/timekeep -sudo chown "$USER_NAME":"$GROUP_NAME" /var/log/timekeep -sudo chmod 755 /var/log/timekeep - sudo tee /etc/systemd/system/timekeep.service > /dev/null <