diff --git a/aw-server/aw-ru-patch.js b/aw-server/aw-ru-patch.js index 0e7b585..2f8be73 100755 --- a/aw-server/aw-ru-patch.js +++ b/aw-server/aw-ru-patch.js @@ -344,7 +344,16 @@ '.aw-ru-pve-audit-value { font-size: 24px; font-weight: 700; }', '.aw-ru-pve-audit-table { width: 100%; border-collapse: collapse; margin-top: 8px; }', '.aw-ru-pve-audit-table th, .aw-ru-pve-audit-table td { padding: 6px 8px; border-bottom: 1px solid rgba(120,120,120,.18); vertical-align: top; text-align: left; font-size: 13px; }', - '.aw-ru-pve-audit-muted { opacity: .72; font-size: 13px; }' + '.aw-ru-pve-audit-muted { opacity: .72; font-size: 13px; }', + '.aw-ru-rdp-center { margin: 16px 0; padding: 16px; border: 1px solid rgba(120,120,120,.35); border-radius: 8px; background: rgba(10,20,40,.04); }', + '.aw-ru-rdp-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 12px 0 16px; }', + '.aw-ru-rdp-card { border: 1px solid rgba(120,120,120,.22); border-radius: 8px; padding: 12px; background: rgba(255,255,255,.02); }', + '.aw-ru-rdp-card h5 { margin: 0 0 6px; font-size: 13px; opacity: .8; }', + '.aw-ru-rdp-value { font-size: 24px; font-weight: 700; }', + '.aw-ru-rdp-table { width: 100%; border-collapse: collapse; margin-top: 8px; }', + '.aw-ru-rdp-table th, .aw-ru-rdp-table td { padding: 6px 8px; border-bottom: 1px solid rgba(120,120,120,.18); vertical-align: top; text-align: left; font-size: 13px; }', + '.aw-ru-rdp-links { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }', + '.aw-ru-rdp-links a { display: inline-block; padding: 4px 8px; border-radius: 999px; background: rgba(90,140,255,.15); text-decoration: none; }' ].join("\n"); document.head.appendChild(style); } @@ -370,6 +379,12 @@ return ""; } + function getCurrentActivityDayFromHash() { + const hash = window.location.hash || ""; + const match = hash.match(/#\/activity\/[^/]+\/day\/([^/?#]+)/i); + return match && match[1] ? decodeURIComponent(match[1]) : "today"; + } + function isPveLikeHost(host) { return /^pve[-_]/i.test(String(host || "")); } @@ -384,6 +399,230 @@ return true; } + function isClientActivityRoute() { + const hash = window.location.hash || ""; + const match = hash.match(/^#\/activity\/([^/]+)(?:\/day\/([^/]+))?\/view\/([^/?#]+)/i); + if (!match) return false; + const host = decodeURIComponent(match[1] || ""); + return isLikelyClientHost(host) && !isPveLikeHost(host); + } + + function getRdpReportBaseUrl() { + const url = new URL(window.location.href); + url.hash = ""; + url.search = ""; + url.pathname = "/reports/worktime/today"; + url.port = "5610"; + return url; + } + + function buildRdpReportUrl(format, day) { + const url = getRdpReportBaseUrl(); + url.searchParams.set("day", day || "today"); + if (format) url.searchParams.set("format", format); + return url.toString(); + } + + function normalizeActivityDay(day) { + if (day && day !== "today") return day; + const now = new Date(); + return [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0") + ].join("-"); + } + + function getActivityDayRange(day) { + const normalizedDay = normalizeActivityDay(day); + const start = new Date(normalizedDay + "T00:00:00"); + const end = new Date(normalizedDay + "T23:59:59"); + return { normalizedDay: normalizedDay, start: start, end: end }; + } + + function formatActiveHhmm(totalSeconds) { + const seconds = Math.max(0, Number(totalSeconds) || 0); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return String(hours).padStart(2, "0") + ":" + String(minutes).padStart(2, "0"); + } + + function isWorktimeRowActive(data) { + if (!data || typeof data !== "object") return false; + if (typeof data.active === "boolean") return data.active; + const state = String(data.state || "").trim().toLowerCase(); + return state === "active" || state === "активно"; + } + + function formatDurationSeconds(totalSeconds) { + const seconds = Math.max(0, Number(totalSeconds) || 0); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + if (hours > 0) return hours + "ч " + String(minutes).padStart(2, "0") + "м"; + if (minutes > 0) return minutes + "м " + String(secs).padStart(2, "0") + "с"; + return secs + "с"; + } + + function formatIsoForUi(value) { + if (!value) return "—"; + try { + return new Date(value).toLocaleString(); + } catch (error) { + return value; + } + } + + async function fetchRdpWorktimeReport(host, day) { + if (!host) return null; + const cacheKey = host + "|" + (day || "today"); + if (!window.__awRuRdpReportCache) window.__awRuRdpReportCache = {}; + if (window.__awRuRdpReportCache[cacheKey]) return window.__awRuRdpReportCache[cacheKey]; + const range = getActivityDayRange(day); + const bucketId = "aw-worktime-sessions_" + host; + const params = new URLSearchParams(); + params.set("start", range.start.toISOString()); + params.set("end", new Date(range.end.getTime() + 1000).toISOString()); + params.set("limit", "100000"); + const response = await fetch("/api/0/buckets/" + encodeURIComponent(bucketId) + "/events?" + params.toString(), { credentials: "same-origin" }); + if (!response.ok) throw new Error("rdp-report-fetch-failed"); + const events = await response.json(); + if (!Array.isArray(events)) return null; + const rowsByUser = new Map(); + events.forEach(function (event) { + const data = event && event.data ? event.data : {}; + const ts = event && event.timestamp ? String(event.timestamp) : ""; + if (!ts) return; + const tsDate = new Date(ts); + if (Number.isNaN(tsDate.getTime())) return; + const tsDay = [ + tsDate.getFullYear(), + String(tsDate.getMonth() + 1).padStart(2, "0"), + String(tsDate.getDate()).padStart(2, "0") + ].join("-"); + if (tsDay !== range.normalizedDay) return; + const userId = String(data.userId || ""); + const userName = String(data.username || userId || "").trim(); + if (!userName) return; + const key = userId || userName; + if (!rowsByUser.has(key)) { + rowsByUser.set(key, { + user: userName, + user_id: userId || userName, + active_seconds: 0, + first_activity: "", + last_activity: "", + sessions_count: new Set(), + samples_count: 0, + active_samples: 0 + }); + } + const row = rowsByUser.get(key); + row.samples_count += 1; + if (data.sessionId !== undefined && data.sessionId !== null) row.sessions_count.add(String(data.sessionId)); + if (isWorktimeRowActive(data)) { + const sampleSeconds = Math.max(0, Number(data.sampleSeconds || event.duration || 0)); + row.active_seconds += sampleSeconds; + row.active_samples += 1; + if (!row.first_activity || ts < row.first_activity) row.first_activity = ts; + if (!row.last_activity || ts > row.last_activity) row.last_activity = ts; + } + }); + const payload = { + host: host, + report_date: range.normalizedDay, + rows: Array.from(rowsByUser.values()).map(function (row) { + return { + user: row.user, + user_id: row.user_id, + active_seconds: row.active_seconds, + active_hhmm: formatActiveHhmm(row.active_seconds), + first_activity: row.first_activity, + last_activity: row.last_activity, + sessions_count: row.sessions_count.size, + samples_count: row.samples_count, + active_samples: row.active_samples + }; + }) + }; + window.__awRuRdpReportCache[cacheKey] = payload; + return payload; + } + + async function injectRdpWorktimeCenter(root) { + if (!isClientActivityRoute()) return; + const host = getCurrentHostFromHash(); + const day = getCurrentActivityDayFromHash(); + const report = await fetchRdpWorktimeReport(host, day); + if (!report || !Array.isArray(report.rows) || !report.rows.length) return; + + const totalActiveSeconds = report.rows.reduce(function (sum, row) { + return sum + Math.max(0, Number(row && row.active_seconds || 0)); + }, 0); + const activeUsers = report.rows.filter(function (row) { + return Number(row && row.active_seconds || 0) > 0; + }); + const topRows = activeUsers + .slice() + .sort(function (left, right) { + return Number(right.active_seconds || 0) - Number(left.active_seconds || 0); + }) + .slice(0, 5); + + Array.from(root.querySelectorAll("li")).forEach(function (item) { + const text = (item.textContent || "").trim(); + if (/^(?:Активное время|Time active):/i.test(text)) { + item.textContent = "Активное время: " + formatDurationSeconds(totalActiveSeconds); + } + }); + + const heading = root.querySelector("h3"); + if (!heading || !heading.parentElement) return; + + let center = root.querySelector("[data-aw-ru-rdp-center='1']"); + if (!center) { + center = document.createElement("section"); + center.className = "aw-ru-rdp-center"; + center.setAttribute("data-aw-ru-rdp-center", "1"); + const anchor = heading.parentElement.querySelector("img") || null; + heading.parentElement.insertBefore(center, anchor); + } + + const latestActivity = topRows.reduce(function (latest, row) { + const value = row && row.last_activity ? String(row.last_activity) : ""; + if (!value) return latest; + if (!latest) return value; + return value > latest ? value : latest; + }, ""); + + center.innerHTML = + '

RDP summary

' + + '

Этот блок строится из server-side worktime отчёта и не зависит от того, жива ли локальная интерактивная RDP-сессия.

' + + '
' + + '
Активное время
' + escapeHtml(formatDurationSeconds(totalActiveSeconds)) + '
' + + '
Активных пользователей
' + escapeHtml(String(activeUsers.length)) + '
' + + '
Последняя активность
' + escapeHtml(formatIsoForUi(latestActivity)) + '
' + + '
' + + '' + + '' + + '' + + (topRows.length ? topRows.map(function (row) { + return '' + + '' + + '' + + '' + + '' + + ''; + }).join("") : '') + + '' + + '
ПользовательАктивное времяПервая активностьПоследняя активность
' + escapeHtml(row.user || row.user_id || "") + '' + escapeHtml(row.active_hhmm || formatDurationSeconds(row.active_seconds || 0)) + '' + escapeHtml(formatIsoForUi(row.first_activity || "")) + '' + escapeHtml(formatIsoForUi(row.last_activity || "")) + '
Нет активных пользователей в отчёте.
' + + ''; + } + function enforceSafeActivityViewForPveHost() { const hash = window.location.hash || ""; const match = hash.match(/^#\/activity\/([^/]+)(?:\/day\/([^/]+))?\/view\/([^/?#]+)/i); @@ -1903,6 +2142,7 @@ staticPatchRouteKey = routeKey; } injectPveAuditCenter(document.body); + injectRdpWorktimeCenter(document.body).catch(function () {}); injectDlpNavigation(document.body); if (isDlpSignalBucketRoute() && dlpOverlayFailureCount === 0) { try { diff --git a/aw-server/aw-worktime-ui-bridge.py b/aw-server/aw-worktime-ui-bridge.py index 91ec1b3..8a853f0 100644 --- a/aw-server/aw-worktime-ui-bridge.py +++ b/aw-server/aw-worktime-ui-bridge.py @@ -13,11 +13,20 @@ STATE_PATH = os.environ.get( "/var/lib/activitywatch/aw-worktime-ui-bridge-state.json", ) TIMEOUT = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_TIMEOUT", "20")) +WATCHER_FALLBACK_ENABLED = os.environ.get("AW_WORKTIME_UI_BRIDGE_WATCHER_FALLBACK", "1").strip().lower() not in { + "0", + "false", + "no", + "off", +} +WATCHER_FALLBACK_STALE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_WATCHER_STALE_SECONDS", "600")) SESSIONS_BUCKET = f"aw-worktime-sessions_{HOST}" AFK_BUCKET = f"aw-rdp-afk_{HOST}" WINDOW_BUCKET = f"aw-rdp-window_{HOST}" +WATCHER_AFK_BUCKET = f"aw-watcher-afk_{HOST}" +WATCHER_WINDOW_BUCKET = f"aw-watcher-window_{HOST}" def _req(method: str, path: str, payload=None): @@ -43,6 +52,31 @@ def ensure_bucket(bucket_id: str, event_type: str, client: str): raise +def get_latest_bucket_event_ts(bucket_id: str): + try: + events = _req("GET", f"/api/0/buckets/{bucket_id}/events?limit=1") or [] + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise + if not events: + return None + ts = events[0].get("timestamp") + if not ts: + return None + try: + return parse_iso_utc(ts) + except Exception: + return None + + +def bucket_needs_fallback(bucket_id: str, now_utc: datetime, stale_after_seconds: float): + latest_dt = get_latest_bucket_event_ts(bucket_id) + if latest_dt is None: + return True + return (now_utc - latest_dt).total_seconds() >= stale_after_seconds + + def load_state(): try: with open(STATE_PATH, "r", encoding="utf-8") as f: @@ -198,6 +232,13 @@ def main(): _req("POST", f"/api/0/buckets/{AFK_BUCKET}/events", afk_events) _req("POST", f"/api/0/buckets/{WINDOW_BUCKET}/events", win_events) + if WATCHER_FALLBACK_ENABLED: + if bucket_needs_fallback(WATCHER_AFK_BUCKET, now_utc, WATCHER_FALLBACK_STALE_SECONDS): + ensure_bucket(WATCHER_AFK_BUCKET, "afkstatus", "aw-watcher-afk") + _req("POST", f"/api/0/buckets/{WATCHER_AFK_BUCKET}/events", afk_events) + if bucket_needs_fallback(WATCHER_WINDOW_BUCKET, now_utc, WATCHER_FALLBACK_STALE_SECONDS): + ensure_bucket(WATCHER_WINDOW_BUCKET, "currentwindow", "aw-watcher-window") + _req("POST", f"/api/0/buckets/{WATCHER_WINDOW_BUCKET}/events", win_events) save_state({"last_ts": new_last_ts}) print(f"posted_afk={len(afk_events)} posted_win={len(win_events)} last_ts={new_last_ts}") diff --git a/aw-server/test_aw_worktime_ui_bridge.py b/aw-server/test_aw_worktime_ui_bridge.py new file mode 100644 index 0000000..6b909d3 --- /dev/null +++ b/aw-server/test_aw_worktime_ui_bridge.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +import importlib.util +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest import mock + + +MODULE_PATH = Path(__file__).with_name("aw-worktime-ui-bridge.py") +SPEC = importlib.util.spec_from_file_location("aw_worktime_ui_bridge", MODULE_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class WorktimeUiBridgeTests(unittest.TestCase): + def test_bucket_needs_fallback_when_bucket_is_missing(self): + now = datetime(2026, 5, 22, 16, 0, 0, tzinfo=timezone.utc) + with mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=None): + self.assertTrue(MODULE.bucket_needs_fallback("aw-watcher-window_SHARKON2025", now, 600)) + + def test_bucket_needs_fallback_when_bucket_is_recent(self): + now = datetime(2026, 5, 22, 16, 0, 0, tzinfo=timezone.utc) + recent = now - timedelta(seconds=120) + with mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=recent): + self.assertFalse(MODULE.bucket_needs_fallback("aw-watcher-window_SHARKON2025", now, 600)) + + def test_bucket_needs_fallback_when_bucket_is_stale(self): + now = datetime(2026, 5, 22, 16, 0, 0, tzinfo=timezone.utc) + stale = now - timedelta(seconds=601) + with mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=stale): + self.assertTrue(MODULE.bucket_needs_fallback("aw-watcher-afk_SHARKON2025", now, 600)) + + +if __name__ == "__main__": + unittest.main() diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index 2294465..d0380e5 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -1,4 +1,4 @@ -Set-StrictMode -Version Latest +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Assert-Administrator { @@ -354,17 +354,81 @@ function Get-ActivityWatchLoggedOnUsers { return @($users) } -function Test-ActivityWatchUserHasSession { +function Get-ActivityWatchSessionRecords { + $sessions = New-Object System.Collections.Generic.List[object] + + try { + $lines = & qwinsta.exe 2>$null + foreach ($line in @($lines)) { + $normalized = [string]$line + if ([string]::IsNullOrWhiteSpace($normalized)) { + continue + } + + $normalized = $normalized.TrimStart(' ', '>') + if ([string]::IsNullOrWhiteSpace($normalized)) { + continue + } + + if ($normalized -match '^(SESSIONNAME|ИМЯ СЕАНСА)\s+') { + continue + } + + $columns = @( + (($normalized -replace '\s{2,}', '|') -split '\|') | + ForEach-Object { $_.Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + if ($columns.Count -lt 3) { + continue + } + + $sessionName = [string]$columns[0] + $userName = $null + $sessionIdIndex = 1 + + if ($columns[1] -notmatch '^\d+$') { + $userName = [string]$columns[1] + $sessionIdIndex = 2 + } + + if ($columns.Count -le $sessionIdIndex -or $columns[$sessionIdIndex] -notmatch '^\d+$') { + continue + } + + $sessionId = [int]$columns[$sessionIdIndex] + $state = if ($columns.Count -gt ($sessionIdIndex + 1)) { [string]$columns[$sessionIdIndex + 1] } else { '' } + $isLive = $state -match '^(Active|Conn)$' + + $sessions.Add([pscustomobject]@{ + SessionName = $sessionName + UserName = $userName + SessionId = $sessionId + State = $state + IsLive = $isLive + }) | Out-Null + } + } + catch { + } + + $explorerUsers = Get-ActivityWatchExplorerUsersBySession + foreach ($session in @($sessions.ToArray())) { + $sessionId = [int]$session.SessionId + if ($explorerUsers.ContainsKey($sessionId)) { + $session.UserName = [string]$explorerUsers[$sessionId] + } + } + + return @($sessions.ToArray()) +} + +function Resolve-ActivityWatchUserCandidates { param( [Parameter(Mandatory = $true)] - [string]$UserId, - [string[]]$LoggedOnUsers + [string]$UserId ) - if ([string]::IsNullOrWhiteSpace($UserId)) { - return $false - } - $candidateIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) [void]$candidateIds.Add($UserId) @@ -379,7 +443,21 @@ function Test-ActivityWatchUserHasSession { [void]$candidateIds.Add(('{0}\{1}' -f $env:USERDOMAIN, $leafUser)) } - foreach ($candidate in @($candidateIds)) { + return @($candidateIds) +} + +function Test-ActivityWatchUserHasSession { + param( + [Parameter(Mandatory = $true)] + [string]$UserId, + [string[]]$LoggedOnUsers + ) + + if ([string]::IsNullOrWhiteSpace($UserId)) { + return $false + } + + foreach ($candidate in @(Resolve-ActivityWatchUserCandidates -UserId $UserId)) { if ($LoggedOnUsers -contains $candidate) { return $true } @@ -388,6 +466,34 @@ function Test-ActivityWatchUserHasSession { return $false } +function Test-ActivityWatchUserHasLiveSession { + param( + [Parameter(Mandatory = $true)] + [string]$UserId, + [object[]]$SessionRecords + ) + + if ([string]::IsNullOrWhiteSpace($UserId)) { + return $false + } + + foreach ($candidate in @(Resolve-ActivityWatchUserCandidates -UserId $UserId)) { + if (@($SessionRecords | Where-Object { + $_.IsLive -and + -not [string]::IsNullOrWhiteSpace([string]$_.UserName) -and + ( + [string]$_.UserName -ieq $candidate -or + ('{0}\{1}' -f $env:COMPUTERNAME, [string]$_.UserName) -ieq $candidate -or + ((-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) -and ('{0}\{1}' -f $env:USERDOMAIN, [string]$_.UserName) -ieq $candidate) + ) + }).Count -gt 0) { + return $true + } + } + + return $false +} + function Copy-ActivityWatchCollectorAssets { param( [Parameter(Mandatory = $true)] @@ -1075,6 +1181,7 @@ function Write-ActivityWatchRecoveryScript { [string]$ConfigPath ) + $modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1' $content = @" param( [string]`$ConfigPath = '$ConfigPath' @@ -1082,57 +1189,58 @@ param( Set-StrictMode -Version Latest `$ErrorActionPreference = 'Continue' +Import-Module '$modulePath' -Force +Invoke-ActivityWatchRecoveryLoop -ConfigPath `$ConfigPath +"@ -function Get-DeploymentConfig { - param([string]`$Path) - return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 } -function Get-RecoveryConfigPaths { - param([string]`$PrimaryConfigPath) +function Get-ActivityWatchRecoveryConfigPaths { + param([string]$PrimaryConfigPath) - `$paths = New-Object System.Collections.Generic.List[string] - if (`$PrimaryConfigPath -and (Test-Path -LiteralPath `$PrimaryConfigPath)) { - `$paths.Add((Resolve-Path -LiteralPath `$PrimaryConfigPath).Path) + $paths = New-Object System.Collections.Generic.List[string] + if ($PrimaryConfigPath -and (Test-Path -LiteralPath $PrimaryConfigPath)) { + $paths.Add((Resolve-Path -LiteralPath $PrimaryConfigPath).Path) } - `$searchRoot = `$env:ProgramData - if (`$PrimaryConfigPath) { - `$stateRoot = Split-Path -Path `$PrimaryConfigPath -Parent - `$candidateRoot = Split-Path -Path `$stateRoot -Parent - if (`$candidateRoot -and (Test-Path -LiteralPath `$candidateRoot)) { - `$searchRoot = `$candidateRoot + $searchRoot = $env:ProgramData + if ($PrimaryConfigPath) { + $stateRoot = Split-Path -Path $PrimaryConfigPath -Parent + $candidateRoot = Split-Path -Path $stateRoot -Parent + if ($candidateRoot -and (Test-Path -LiteralPath $candidateRoot)) { + $searchRoot = $candidateRoot } } - if (Test-Path -LiteralPath `$searchRoot) { - Get-ChildItem -LiteralPath `$searchRoot -Directory -ErrorAction SilentlyContinue | - Where-Object { `$_.Name -like 'ActivityWatch*' } | + if (Test-Path -LiteralPath $searchRoot) { + Get-ChildItem -LiteralPath $searchRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like 'ActivityWatch*' } | ForEach-Object { - `$candidate = Join-Path `$_.FullName 'deployment-config.json' - if (Test-Path -LiteralPath `$candidate) { - `$paths.Add(`$candidate) + $candidate = Join-Path $_.FullName 'deployment-config.json' + if (Test-Path -LiteralPath $candidate) { + $paths.Add($candidate) } } } - return @(`$paths | Sort-Object -Unique) + return @($paths | Sort-Object -Unique) } -function Get-RecoveryTaskDefinitions { - param([string[]]`$ConfigPaths) +function Get-ActivityWatchRecoveryTaskDefinitions { + param([string[]]$ConfigPaths) - `$taskMap = [ordered]@{} - foreach (`$candidatePath in @(`$ConfigPaths)) { + $taskMap = [ordered]@{} + foreach ($candidatePath in @($ConfigPaths)) { try { - `$config = Get-DeploymentConfig -Path `$candidatePath - foreach (`$task in @(`$config.userTasks)) { - `$taskName = [string]`$task.launchTaskName - `$userId = [string]`$task.userId - if (-not [string]::IsNullOrWhiteSpace(`$taskName) -and -not `$taskMap.Contains(`$taskName)) { - `$taskMap[`$taskName] = [pscustomobject]@{ - taskName = `$taskName - userId = `$userId + $config = Read-ActivityWatchDeploymentConfig -Path $candidatePath + foreach ($task in @($config.userTasks)) { + $taskName = [string]$task.launchTaskName + $userId = [string]$task.userId + if (-not [string]::IsNullOrWhiteSpace($taskName) -and -not $taskMap.Contains($taskName)) { + $taskMap[$taskName] = [pscustomobject]@{ + taskName = $taskName + userId = $userId } } } @@ -1141,221 +1249,380 @@ function Get-RecoveryTaskDefinitions { } } - return @(`$taskMap.Values) + return @($taskMap.Values) } -function New-RecoveryLock { - param([string]`$PrimaryConfigPath) +function New-ActivityWatchRecoveryLock { + param([string]$PrimaryConfigPath) - `$stateRoot = if (`$PrimaryConfigPath) { Split-Path -Path `$PrimaryConfigPath -Parent } else { Join-Path `$env:ProgramData 'AWatch-rus' } - if (-not (Test-Path -LiteralPath `$stateRoot)) { - New-Item -Path `$stateRoot -ItemType Directory -Force | Out-Null + $stateRoot = if ($PrimaryConfigPath) { Split-Path -Path $PrimaryConfigPath -Parent } else { Join-Path $env:ProgramData 'AWatch-rus' } + if (-not (Test-Path -LiteralPath $stateRoot)) { + New-Item -Path $stateRoot -ItemType Directory -Force | Out-Null } - `$lockPath = Join-Path `$stateRoot 'recovery-loop.lock' - if (Test-Path -LiteralPath `$lockPath) { + $lockPath = Join-Path $stateRoot 'recovery-loop.lock' + if (Test-Path -LiteralPath $lockPath) { try { - `$lockData = Get-Content -LiteralPath `$lockPath -Raw | ConvertFrom-Json - `$existingPid = [int]`$lockData.pid - if (`$existingPid -gt 0 -and (Get-Process -Id `$existingPid -ErrorAction SilentlyContinue)) { - return `$null + $lockData = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json + $existingPid = [int]$lockData.pid + if ($existingPid -gt 0 -and (Get-Process -Id $existingPid -ErrorAction SilentlyContinue)) { + return $null } } catch { } } - `$payload = @{ - pid = `$PID + $payload = @{ + pid = $PID createdAt = (Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json -Compress - Set-Content -LiteralPath `$lockPath -Value `$payload -Encoding UTF8 - return `$lockPath + Set-Content -LiteralPath $lockPath -Value $payload -Encoding UTF8 + return $lockPath } -function Start-TaskIfNotRunning { - param( - [string]`$TaskName, - [string]`$UserId, - [string[]]`$LoggedOnUsers - ) - if ([string]::IsNullOrWhiteSpace(`$TaskName)) { - return - } +function Test-ActivityWatchCollectorRunningGlobal { + param([string]$ScriptPath) - if ([string]::IsNullOrWhiteSpace(`$UserId)) { - return - } - - if (-not (Test-UserHasSession -UserId `$UserId -LoggedOnUsers `$LoggedOnUsers)) { - return - } - - try { - `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue - if (-not `$task) { - return - } - if ([string]`$task.State -eq 'Running') { - return - } - Start-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue - } - catch { - } -} - -function Get-LoggedOnUsers { - `$users = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - - try { - `$lines = & quser.exe 2>`$null - foreach (`$line in @(`$lines)) { - `$normalized = [string]`$line - if ([string]::IsNullOrWhiteSpace(`$normalized)) { - continue - } - - `$normalized = `$normalized.TrimStart(' ', '>') - if ([string]::IsNullOrWhiteSpace(`$normalized)) { - continue - } - - if (`$normalized -match '^(USERNAME|ПОЛЬЗОВАТЕЛЬ)\s+') { - continue - } - - `$parts = `$normalized -split '\s+' - if (`$parts.Count -lt 1) { - continue - } - - `$user = [string]`$parts[0] - if ([string]::IsNullOrWhiteSpace(`$user)) { - continue - } - - [void]`$users.Add(`$user) - [void]`$users.Add(('{0}\{1}' -f `$env:COMPUTERNAME, `$user)) - if (-not [string]::IsNullOrWhiteSpace(`$env:USERDOMAIN)) { - [void]`$users.Add(('{0}\{1}' -f `$env:USERDOMAIN, `$user)) - } - } - } - catch { - } - - return @(`$users) -} - -function Test-UserHasSession { - param( - [string]`$UserId, - [string[]]`$LoggedOnUsers - ) - - if ([string]::IsNullOrWhiteSpace(`$UserId)) { - return `$false - } - - `$candidateIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - [void]`$candidateIds.Add(`$UserId) - - `$leafUser = `$UserId - if (`$leafUser -match '^[^\\]+\\(.+)$') { - `$leafUser = `$Matches[1] - [void]`$candidateIds.Add(`$leafUser) - } - - [void]`$candidateIds.Add(('{0}\{1}' -f `$env:COMPUTERNAME, `$leafUser)) - if (-not [string]::IsNullOrWhiteSpace(`$env:USERDOMAIN)) { - [void]`$candidateIds.Add(('{0}\{1}' -f `$env:USERDOMAIN, `$leafUser)) - } - - foreach (`$candidate in @(`$candidateIds)) { - if (`$LoggedOnUsers -contains `$candidate) { - return `$true - } - } - - return `$false -} - -function Test-CollectorRunningGlobal { - param([string]`$ScriptPath) - if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { - return `$false + if ([string]::IsNullOrWhiteSpace($ScriptPath)) { + return $false } return [bool]@( Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { - (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and - `$_.CommandLine -match [Regex]::Escape(`$ScriptPath) + ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and + $_.CommandLine -match [Regex]::Escape($ScriptPath) } ).Count } -function Start-CollectorScriptGlobalIfNeeded { +function Start-ActivityWatchCollectorScriptGlobalIfNeeded { param( - [string]`$ScriptPath, - [string]`$ConfigPath + [string]$ScriptPath, + [string]$ConfigPath ) - if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + if ([string]::IsNullOrWhiteSpace($ScriptPath)) { return } - if (-not (Test-Path -LiteralPath `$ScriptPath)) { + if (-not (Test-Path -LiteralPath $ScriptPath)) { return } - if (Test-CollectorRunningGlobal -ScriptPath `$ScriptPath) { + if (Test-ActivityWatchCollectorRunningGlobal -ScriptPath $ScriptPath) { return } - `$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' - `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) - Start-Process -FilePath `$powershellExe -ArgumentList `$argumentList -WindowStyle Hidden + $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath, '-ConfigPath', $ConfigPath) + Start-Process -FilePath $powershellExe -ArgumentList $argumentList -WindowStyle Hidden } -`$recoveryLockPath = New-RecoveryLock -PrimaryConfigPath `$ConfigPath -if (-not `$recoveryLockPath) { - return +function Start-ActivityWatchTaskIfNotRunning { + param( + [string]$TaskName, + [string]$UserId, + [object[]]$SessionRecords + ) + + if ([string]::IsNullOrWhiteSpace($TaskName) -or [string]::IsNullOrWhiteSpace($UserId)) { + return $false + } + + if (-not (Test-ActivityWatchUserHasLiveSession -UserId $UserId -SessionRecords $SessionRecords)) { + return $false + } + + try { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { + return $false + } + if ([string]$task.State -eq 'Running') { + return $true + } + Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + return $true + } + catch { + return $false + } } -try { - while (`$true) { - `$sleepSeconds = 180 - try { - `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath - `$config = Get-DeploymentConfig -Path `$ConfigPath - `$loggedOnUsers = Get-LoggedOnUsers - `$stateRoot = [string]`$config.paths.stateRoot - `$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' } - Start-CollectorScriptGlobalIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath - foreach (`$taskDef in Get-RecoveryTaskDefinitions -ConfigPaths `$configPaths) { - Start-TaskIfNotRunning -TaskName `$taskDef.taskName -UserId `$taskDef.userId -LoggedOnUsers `$loggedOnUsers - } +function Get-ActivityWatchLiveInteractiveSessions { + param([object[]]$SessionRecords) - if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { - `$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30) + return @( + $SessionRecords | + Where-Object { + $_.IsLive -and + $_.SessionId -gt 0 -and + -not [string]::IsNullOrWhiteSpace([string]$_.UserName) + } | + Sort-Object @{ Expression = { if ([string]$_.SessionName -ieq 'console') { 0 } else { 1 } } }, @{ Expression = { [int]$_.SessionId } } + ) +} + +function Resolve-ActivityWatchLiveSessionUserId { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$SessionRecord, + [pscustomobject[]]$TaskDefinitions + ) + + $rawUser = [string]$SessionRecord.UserName + if ([string]::IsNullOrWhiteSpace($rawUser)) { + return $null + } + + foreach ($taskDef in @($TaskDefinitions)) { + foreach ($candidate in @(Resolve-ActivityWatchUserCandidates -UserId [string]$taskDef.userId)) { + if ($candidate -ieq $rawUser -or + $candidate -ieq ('{0}\{1}' -f $env:COMPUTERNAME, $rawUser) -or + ((-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) -and $candidate -ieq ('{0}\{1}' -f $env:USERDOMAIN, $rawUser))) { + return [string]$taskDef.userId } } - catch { + } + + if ($rawUser -match '^[^\\]+\\') { + return $rawUser + } + + return ('{0}\{1}' -f $env:COMPUTERNAME, $rawUser) +} + +function Get-ActivityWatchExplorerUsersBySession { + $map = @{} + + try { + Get-Process explorer -IncludeUserName -ErrorAction SilentlyContinue | + Where-Object { $_.SessionId -gt 0 -and -not [string]::IsNullOrWhiteSpace([string]$_.UserName) } | + Sort-Object SessionId, StartTime | + ForEach-Object { + if (-not $map.ContainsKey([int]$_.SessionId)) { + $map[[int]$_.SessionId] = [string]$_.UserName + } + } + } + catch { + } + + return $map +} + +function Get-ActivityWatchDisconnectedInteractiveSessions { + param([object[]]$SessionRecords) + + $explorerUsers = Get-ActivityWatchExplorerUsersBySession + $result = New-Object System.Collections.Generic.List[object] + + foreach ($session in @($SessionRecords | Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 })) { + $resolvedUser = [string]$session.UserName + if ([string]::IsNullOrWhiteSpace($resolvedUser) -and $explorerUsers.ContainsKey([int]$session.SessionId)) { + $resolvedUser = [string]$explorerUsers[[int]$session.SessionId] } - Start-Sleep -Seconds `$sleepSeconds - } -} -finally { - if (`$recoveryLockPath -and (Test-Path -LiteralPath `$recoveryLockPath)) { - Remove-Item -LiteralPath `$recoveryLockPath -Force -ErrorAction SilentlyContinue - } -} -"@ + if ([string]::IsNullOrWhiteSpace($resolvedUser)) { + continue + } - Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 + $result.Add([pscustomobject]@{ + SessionName = [string]$session.SessionName + SessionId = [int]$session.SessionId + State = [string]$session.State + UserName = $resolvedUser + }) | Out-Null + } + + return @($result | Sort-Object SessionId -Unique) +} + +function Promote-ActivityWatchDisconnectedSessionToConsole { + param( + [pscustomobject[]]$TaskDefinitions, + [object[]]$SessionRecords + ) + + $candidates = Get-ActivityWatchDisconnectedInteractiveSessions -SessionRecords $SessionRecords + if (-not $candidates -or $candidates.Count -eq 0) { + return $false + } + + $selected = $null + foreach ($taskDef in @($TaskDefinitions)) { + foreach ($candidate in @($candidates)) { + foreach ($knownUser in @(Resolve-ActivityWatchUserCandidates -UserId [string]$taskDef.userId)) { + if ($knownUser -ieq [string]$candidate.UserName -or + $knownUser -ieq ('{0}\{1}' -f $env:COMPUTERNAME, [string]$candidate.UserName) -or + ((-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) -and $knownUser -ieq ('{0}\{1}' -f $env:USERDOMAIN, [string]$candidate.UserName))) { + $selected = $candidate + break + } + } + if ($selected) { break } + } + if ($selected) { break } + } + + if (-not $selected) { + $selected = $candidates | Select-Object -First 1 + } + + if (-not $selected) { + return $false + } + + try { + & cmd.exe /c ("tscon {0} /dest:console" -f [int]$selected.SessionId) | Out-Null + return ($LASTEXITCODE -eq 0) + } + catch { + return $false + } +} + +function Ensure-ActivityWatchLaunchTaskForUser { + param( + [Parameter(Mandatory = $true)] + [string]$UserId, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + if ([string]::IsNullOrWhiteSpace($UserId) -or -not (Test-Path -LiteralPath $LaunchScriptPath)) { + return $null + } + + $taskName = "ActivityWatch Launch [$((Get-ActivityWatchTaskNameToken -UserId $UserId))]" + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $UserId + $principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + + try { + $existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + if ($existingTask) { + $existingUserId = [string]$existingTask.Principal.UserId + $existingArgs = @($existingTask.Actions | ForEach-Object { [string]$_.Arguments }) -join ' ' + if ($existingUserId -ieq $UserId -and $existingArgs -like "*$launcherPath*") { + return $taskName + } + + Remove-ActivityWatchScheduledTask -TaskName $taskName + } + + Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null + return $taskName + } + catch { + return $null + } +} + +function Start-ActivityWatchConsoleFallbackIfNeeded { + param( + [pscustomobject[]]$TaskDefinitions, + [object[]]$SessionRecords, + [pscustomobject]$Config, + [string]$ConfigPath, + [bool]$ConfiguredLiveTasksStarted + ) + + if ($ConfiguredLiveTasksStarted) { + return + } + + $liveSessions = Get-ActivityWatchLiveInteractiveSessions -SessionRecords $SessionRecords + if (-not $liveSessions -or $liveSessions.Count -eq 0) { + if (Promote-ActivityWatchDisconnectedSessionToConsole -TaskDefinitions $TaskDefinitions -SessionRecords $SessionRecords) { + Start-Sleep -Seconds 3 + $SessionRecords = Get-ActivityWatchSessionRecords + $liveSessions = Get-ActivityWatchLiveInteractiveSessions -SessionRecords $SessionRecords + } + } + if (-not $liveSessions -or $liveSessions.Count -eq 0) { + return + } + + $launchScriptPath = if ($Config.paths.PSObject.Properties.Name -contains 'launchScript') { [string]$Config.paths.launchScript } else { $null } + if ([string]::IsNullOrWhiteSpace($launchScriptPath)) { + return + } + + $preferredSession = $liveSessions | Select-Object -First 1 + $userId = Resolve-ActivityWatchLiveSessionUserId -SessionRecord $preferredSession -TaskDefinitions $TaskDefinitions + if ([string]::IsNullOrWhiteSpace($userId)) { + return + } + + $taskName = Ensure-ActivityWatchLaunchTaskForUser -UserId $userId -LaunchScriptPath $launchScriptPath -ConfigPath $ConfigPath + if ([string]::IsNullOrWhiteSpace($taskName)) { + return + } + + try { + $task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + if ($task -and [string]$task.State -ne 'Running') { + Start-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + } + } + catch { + } +} + +function Invoke-ActivityWatchRecoveryLoop { + param([string]$ConfigPath) + + $recoveryLockPath = New-ActivityWatchRecoveryLock -PrimaryConfigPath $ConfigPath + if (-not $recoveryLockPath) { + return + } + + try { + while ($true) { + $sleepSeconds = 180 + try { + $configPaths = Get-ActivityWatchRecoveryConfigPaths -PrimaryConfigPath $ConfigPath + $config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath + $taskDefs = Get-ActivityWatchRecoveryTaskDefinitions -ConfigPaths $configPaths + $sessionRecords = Get-ActivityWatchSessionRecords + $stateRoot = [string]$config.paths.stateRoot + $sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' } + Start-ActivityWatchCollectorScriptGlobalIfNeeded -ScriptPath $sessionCollectorScript -ConfigPath $ConfigPath + + $configuredLiveTasksStarted = $false + foreach ($taskDef in $taskDefs) { + if (Start-ActivityWatchTaskIfNotRunning -TaskName $taskDef.taskName -UserId $taskDef.userId -SessionRecords $sessionRecords) { + $configuredLiveTasksStarted = $true + } + } + + Start-ActivityWatchConsoleFallbackIfNeeded -TaskDefinitions $taskDefs -SessionRecords $sessionRecords -Config $config -ConfigPath $ConfigPath -ConfiguredLiveTasksStarted $configuredLiveTasksStarted + + if ($config -and $config.recovery -and $config.recovery.intervalSeconds) { + $sleepSeconds = [Math]::Max([int]$config.recovery.intervalSeconds, 30) + } + } + catch { + } + + Start-Sleep -Seconds $sleepSeconds + } + } + finally { + if ($recoveryLockPath -and (Test-Path -LiteralPath $recoveryLockPath)) { + Remove-Item -LiteralPath $recoveryLockPath -Force -ErrorAction SilentlyContinue + } + } } function Get-ActivityWatchHiddenLauncherPath { @@ -1702,10 +1969,10 @@ function Start-ActivityWatchTasks { [string]$RecoveryTaskName = 'ActivityWatch Recovery' ) - $loggedOnUsers = Get-ActivityWatchLoggedOnUsers + $sessionRecords = Get-ActivityWatchSessionRecords foreach ($definition in $TaskDefinitions) { - if (Test-ActivityWatchUserHasSession -UserId $definition.UserId -LoggedOnUsers $loggedOnUsers) { + if (Test-ActivityWatchUserHasLiveSession -UserId $definition.UserId -SessionRecords $sessionRecords) { Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue } }