Skip to content

Commit 6b46db0

Browse files
authored
Merge pull request #6 from jms-guy/linux_debug
Linux debug
2 parents 9b78010 + ecb4351 commit 6b46db0

14 files changed

Lines changed: 175 additions & 125 deletions

README.md

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77

88
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.
99

10-
2025/10/15 -- **Linux version currently not working**
11-
1210
## Table of Contents
1311
- [Features](#features)
1412
- [How It Works](#how-it-works)
@@ -84,7 +82,7 @@ GOOS=windows go build -o timekeep-service.exe ./cmd/service
8482
GOOS=windows go build -o timekeep.exe ./cmd/cli
8583
8684
# Install and start service (Run as Administrator)
87-
sc.exe create timekeep binPath= "C:\Program Files\Timekeep\timekeep-service.exe" start= auto # Assuming this is the location of service binary
85+
sc.exe create timekeep binPath= "Path to timekeep-service.exe binary" start= auto
8886
sc.exe start timekeep
8987
9088
# Verify service is running
@@ -123,11 +121,6 @@ sudo mkdir -p /var/run/timekeep
123121
sudo chown "$USER_NAME":"$GROUP_NAME" /var/run/timekeep
124122
sudo chmod 755 /var/run/timekeep
125123

126-
# Create and set permissions for log directory
127-
sudo mkdir -p /var/log/timekeep
128-
sudo chown "$USER_NAME":"$GROUP_NAME" /var/log/timekeep
129-
sudo chmod 755 /var/log/timekeep
130-
131124
# Create systemd service
132125
sudo tee /etc/systemd/system/timekeep.service > /dev/null <<EOF
133126
[Unit]
@@ -137,7 +130,11 @@ After=network.target
137130
[Service]
138131
Type=simple
139132
ExecStart=/usr/local/bin/timekeepd
133+
StandardOutput=journal
134+
StandardError=journal
135+
KillMode=process
140136
Restart=always
137+
RestartSec=2s
141138
User=$USER_NAME
142139
Group=$GROUP_NAME
143140
@@ -184,7 +181,7 @@ To enable WakaTime integration, users must:
184181

185182
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:
186183

187-
`timekeep wakatime enable --api-key YOUR-KEY --set-path wakatime-cli-PATH`
184+
`timekeep wakatime enable --api-key "YOUR-KEY" --set-path "wakatime-cli-PATH"`
188185

189186
```json
190187
{
@@ -250,7 +247,7 @@ Users can update a program's category or project with the **update** command:
250247
## File Locations
251248
- **Logs**
252249
- **Windows**: *C:\ProgramData\Timekeep\logs*
253-
- **Linux**: */var/log/timekeep*
250+
- **Linux**: *journal*
254251
255252
- **Config**
256253
- **Windows**: *C:\ProgramData\Timekeep\config*

cmd/service/internal/events/event_controller.go

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,12 @@ type Command struct {
2727
}
2828

2929
type EventController struct {
30-
PsProcess *exec.Cmd // Powershell process for Windows event monitoring
31-
RunCtx context.Context
32-
Cancel context.CancelFunc // Event monitoring cancel context
33-
Config *config.Config // Struct built from config file
34-
wakaHeartbeatTicker *time.Ticker // Ticker for WakaTime enabled heartbeats
35-
heartbeatMu sync.Mutex // Mutex for WakaTime heartbeat ticker
36-
version string // Timekeep version
30+
PsProcess *exec.Cmd // Powershell process for Windows event monitoring
31+
mu sync.Mutex // Mutex for context cancellations
32+
MonCancel context.CancelFunc // Monitoring function cancel context
33+
WakaCancel context.CancelFunc // WakaTime function cancel context
34+
Config *config.Config // Struct built from config file
35+
version string // Timekeep version
3736
}
3837

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

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

90-
if e.Cancel != nil {
91-
e.Cancel()
92-
}
93-
runCtx, runCancel := context.WithCancel(ctx)
94-
e.RunCtx = runCtx
95-
e.Cancel = runCancel
96-
9789
newConfig, err := config.Load()
9890
if err != nil {
9991
logger.Printf("ERROR: Failed to load config: %s", err)
@@ -111,11 +103,11 @@ func (e *EventController) RefreshProcessMonitor(ctx context.Context, logger *log
111103
if len(programs) > 0 {
112104
toTrack := updateSessionsMapOnRefresh(sm, programs)
113105

114-
go e.MonitorProcesses(e.RunCtx, logger, sm, pr, a, h, toTrack)
106+
e.StartMonitor(serviceCtx, logger, sm, pr, a, h, toTrack)
115107
}
116108

117109
if e.Config.WakaTime.Enabled {
118-
e.StartHeartbeats(e.RunCtx, logger, sm)
110+
e.StartHeartbeats(serviceCtx, logger, sm)
119111
}
120112

121113
logger.Printf("INFO: Process monitor refresh with %d programs", len(programs))

cmd/service/internal/events/events_linux.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@ import (
1818
"github.com/jms-guy/timekeep/internal/repository"
1919
)
2020

21+
const grace = 3 * time.Second
22+
23+
func (e *EventController) StartMonitor(parent context.Context, logger *log.Logger, sm *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) {
24+
e.mu.Lock()
25+
if e.MonCancel != nil {
26+
e.MonCancel()
27+
e.MonCancel = nil
28+
}
29+
ctx, cancel := context.WithCancel(parent)
30+
e.MonCancel = cancel
31+
e.mu.Unlock()
32+
33+
go e.MonitorProcesses(ctx, logger, sm, pr, a, h, programs)
34+
}
35+
2136
// Main process monitoring function for Linux version
2237
func (e *EventController) MonitorProcesses(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) {
2338
logger.Println("INFO: Executing main process monitor")
@@ -111,7 +126,9 @@ func (e *EventController) checkForProcessStopEvents(logger *log.Logger, sm *sess
111126
continue
112127
}
113128

114-
ends = append(ends, toEnd{program, pid})
129+
if now.Sub(t.LastSeen) >= grace {
130+
ends = append(ends, toEnd{program, pid})
131+
}
115132
}
116133
}
117134
sm.Mu.Unlock()
@@ -121,7 +138,14 @@ func (e *EventController) checkForProcessStopEvents(logger *log.Logger, sm *sess
121138
}
122139
}
123140

124-
func (e *EventController) StopProcessMonitor() {}
141+
func (e *EventController) StopProcessMonitor() {
142+
e.mu.Lock()
143+
if e.MonCancel != nil {
144+
e.MonCancel()
145+
e.MonCancel = nil
146+
}
147+
e.mu.Unlock()
148+
}
125149

126150
// Read process /proc/{pid}/exe path to get program name
127151
func readExePath(pid int) (string, error) {

cmd/service/internal/events/events_wakatime.go

Lines changed: 54 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,74 +4,73 @@ import (
44
"context"
55
"fmt"
66
"log"
7+
"os"
78
"os/exec"
89
"time"
910

1011
"github.com/jms-guy/timekeep/cmd/service/internal/sessions"
1112
)
1213

1314
// Start WakaTime heartbeat ticker
14-
func (e *EventController) StartHeartbeats(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager) {
15-
e.heartbeatMu.Lock()
16-
e.wakaHeartbeatTicker = time.NewTicker(1 * time.Minute)
17-
ticker := e.wakaHeartbeatTicker
18-
e.heartbeatMu.Unlock()
15+
func (e *EventController) StartHeartbeats(parent context.Context, logger *log.Logger, sm *sessions.SessionManager) {
16+
newCtx, newCancel := context.WithCancel(parent)
17+
18+
e.mu.Lock()
19+
oldCancel := e.WakaCancel
20+
e.WakaCancel = newCancel
21+
e.mu.Unlock()
22+
23+
if oldCancel != nil {
24+
oldCancel()
25+
}
1926

2027
logger.Println("INFO: Starting WakaTime heartbeats")
2128

22-
go func() {
23-
defer func() {
24-
e.heartbeatMu.Lock()
25-
if e.wakaHeartbeatTicker != nil {
26-
e.wakaHeartbeatTicker.Stop()
27-
e.wakaHeartbeatTicker = nil
28-
}
29-
e.heartbeatMu.Unlock()
30-
}()
29+
go func(ctx context.Context) {
30+
ticker := time.NewTicker(time.Minute)
31+
defer ticker.Stop()
3132

3233
errorCount := 0
3334
for {
3435
select {
3536
case <-ctx.Done():
3637
logger.Println("INFO: Stopping WakaTime heartbeats")
3738
return
38-
3939
case <-ticker.C:
4040
if errorCount >= 5 {
4141
logger.Println("ERROR: WakaTime heartbeats failed 5 times consecutively, stopping")
4242
return
4343
}
44-
4544
if err := e.sendHeartbeats(ctx, logger, sm); err != nil {
4645
logger.Printf("ERROR: Failed to send WakaTime heartbeat: %s", err)
4746
errorCount++
4847
continue
4948
}
50-
5149
errorCount = 0
5250
}
5351
}
54-
}()
52+
}(newCtx)
5553
}
5654

5755
// Send specified heartbeats to WakaTime
5856
func (e *EventController) sendHeartbeats(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager) error {
59-
sm.Mu.Lock()
60-
defer sm.Mu.Unlock()
57+
type item struct{ program, category, project string }
58+
items := []item{}
6159

62-
for program, tracked := range sm.Programs {
63-
if len(tracked.PIDs) > 0 {
64-
if tracked.Category != "" {
65-
if err := e.sendWakaHeartbeat(ctx, logger, program, tracked.Category, tracked.Project); err != nil {
66-
return err
67-
}
68-
logger.Printf("INFO: WakaTime heartbeat sent for %s, category %s", program, tracked.Category)
69-
continue
70-
}
71-
logger.Printf("INFO: WakaTime heartbeat skipped for %s, no category set", program)
60+
sm.Mu.Lock()
61+
for p, t := range sm.Programs {
62+
if len(t.PIDs) > 0 && t.Category != "" {
63+
items = append(items, item{p, t.Category, t.Project})
7264
}
7365
}
66+
sm.Mu.Unlock()
7467

68+
for _, it := range items {
69+
if err := e.sendWakaHeartbeat(ctx, logger, it.program, it.category, it.project); err != nil {
70+
return err
71+
}
72+
logger.Printf("INFO: WakaTime heartbeat sent for %s, category %s", it.program, it.category)
73+
}
7574
return nil
7675
}
7776

@@ -93,23 +92,39 @@ func (e *EventController) sendWakaHeartbeat(ctx context.Context, logger *log.Log
9392
"--key", e.Config.WakaTime.APIKey,
9493
"--entity", program,
9594
"--entity-type", "app",
96-
"--plugin", "timekeep/" + e.version,
97-
"--alternate-project", projectToUse,
9895
"--category", category,
96+
"--alternate-project", projectToUse,
9997
"--time", fmt.Sprintf("%d", time.Now().Unix()),
98+
"--verbose",
99+
"--write",
100100
}
101101

102-
cmd := exec.CommandContext(ctx, cliPath, args...)
103-
return cmd.Run()
102+
logger.Printf("DEBUG: cli=%s args=%v", cliPath, args)
103+
104+
execCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
105+
defer cancel()
106+
107+
cmd := exec.CommandContext(execCtx, cliPath, args...)
108+
cmd.Env = append(os.Environ(),
109+
"HOME=/home/jamieguy",
110+
"PATH=/usr/local/bin:/usr/bin",
111+
)
112+
out, err := cmd.CombinedOutput()
113+
if err != nil {
114+
logger.Printf("ERROR: wakatime-cli failed: %v, output: %s", err, out)
115+
return err
116+
}
117+
return nil
104118
}
105119

106120
// Stops WakaTime heartbeat ticker after disabling integration
107121
func (e *EventController) StopHeartbeats() {
108-
e.heartbeatMu.Lock()
109-
defer e.heartbeatMu.Unlock()
122+
e.mu.Lock()
123+
cancel := e.WakaCancel
124+
e.WakaCancel = nil
125+
e.mu.Unlock()
110126

111-
if e.wakaHeartbeatTicker != nil {
112-
e.wakaHeartbeatTicker.Stop()
113-
e.wakaHeartbeatTicker = nil
127+
if cancel != nil {
128+
cancel()
114129
}
115130
}

cmd/service/internal/events/events_windows.go

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,15 @@ var monitorScript string
2424
var premonitorScript string
2525

2626
// Main process monitoring function for Windows version
27-
func (e *EventController) MonitorProcesses(ctx context.Context, logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) {
27+
func (e *EventController) StartMonitor(parent context.Context, logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) {
28+
e.mu.Lock()
29+
if e.MonCancel != nil {
30+
e.MonCancel()
31+
e.MonCancel = nil
32+
}
33+
ctx, cancel := context.WithCancel(parent)
34+
e.MonCancel = cancel
35+
e.mu.Unlock()
2836
e.startProcessMonitor(ctx, logger, programs)
2937
}
3038

@@ -114,21 +122,21 @@ func (e *EventController) startProcessMonitor(ctx context.Context, logger *log.L
114122

115123
// Stops the WMI powershell script
116124
func (e *EventController) StopProcessMonitor() {
125+
e.mu.Lock()
126+
if e.MonCancel != nil {
127+
e.MonCancel()
128+
e.MonCancel = nil
129+
}
130+
e.mu.Unlock()
131+
117132
if e.PsProcess != nil {
118133
_ = e.PsProcess.Process.Kill()
119134
e.PsProcess = nil
120135
}
121136
}
122137

123138
// Runs the pre-monitoring script, gathering PIDs for tracked programs that are already running on service start
124-
func (e *EventController) StartPreMonitor(ctx context.Context, logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) {
125-
select {
126-
case <-ctx.Done():
127-
logger.Println("WARNING: Context already cancelled, not starting pre-monitor")
128-
return
129-
default:
130-
}
131-
139+
func (e *EventController) StartPreMonitor(logger *log.Logger, s *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, programs []string) {
132140
programList := strings.Join(programs, ",")
133141

134142
scriptTempDir := filepath.Join("C:\\", "ProgramData", "TimeKeep", "scripts_temp")
@@ -161,7 +169,7 @@ func (e *EventController) StartPreMonitor(ctx context.Context, logger *log.Logge
161169
time.Sleep(100 * time.Millisecond)
162170

163171
args := []string{"-ExecutionPolicy", "Bypass", "-File", tempFile.Name(), "-Programs", programList}
164-
cmd := exec.CommandContext(ctx, "powershell", args...)
172+
cmd := exec.Command("powershell", args...)
165173

166174
var stderr bytes.Buffer
167175
cmd.Stderr = &stderr
@@ -179,13 +187,6 @@ func (e *EventController) StartPreMonitor(ctx context.Context, logger *log.Logge
179187

180188
err := cmd.Wait()
181189

182-
select {
183-
case <-ctx.Done():
184-
logger.Println("INFO: Powershell pre-monitor stopped due to context cancellation")
185-
return
186-
default:
187-
}
188-
189190
if err != nil {
190191
logger.Printf("ERROR: PowerShell pre-monitor process exited with error: %s", err)
191192
} else {

0 commit comments

Comments
 (0)