Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <<EOF
[Unit]
Expand All @@ -137,7 +130,11 @@ After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/timekeepd
StandardOutput=journal
StandardError=journal
KillMode=process
Restart=always
RestartSec=2s
User=$USER_NAME
Group=$GROUP_NAME

Expand Down Expand Up @@ -184,7 +181,7 @@ To enable WakaTime integration, users must:

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?tab=readme-ov-file#file-locations) file, or provide them through flags:

`timekeep wakatime enable --api-key YOUR-KEY --set-path wakatime-cli-PATH`
`timekeep wakatime enable --api-key "YOUR-KEY" --set-path "wakatime-cli-PATH"`

```json
{
Expand Down Expand Up @@ -250,7 +247,7 @@ Users can update a program's category or project with the **update** command:
## File Locations
- **Logs**
- **Windows**: *C:\ProgramData\Timekeep\logs*
- **Linux**: */var/log/timekeep*
- **Linux**: *journal*

- **Config**
- **Windows**: *C:\ProgramData\Timekeep\config*
Expand Down
26 changes: 9 additions & 17 deletions cmd/service/internal/events/event_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,12 @@ type Command struct {
}

type EventController struct {
PsProcess *exec.Cmd // Powershell process for Windows event monitoring
RunCtx context.Context
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
PsProcess *exec.Cmd // Powershell process for Windows event monitoring
mu sync.Mutex // Mutex for context cancellations
MonCancel context.CancelFunc // Monitoring function cancel context
WakaCancel context.CancelFunc // WakaTime function cancel context
Config *config.Config // Struct built from config file
version string // Timekeep version
}

func NewEventController() *EventController {
Expand Down Expand Up @@ -83,17 +82,10 @@ func (e *EventController) HandleConnection(serviceCtx context.Context, logger *l
}

// Stops the currently running process monitoring script, and starts a new one with updated program list
func (e *EventController) RefreshProcessMonitor(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository) {
func (e *EventController) RefreshProcessMonitor(serviceCtx context.Context, logger *log.Logger, sm *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository) {
e.StopHeartbeats()
e.StopProcessMonitor()

if e.Cancel != nil {
e.Cancel()
}
runCtx, runCancel := context.WithCancel(ctx)
e.RunCtx = runCtx
e.Cancel = runCancel

newConfig, err := config.Load()
if err != nil {
logger.Printf("ERROR: Failed to load config: %s", err)
Expand All @@ -111,11 +103,11 @@ func (e *EventController) RefreshProcessMonitor(ctx context.Context, logger *log
if len(programs) > 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))
Expand Down
28 changes: 26 additions & 2 deletions cmd/service/internal/events/events_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand All @@ -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) {
Expand Down
93 changes: 54 additions & 39 deletions cmd/service/internal/events/events_wakatime.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,74 +4,73 @@ import (
"context"
"fmt"
"log"
"os"
"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()
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 {
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
}
}
}()
}(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
}

Expand All @@ -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()
}
}
35 changes: 18 additions & 17 deletions cmd/service/internal/events/events_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -114,21 +122,21 @@ 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
}
}

// 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")
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Loading