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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,6 @@ go.work.sum
# .vscode/

testbuild.sh
testlinuxbuild.sh
timekeep-release
changes.md
26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

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.

**Linux version currently not working**

## Table of Contents
- [Features](#features)
- [How It Works](#how-it-works)
Expand All @@ -15,7 +17,6 @@ A process activity tracker, it runs as a background service recording start/stop
- [WakaTime](#wakatime)
- [File Locations](#file-locations)
- [Current Limitations](#current-limitations)
- [To-Do](#to-do)
- [Contributing & Issues](#contributing--issues)
- [License](#license)

Expand All @@ -30,8 +31,8 @@ A process activity tracker, it runs as a background service recording start/stop
- WakaTime integration allows for tracking external program usage alongside your IDE/web-browsing stats

## How It Works
- Windows: embeds a PowerShell script to subscribe to WMI process start/stop events.
- Linux: polls `/proc`, resolves process identity via `/proc/<pid>/exe` (readlink) -> fallback to `/proc/<pid>/cmdline` -> last-resort `/proc/<pid>/comm`, then matches by basename.
- Windows: Embeds a PowerShell script to subscribe to WMI process start/stop events. Runs a pre-monitoring script to find any tracked programs already running on service start
- Linux: Polls `/proc`, resolves process identity via `/proc/<pid>/exe` (readlink) -> fallback to `/proc/<pid>/cmdline` -> last-resort `/proc/<pid>/comm`, then matches by basename.
- Session model: A session begins when the first process for a tracked program starts. Additional processes (ex. multiple windows) are added to the active session. The session ends only when the last process terminates, giving an accurate picture of total time with that program.

## Usage
Expand Down Expand Up @@ -122,6 +123,11 @@ 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 Down Expand Up @@ -170,7 +176,7 @@ 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.
Timekeep now integrates with [WakaTime](https://wakatime.com), allowing users to track external program usage alongside their IDE and web-browsing stats. **Timekeep does not track activity within these programs, only when these programs are running.**

To enable WakaTime integration, users must:
1. Have a WakaTime account
Expand All @@ -193,6 +199,8 @@ Enable integration through timekeep. Set your WakaTime API key and wakatime-cli

**The wakatime-cli path must be an absolute path.**

Example path: *C:\Users\Guy\.wakatime\wakatime-cli.exe*

### Complete WakaTime setup example

`timekeep wakatime enable --api-key YOUR-KEY --set-path wakatime-cli-PATH`
Expand Down Expand Up @@ -235,6 +243,10 @@ Users can also set project variables on a per-program basis:

Program-set project variables will take precedence over a set Global Project. If no project variable is set via the global_project config or when adding programs, WakaTime will fall back to default "Unknown Project".

Users can update a program's category or project with the **update** command:

`timekeep update notepad.exe --category planning --project Timekeep2`

## File Locations
- **Logs**
- **Windows**: *C:\ProgramData\Timekeep\logs*
Expand Down Expand Up @@ -263,12 +275,6 @@ Program-set project variables will take precedence over a set Global Project. If
## 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)
- Windows - Processes may be missed if start event happens while service is paused or stopped

## To-Do
- Linux - More accurate start/end time logging
- Linux - Configurable polling interval?
- Windows - Check for running processes on service start

## Contributing & Issues
To contribute, clone the repo with ```git clone https://github.com/jms-guy/timekeep```. Please fork the repository and open a pull request to the `main` branch. Run tests from base repo using ```go test ./...```
Expand Down
4 changes: 4 additions & 0 deletions cmd/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ func (s *CLIService) RemovePrograms(ctx context.Context, args []string, all bool
return nil
}

if len(args) < 1 {
return fmt.Errorf("missing argument")
}

for _, program := range args {
err := s.PrRepo.RemoveProgram(ctx, strings.ToLower(program))
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/cli/timekeep.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func (s *CLIService) removeProgramsCmd() *cobra.Command {
Aliases: []string{"RM", "remove", "Remove", "REMOVE"},
Short: "Remove a program from tracking list",
Long: "User may specify multiple programs to remove, as long as they're separated by a space. May provide the --all flag to remove all programs from tracking list",
Args: cobra.MinimumNArgs(1),
Args: cobra.RangeArgs(0, 1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

Expand Down
91 changes: 65 additions & 26 deletions cmd/service/internal/events/event_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/jms-guy/timekeep/cmd/service/internal/sessions"
"github.com/jms-guy/timekeep/internal/config"
"github.com/jms-guy/timekeep/internal/database"
"github.com/jms-guy/timekeep/internal/repository"
)

Expand All @@ -26,8 +27,9 @@ type Command struct {
}

type EventController struct {
PsProcess *exec.Cmd // Powershell process for Windows event monitoring
cancel context.CancelFunc // Event monitoring cancel context
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
Expand Down Expand Up @@ -82,45 +84,82 @@ 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) {
e.StopProcessMonitor()
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)
return
}

e.Config = newConfig

programs, err := pr.GetAllPrograms(ctx)
programs, err := pr.GetAllPrograms(context.Background())
if err != nil {
logger.Printf("ERROR: Failed to get programs: %s", err)
return
}

if len(programs) > 0 {
toTrack := []string{}
for _, program := range programs {
category := ""
project := ""
if program.Category.Valid {
category = program.Category.String
}
if program.Project.Valid {
project = program.Project.String
}
sm.EnsureProgram(program.Name, category, project)
toTrack := updateSessionsMapOnRefresh(sm, programs)

toTrack = append(toTrack, program.Name)
}
go e.MonitorProcesses(e.RunCtx, logger, sm, pr, a, h, toTrack)
}

go e.MonitorProcesses(ctx, logger, sm, pr, a, h, toTrack)
if e.Config.WakaTime.Enabled {
e.StartHeartbeats(e.RunCtx, logger, sm)
}

newConfig, err := config.Load()
if err != nil {
logger.Printf("ERROR: Failed to load config: %s", err)
return
logger.Printf("INFO: Process monitor refresh with %d programs", len(programs))
}

// Takes list of programs from database, and updates session map by adding/removing/altering based on any changes from last database grab
func updateSessionsMapOnRefresh(sm *sessions.SessionManager, programs []database.TrackedProgram) []string {
desired := make(map[string]struct{}, len(programs))
toTrack := make([]string, 0, len(programs))

sm.Mu.Lock()
currentKeys := make([]string, 0, len(sm.Programs))
for k := range sm.Programs {
currentKeys = append(currentKeys, k)
}

e.Config = newConfig
for _, p := range programs {
name := p.Name
cat := ""
if p.Category.Valid {
cat = p.Category.String
}
proj := ""
if p.Project.Valid {
proj = p.Project.String
}

if e.Config.WakaTime.Enabled {
e.StartHeartbeats(ctx, logger, sm)
sm.EnsureProgram(name, cat, proj)
desired[name] = struct{}{}
toTrack = append(toTrack, name)
}

logger.Printf("INFO: Process monitor refresh with %d programs", len(programs))
if len(desired) == 0 {
for _, k := range currentKeys {
delete(sm.Programs, k)
}
} else {
for _, k := range currentKeys {
if _, keep := desired[k]; !keep {
delete(sm.Programs, k)
}
}
}
sm.Mu.Unlock()

return toTrack
}
25 changes: 9 additions & 16 deletions cmd/service/internal/events/events_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,25 @@ import (

// 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) {
if e.cancel != nil {
e.cancel()
e.cancel = nil
}
logger.Println("INFO: Executing main process monitor")

ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
logger.Println("INFO: Monitor context cancelled")
return
case <-ticker.C:
livePIDS := e.checkForProcessStartEvents(ctx, logger, sm, a)
e.checkForProcessStopEvents(ctx, logger, sm, pr, a, h, livePIDS)
livePIDS := e.checkForProcessStartEvents(logger, sm, a)
e.checkForProcessStopEvents(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(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager, a repository.ActiveRepository) map[int]struct{} {
func (e *EventController) checkForProcessStartEvents(logger *log.Logger, sm *sessions.SessionManager, a repository.ActiveRepository) map[int]struct{} {
entries, err := os.ReadDir("/proc") // Read /proc
if err != nil {
logger.Printf("ERROR: Couldn't read /proc: %s", err)
Expand Down Expand Up @@ -79,15 +77,15 @@ func (e *EventController) checkForProcessStartEvents(ctx context.Context, logger
}
sm.Mu.Unlock()

sm.CreateSession(ctx, logger, a, identity, pid)
sm.CreateSession(context.Background(), logger, a, identity, pid)
}

return live
}

// 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(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager, pr repository.ProgramRepository, a repository.ActiveRepository, h repository.HistoryRepository, livePIDs map[int]struct{}) {
func (e *EventController) checkForProcessStopEvents(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{}{}
}
Expand Down Expand Up @@ -119,16 +117,11 @@ func (e *EventController) checkForProcessStopEvents(ctx context.Context, logger
sm.Mu.Unlock()

for _, eend := range ends {
sm.EndSession(ctx, logger, pr, a, h, eend.program, eend.pid)
sm.EndSession(context.Background(), logger, pr, a, h, eend.program, eend.pid)
}
}

func (e *EventController) StopProcessMonitor() {
if e.cancel != nil {
e.cancel()
e.cancel = nil
}
}
func (e *EventController) StopProcessMonitor() {}

// Read process /proc/{pid}/exe path to get program name
func readExePath(pid int) (string, error) {
Expand Down
1 change: 1 addition & 0 deletions cmd/service/internal/events/events_wakatime.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ func (e *EventController) sendWakaHeartbeat(ctx context.Context, logger *log.Log
if project != "" {
projectToUse = project
}
logger.Printf("INFO: Sending WakaTime heartbeat for %s, category %s, project %s", program, category, projectToUse)

args := []string{
"--key", e.Config.WakaTime.APIKey,
Expand Down
Loading