<# Doka Activity Tracker ===================== Watches which programs you use during the day and writes a summary to a JSON file on your own computer. That is all it does. WHAT IT LOOKS AT (four things, all read from Windows itself) 1. Which window is in front (has keyboard focus) and its title bar text. 2. Which windows are open on screen, so "open all day" and "actually being used" can be told apart. 3. How long since you last touched the keyboard or mouse (idle detection). 4. Whether a meeting is in progress: a Teams/Zoom/Meet meeting window is visible, or some program has the microphone open. Time sat in a meeting without typing is counted as "attending", not "idle". WHAT IT WRITES One file per day: Documents\ActivityTracker\-YYYY-MM-DD.json is 8 random characters chosen the first time the tracker runs (kept in tracker.id next to this script) so files from different people don't clash. It is not built from your name or machine. Set trackerId in config.json to use a label of your own instead. The format is described in docs/schema.md in the project repository. WHAT IT NEVER DOES - No keystrokes, clipboard, screenshots, URLs, file contents, or file paths. - No user name, machine name, or account details. - No network. There is no code in this file that talks to the internet. Search it for "http", "Invoke-WebRequest", "Invoke-RestMethod", "WebClient" or "Net.Sockets" - you will find them only in this sentence. HOW TO RUN Double-click Start-Tracker.bat (or: powershell -ExecutionPolicy Bypass -File ActivityTracker.ps1) Stop it with Stop-Tracker.bat, Ctrl+C in its window, or by closing the window. Run with -Once to take a single sample and print what it saw, without saving. #> #Requires -Version 5.1 [CmdletBinding()] param( # Optional settings file. Defaults to config.json next to this script. # Copy config.example.json to config.json and edit. [string]$ConfigPath = '', # Take one sample, print it to the console, and exit. Nothing is written. [switch]$Once ) $ErrorActionPreference = 'Stop' $TrackerVersion = '0.1.0' # The folder this script lives in. ($PSScriptRoot is empty while parameter # defaults are evaluated in Windows PowerShell 5.1, so it is resolved here.) $ScriptDir = $PSScriptRoot if (-not $ScriptDir) { $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path } if (-not $ConfigPath) { $ConfigPath = Join-Path $ScriptDir 'config.json' } # --------------------------------------------------------------------------- # Settings (defaults; anything in config.json overrides these) # --------------------------------------------------------------------------- $Config = [ordered]@{ pollIntervalSec = 5 # how often to look (seconds) idleThresholdSec = 300 # no input for this long = idle saveIntervalSec = 60 # how often to rewrite the day's JSON file outputFolder = (Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'ActivityTracker') captureTitles = $true # false = record app names only, never window titles trackerId = '' # file name prefix; blank = random id generated once and saved to tracker.id # Windows that belong to the shell rather than to your work. ignoreProcesses = @('ApplicationFrameHost', 'TextInputHost', 'ShellExperienceHost', 'SearchHost', 'StartMenuExperienceHost', 'LockApp', 'SystemSettings', 'Widgets') ignoreTitles = @('^Program Manager$') meetingDetection = [ordered]@{ microphone = $true # check which app currently has the microphone open rules = @( # a visible window matching process + title (and not exclude) = a meeting [ordered]@{ process = '^(ms-teams|Teams)$'; title = '\| Microsoft Teams$' exclude = '^(Chat|Calendar|Activity|Calls|Apps|Files|OneDrive|Teams and channels|Assignments|Microsoft Teams)( \||$)' } [ordered]@{ process = '^Zoom$'; title = '^Zoom (Meeting|Webinar)' } [ordered]@{ process = '^(chrome|msedge|firefox)$'; title = '^Meet - ' } [ordered]@{ process = '^(CiscoCollabHost|atmgr)$'; title = '.' } ) } } # Packaged (Store) apps report a package name to the microphone registry rather # than an exe name; map the ones we know to their process names. $PackagedProcessNames = @{ 'MSTeams' = 'ms-teams'; 'Microsoft.SkypeApp' = 'Skype' } function Merge-Config($target, $source) { foreach ($prop in $source.PSObject.Properties) { $v = $prop.Value if ($v -is [System.Management.Automation.PSCustomObject] -and $target[$prop.Name] -is [System.Collections.IDictionary]) { Merge-Config $target[$prop.Name] $v } else { $target[$prop.Name] = $v } } } if (Test-Path -LiteralPath $ConfigPath) { Merge-Config $Config (Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json) } $Poll = [int]$Config.pollIntervalSec # The id that prefixes each day's file name. Random, chosen once, never derived # from anything personal. -Once does not create it. $IdFile = Join-Path $ScriptDir 'tracker.id' if (-not $Config.trackerId -and (Test-Path -LiteralPath $IdFile)) { $Config.trackerId = (Get-Content -LiteralPath $IdFile -Raw).Trim() } if (-not $Config.trackerId -and -not $Once) { $bytes = New-Object byte[] 4 (New-Object System.Security.Cryptography.RNGCryptoServiceProvider).GetBytes($bytes) $Config.trackerId = ($bytes | ForEach-Object { $_.ToString('x2') }) -join '' Set-Content -LiteralPath $IdFile -Value $Config.trackerId -NoNewline } if ($Config.trackerId) { $Config.trackerId = [regex]::Replace([string]$Config.trackerId, '[^A-Za-z0-9._-]', '-') } # --------------------------------------------------------------------------- # The Windows calls. This is the only "low level" part: it asks Windows for # the window list, the focused window, its title, and the last-input time. # --------------------------------------------------------------------------- Add-Type -TypeDefinition @' using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Text; public static class Win32 { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool GetLastInputInfo(ref LASTINPUTINFO info); [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr hWnd, int attr, out int value, int size); public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam); [StructLayout(LayoutKind.Sequential)] public struct LASTINPUTINFO { public uint cbSize; public uint dwTime; } public static string Title(IntPtr hWnd) { int len = GetWindowTextLength(hWnd); if (len == 0) return ""; var sb = new StringBuilder(len + 1); GetWindowText(hWnd, sb, sb.Capacity); return sb.ToString(); } public static uint Pid(IntPtr hWnd) { uint pid; GetWindowThreadProcessId(hWnd, out pid); return pid; } // Store apps keep invisible "cloaked" windows around; skip those. static bool IsCloaked(IntPtr hWnd) { int cloaked; try { DwmGetWindowAttribute(hWnd, 14, out cloaked, sizeof(int)); } catch { return false; } return cloaked != 0; } public static List VisibleWindows() { var list = new List(); EnumWindows((h, l) => { if (IsWindowVisible(h) && GetWindowTextLength(h) > 0 && !IsCloaked(h)) list.Add(h); return true; }, IntPtr.Zero); return list; } // Milliseconds since the last keyboard or mouse input. public static uint IdleMilliseconds() { var info = new LASTINPUTINFO(); info.cbSize = (uint)Marshal.SizeOf(info); GetLastInputInfo(ref info); return unchecked((uint)Environment.TickCount - info.dwTime); } } '@ # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- function Format-Stamp([datetime]$t) { $t.ToString('yyyy-MM-ddTHH:mm:ss') } $ProcessCache = @{} # pid -> @{ process; name } function Get-ProcessInfo([uint32]$ProcId) { if ($ProcessCache.ContainsKey($ProcId)) { return $ProcessCache[$ProcId] } $p = Get-Process -Id $ProcId -ErrorAction SilentlyContinue if (-not $p) { return $null } $friendly = $null try { if ($p.Path) { $friendly = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($p.Path).FileDescription } } catch { } if ([string]::IsNullOrWhiteSpace($friendly)) { $friendly = $p.ProcessName } $info = @{ process = $p.ProcessName; name = $friendly.Trim() } $ProcessCache[$ProcId] = $info return $info } # Which programs have the microphone open right now. Windows keeps this list in # the registry for its own privacy indicator; LastUsedTimeStop == 0 means "in use". function Get-MicrophoneUsers { $root = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone' $users = New-Object System.Collections.Generic.List[string] foreach ($key in (Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue)) { if ($key.PSChildName -eq 'NonPackaged') { foreach ($sub in (Get-ChildItem -LiteralPath $key.PSPath -ErrorAction SilentlyContinue)) { $stop = (Get-ItemProperty -LiteralPath $sub.PSPath -ErrorAction SilentlyContinue).LastUsedTimeStop if ($null -ne $stop -and $stop -eq 0) { $exe = ($sub.PSChildName -split '#')[-1] $users.Add([System.IO.Path]::GetFileNameWithoutExtension($exe)) } } } else { $stop = (Get-ItemProperty -LiteralPath $key.PSPath -ErrorAction SilentlyContinue).LastUsedTimeStop if ($null -ne $stop -and $stop -eq 0) { $pkg = ($key.PSChildName -split '_')[0] if ($PackagedProcessNames.ContainsKey($pkg)) { $users.Add($PackagedProcessNames[$pkg]) } else { $users.Add($pkg) } } } } return $users } # --------------------------------------------------------------------------- # The day's data, kept in memory and written out every saveIntervalSec. # --------------------------------------------------------------------------- $Day = $null function New-Day([datetime]$date) { return @{ date = $date.ToString('yyyy-MM-dd') first = $null; last = $null active = 0; attending = 0; idle = 0; meeting = 0; samples = 0 apps = @{} # process -> @{ process; name; active; attending; open; windows = @{ title -> @{...} } } timeline = New-Object System.Collections.Generic.List[object] meetings = New-Object System.Collections.Generic.List[object] } } function Get-App($process, $name) { if (-not $Day.apps.ContainsKey($process)) { $Day.apps[$process] = @{ process = $process; name = $name; active = 0; attending = 0; open = 0; windows = @{} } } return $Day.apps[$process] } function Get-Window($app, $title, $stamp) { if (-not $app.windows.ContainsKey($title)) { $app.windows[$title] = @{ title = $title; active = 0; attending = 0; open = 0; firstSeen = $stamp; lastSeen = $stamp } } $w = $app.windows[$title]; $w.lastSeen = $stamp return $w } function Get-DayFilePath { Join-Path $Config.outputFolder ('{0}-{1}.json' -f $Config.trackerId, $Day.date) } function ConvertTo-DayDocument { $apps = @(foreach ($a in ($Day.apps.Values | Sort-Object { -($_.active + $_.attending) })) { [ordered]@{ process = $a.process; name = $a.name activeSeconds = $a.active; attendingSeconds = $a.attending; openSeconds = $a.open windows = @(foreach ($w in ($a.windows.Values | Sort-Object { -($_.active + $_.attending) })) { [ordered]@{ title = $w.title; activeSeconds = $w.active; attendingSeconds = $w.attending; openSeconds = $w.open; firstSeen = $w.firstSeen; lastSeen = $w.lastSeen } }) } }) $timeline = @(foreach ($s in $Day.timeline) { $o = [ordered]@{ start = $s.start; end = $s.end; process = $s.process } if ($s.title) { $o.title = $s.title } if ($s.state -eq 'attending') { $o.state = 'attending' } $o }) $meetings = @(foreach ($m in $Day.meetings) { [ordered]@{ start = $m.start; end = $m.end; process = $m.process; title = $m.title; source = $m.source } }) return [ordered]@{ schemaVersion = 1 generator = [ordered]@{ name = 'doka-activity-tracker'; version = $TrackerVersion } date = $Day.date trackerId = [string]$Config.trackerId config = [ordered]@{ pollIntervalSec = $Poll; idleThresholdSec = [int]$Config.idleThresholdSec; captureTitles = [bool]$Config.captureTitles meetingDetection = [ordered]@{ microphone = [bool]$Config.meetingDetection.microphone; rules = @($Config.meetingDetection.rules) } } summary = [ordered]@{ firstSample = $Day.first; lastSample = $Day.last activeSeconds = $Day.active; attendingSeconds = $Day.attending; idleSeconds = $Day.idle; meetingSeconds = $Day.meeting; samples = $Day.samples } apps = $apps timeline = $timeline meetings = $meetings } } function Save-Day { if (-not $Day -or $Day.samples -eq 0) { return } if (-not (Test-Path -LiteralPath $Config.outputFolder)) { New-Item -ItemType Directory -Path $Config.outputFolder | Out-Null } $path = Get-DayFilePath $json = ConvertTo-Json -InputObject (ConvertTo-DayDocument) -Depth 12 # Write to a temp file and swap it in, so a crash mid-write never leaves a broken file. $tmp = "$path.tmp" [System.IO.File]::WriteAllText($tmp, $json, (New-Object System.Text.UTF8Encoding($false))) Move-Item -LiteralPath $tmp -Destination $path -Force } # If the tracker was restarted part-way through the day, pick up where it left off. function Import-Day([string]$path) { $doc = Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json if ($doc.schemaVersion -ne 1) { throw "Existing file $path has schemaVersion $($doc.schemaVersion); expected 1" } $Day.first = $doc.summary.firstSample; $Day.last = $doc.summary.lastSample $Day.active = [int]$doc.summary.activeSeconds; $Day.attending = [int]$doc.summary.attendingSeconds $Day.idle = [int]$doc.summary.idleSeconds; $Day.meeting = [int]$doc.summary.meetingSeconds; $Day.samples = [int]$doc.summary.samples foreach ($a in $doc.apps) { $app = Get-App $a.process $a.name $app.active = [int]$a.activeSeconds; $app.attending = [int]$a.attendingSeconds; $app.open = [int]$a.openSeconds foreach ($w in $a.windows) { $app.windows[$w.title] = @{ title = $w.title; active = [int]$w.activeSeconds; attending = [int]$w.attendingSeconds; open = [int]$w.openSeconds; firstSeen = $w.firstSeen; lastSeen = $w.lastSeen } } } foreach ($s in $doc.timeline) { $state = if ($s.process -eq 'idle') { 'idle' } elseif ($s.state) { $s.state } else { 'active' } $key = if ($state -eq 'idle') { 'idle' } else { $state + "`n" + $s.process + "`n" + $s.title } $Day.timeline.Add(@{ key = $key; start = $s.start; end = $s.end; process = $s.process; title = $s.title; state = $state }) } foreach ($m in $doc.meetings) { $Day.meetings.Add(@{ key = ($m.process + "`n" + $m.title); start = $m.start; end = $m.end; process = $m.process; title = $m.title; source = $m.source }) } } # --------------------------------------------------------------------------- # One sample: look at the screen, decide the state, add pollIntervalSec to the # right counters, extend or start timeline segments. # --------------------------------------------------------------------------- function Test-Ignored($process, $title) { foreach ($p in $Config.ignoreProcesses) { if ($process -eq $p) { return $true } } foreach ($t in $Config.ignoreTitles) { if ($title -match $t) { return $true } } return $false } function Read-Screen { # Every visible window: process, friendly name, title. $windows = New-Object System.Collections.Generic.List[object] foreach ($h in [Win32]::VisibleWindows()) { $info = Get-ProcessInfo ([Win32]::Pid($h)) if (-not $info) { continue } $title = [Win32]::Title($h) if (Test-Ignored $info.process $title) { continue } if (-not $Config.captureTitles) { $title = $info.name } $windows.Add(@{ handle = $h; process = $info.process; name = $info.name; title = $title }) } # The focused window. $fg = $null $fgHandle = [Win32]::GetForegroundWindow() if ($fgHandle -ne [IntPtr]::Zero) { $info = Get-ProcessInfo ([Win32]::Pid($fgHandle)) if ($info) { $title = [Win32]::Title($fgHandle) if (-not (Test-Ignored $info.process $title)) { if ([string]::IsNullOrWhiteSpace($title) -or -not $Config.captureTitles) { $title = $info.name } $fg = @{ process = $info.process; name = $info.name; title = $title } } } } # Is a meeting going on? $meeting = $null foreach ($w in $windows) { foreach ($rule in $Config.meetingDetection.rules) { if ($w.process -match $rule.process -and $w.title -match $rule.title -and -not ($rule.exclude -and $w.title -match $rule.exclude)) { $meeting = @{ process = $w.process; name = $w.name; title = $w.title; source = 'window' }; break } } if ($meeting) { break } } if ($Config.meetingDetection.microphone) { $mic = @(Get-MicrophoneUsers) if ($mic.Count -gt 0) { if ($meeting -and $mic -contains $meeting.process) { $meeting.source = 'both' } elseif (-not $meeting) { $proc = $mic[0] $w = $windows | Where-Object { $_.process -eq $proc } | Select-Object -First 1 if ($w) { $meeting = @{ process = $w.process; name = $w.name; title = $w.title; source = 'microphone' } } else { $meeting = @{ process = $proc; name = $proc; title = "$proc (microphone in use)"; source = 'microphone' } } } } } return @{ windows = $windows; foreground = $fg; meeting = $meeting idleSec = [int]([Win32]::IdleMilliseconds() / 1000) } } function Add-Segment($list, $key, $start, $end, $item) { # Extend the last segment when it is the same thing and there was no gap # (a gap means the machine was asleep or the tracker wasn't running). $lastSeg = if ($list.Count -gt 0) { $list[$list.Count - 1] } else { $null } $contiguous = $false if ($lastSeg -and $lastSeg.key -eq $key) { $gap = ([datetime]$start - [datetime]$lastSeg.end).TotalSeconds $contiguous = ($gap -ge -$Poll) -and ($gap -le $Poll) } if ($contiguous) { $lastSeg.end = $end if ($item.source -and $lastSeg.source -and $item.source -ne $lastSeg.source) { $lastSeg.source = 'both' } } else { $seg = @{ key = $key; start = $start; end = $end } foreach ($k in $item.Keys) { $seg[$k] = $item[$k] } $list.Add($seg) } } function Add-Sample([datetime]$now, $screen) { $stamp = Format-Stamp $now $endStamp = Format-Stamp $now.AddSeconds($Poll) if (-not $Day.first) { $Day.first = $stamp } $Day.last = $stamp $Day.samples++ # Open windows: each process and each distinct title gets pollIntervalSec. $seenProc = @{}; $seenWin = @{} foreach ($w in $screen.windows) { $app = Get-App $w.process $w.name if (-not $seenProc[$w.process]) { $app.open += $Poll; $seenProc[$w.process] = $true } $wk = $w.process + "`n" + $w.title if (-not $seenWin[$wk]) { $win = Get-Window $app $w.title $stamp; $win.open += $Poll; $seenWin[$wk] = $true } } $isIdle = $screen.idleSec -ge [int]$Config.idleThresholdSec $m = $screen.meeting if ($m) { $Day.meeting += $Poll Add-Segment $Day.meetings ($m.process + "`n" + $m.title) $stamp $endStamp @{ process = $m.process; title = $m.title; source = $m.source } } if (-not $isIdle) { $f = $screen.foreground if (-not $f) { $f = @{ process = 'shell'; name = 'Windows shell'; title = 'Start menu, taskbar or desktop' } } $Day.active += $Poll $app = Get-App $f.process $f.name; $app.active += $Poll $win = Get-Window $app $f.title $stamp; $win.active += $Poll Add-Segment $Day.timeline ("active`n" + $f.process + "`n" + $f.title) $stamp $endStamp @{ process = $f.process; title = $f.title; state = 'active' } return "active $($f.name) - $($f.title)" } elseif ($m) { $Day.attending += $Poll $app = Get-App $m.process $m.name; $app.attending += $Poll $win = Get-Window $app $m.title $stamp; $win.attending += $Poll Add-Segment $Day.timeline ("attending`n" + $m.process + "`n" + $m.title) $stamp $endStamp @{ process = $m.process; title = $m.title; state = 'attending' } return "attending $($m.title)" } else { $Day.idle += $Poll Add-Segment $Day.timeline 'idle' $stamp $endStamp @{ process = 'idle'; title = $null; state = 'idle' } return "idle ($($screen.idleSec)s without input)" } } # --------------------------------------------------------------------------- # -Once: show what a single sample sees, then exit. Handy for checking that # your apps and meetings are detected the way you expect. # --------------------------------------------------------------------------- if ($Once) { $screen = Read-Screen Write-Host "Idle for: $($screen.idleSec)s (threshold $($Config.idleThresholdSec)s)" if ($screen.foreground) { Write-Host "Focused: [$($screen.foreground.process)] $($screen.foreground.name) - $($screen.foreground.title)" } else { Write-Host "Focused: (nothing / ignored window)" } if ($screen.meeting) { Write-Host "Meeting: [$($screen.meeting.process)] $($screen.meeting.title) (detected by $($screen.meeting.source))" } else { Write-Host "Meeting: none detected" } Write-Host "Open windows:" foreach ($w in ($screen.windows | Sort-Object { $_.process }, { $_.title })) { Write-Host (" [{0}] {1} - {2}" -f $w.process, $w.name, $w.title) } $idNote = if ($Config.trackerId) { $Config.trackerId } else { '(random id will be created on first real run)' } Write-Host "`nTracker id: $idNote" Write-Host "Nothing was saved. Output folder would be: $($Config.outputFolder)" return } # --------------------------------------------------------------------------- # Main loop # --------------------------------------------------------------------------- $StopFlag = Join-Path $ScriptDir 'tracker.stop' $PidFile = Join-Path $ScriptDir 'tracker.pid' Remove-Item -LiteralPath $StopFlag -ErrorAction SilentlyContinue Set-Content -LiteralPath $PidFile -Value $PID $Day = New-Day (Get-Date) if (Test-Path -LiteralPath (Get-DayFilePath)) { try { Import-Day (Get-DayFilePath); Write-Host "Resuming today's file ($($Day.samples) samples so far)." } catch { Write-Warning "Could not read existing $(Get-DayFilePath) ($_); starting fresh."; $Day = New-Day (Get-Date) } } Write-Host "Doka Activity Tracker $TrackerVersion (tracker id: $($Config.trackerId))" Write-Host "Sampling every $Poll s, idle after $($Config.idleThresholdSec) s, saving to $($Config.outputFolder)" Write-Host "Stop with Stop-Tracker.bat or Ctrl+C. Nothing leaves this computer.`n" $lastSave = Get-Date try { while ($true) { $now = Get-Date if ($now.ToString('yyyy-MM-dd') -ne $Day.date) { # midnight: finish yesterday's file, start today's Save-Day; $Day = New-Day $now } $status = Add-Sample $now (Read-Screen) Write-Verbose "$(Format-Stamp $now) $status" if (($now - $lastSave).TotalSeconds -ge [int]$Config.saveIntervalSec) { Save-Day; $lastSave = $now; $ProcessCache.Clear() Write-Host "$($now.ToString('HH:mm:ss')) active $([math]::Round($Day.active/60))m attending $([math]::Round($Day.attending/60))m idle $([math]::Round($Day.idle/60))m | $status" } # Wait out the rest of the interval in short slices, checking for a stop # request as we go. Checking only once per cycle would mean a long poll # interval makes Stop-Tracker.bat look like it did nothing. $remain = [int][math]::Max(250, $Poll * 1000 - ((Get-Date) - $now).TotalMilliseconds) $stopping = $false while ($remain -gt 0) { if (Test-Path -LiteralPath $StopFlag) { $stopping = $true; break } $slice = [int][math]::Min(500, $remain) Start-Sleep -Milliseconds $slice $remain -= $slice } if ($stopping -or (Test-Path -LiteralPath $StopFlag)) { Write-Host 'Stop requested.'; break } } } finally { Save-Day Remove-Item -LiteralPath $StopFlag, $PidFile -ErrorAction SilentlyContinue Write-Host "Saved $(Get-DayFilePath). Bye." }