From 524ab33f63a3adf5168fa2987ecf0666a1c9d1ca Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Wed, 27 May 2026 11:36:58 +0300 Subject: [PATCH] fix(activity): restore app-level window stream on rdp host --- aw-server/aw-worktime-ui-bridge.py | 96 ++++++++++++++++++-- aw-server/test_aw_worktime_ui_bridge.py | 67 ++++++++++++++ windows/ActivityWatch.Windows.Common.psm1 | 2 +- windows/browser-domains-native-collector.ps1 | 36 ++++++++ 4 files changed, 190 insertions(+), 11 deletions(-) diff --git a/aw-server/aw-worktime-ui-bridge.py b/aw-server/aw-worktime-ui-bridge.py index 8a853f0..452e0e2 100644 --- a/aw-server/aw-worktime-ui-bridge.py +++ b/aw-server/aw-worktime-ui-bridge.py @@ -27,6 +27,8 @@ 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}" +WEB_CATEGORY_BUCKET = f"aw-detmir-web-category_{HOST}" +COLLECTOR_HEALTH_MAX_AGE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_MAX_AGE_SECONDS", "300")) def _req(method: str, path: str, payload=None): @@ -52,7 +54,7 @@ def ensure_bucket(bucket_id: str, event_type: str, client: str): raise -def get_latest_bucket_event_ts(bucket_id: str): +def get_latest_bucket_event(bucket_id: str): try: events = _req("GET", f"/api/0/buckets/{bucket_id}/events?limit=1") or [] except urllib.error.HTTPError as e: @@ -61,7 +63,14 @@ def get_latest_bucket_event_ts(bucket_id: str): raise if not events: return None - ts = events[0].get("timestamp") + return events[0] + + +def get_latest_bucket_event_ts(bucket_id: str): + event = get_latest_bucket_event(bucket_id) + if not event: + return None + ts = event.get("timestamp") if not ts: return None try: @@ -77,6 +86,29 @@ def bucket_needs_fallback(bucket_id: str, now_utc: datetime, stale_after_seconds return (now_utc - latest_dt).total_seconds() >= stale_after_seconds +def watcher_window_needs_bridge_sync(now_utc: datetime): + latest_event = get_latest_bucket_event(WATCHER_WINDOW_BUCKET) + if latest_event is None: + return True + latest_dt = get_latest_bucket_event_ts(WATCHER_WINDOW_BUCKET) + if latest_dt is None: + return True + if (now_utc - latest_dt).total_seconds() >= WATCHER_FALLBACK_STALE_SECONDS: + return True + + data = latest_event.get("data") or {} + source = str(data.get("source", "")).strip().lower() + app = str(data.get("app", "")).strip() + title = str(data.get("title", "")).strip() + if source != "aw-worktime-ui-bridge": + return False + if app.upper() == "RDP": + return True + if title == "RDP idle" or title.startswith("RDP active"): + return True + return False + + def load_state(): try: with open(STATE_PATH, "r", encoding="utf-8") as f: @@ -116,6 +148,39 @@ def build_window_title(users, active_count): return f"RDP active ({active_count}): " + ", ".join(users) +def get_latest_foreground_context(now_utc: datetime): + try: + events = _req("GET", f"/api/0/buckets/{WEB_CATEGORY_BUCKET}/events?limit=20") or [] + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise + + for event in events: + data = event.get("data") or {} + if str(data.get("signalType", "")).strip().lower() != "collector_health": + continue + foreground_process = str(data.get("foregroundProcess", "")).strip() + foreground_title = str(data.get("foregroundTitle", "")).strip() + if not foreground_process and not foreground_title: + continue + ts = event.get("timestamp") + if not ts: + continue + try: + event_dt = parse_iso_utc(ts) + except Exception: + continue + if (now_utc - event_dt).total_seconds() > COLLECTOR_HEALTH_MAX_AGE_SECONDS: + continue + return { + "app": foreground_process if foreground_process.endswith(".exe") else f"{foreground_process}.exe", + "title": foreground_title or foreground_process, + } + + return None + + def _is_session_active(row_data): if isinstance(row_data.get("active"), bool): if row_data.get("active"): @@ -136,7 +201,7 @@ def _is_session_active(row_data): return False -def transform(events): +def transform(events, foreground_context=None): out_afk = [] out_win = [] last_ts = None @@ -185,11 +250,21 @@ def transform(events): afk_data = {"status": "not-afk" if is_active else "afk", "source": "aw-worktime-ui-bridge"} out_afk.append({"timestamp": ts, "duration": duration, "data": afk_data}) - win_data = { - "app": "RDP", - "title": build_window_title(active_users, active_count), - "source": "aw-worktime-ui-bridge", - } + if is_active and foreground_context: + title = str(foreground_context.get("title") or "").strip() + if active_count > 1: + title = f"{title} | {build_window_title(active_users, active_count)}" if title else build_window_title(active_users, active_count) + win_data = { + "app": str(foreground_context.get("app") or "RDP"), + "title": title or build_window_title(active_users, active_count), + "source": "aw-worktime-ui-bridge", + } + else: + win_data = { + "app": "RDP", + "title": build_window_title(active_users, active_count), + "source": "aw-worktime-ui-bridge", + } out_win.append({"timestamp": ts, "duration": duration, "data": win_data}) last_ts = ts @@ -226,7 +301,8 @@ def main(): if not events: return - afk_events, win_events, new_last_ts = transform(events) + foreground_context = get_latest_foreground_context(now_utc) + afk_events, win_events, new_last_ts = transform(events, foreground_context=foreground_context) if not afk_events or not win_events or not new_last_ts: return @@ -236,7 +312,7 @@ def main(): 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): + if watcher_window_needs_bridge_sync(now_utc): 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}) diff --git a/aw-server/test_aw_worktime_ui_bridge.py b/aw-server/test_aw_worktime_ui_bridge.py index 6b909d3..2bde2b6 100644 --- a/aw-server/test_aw_worktime_ui_bridge.py +++ b/aw-server/test_aw_worktime_ui_bridge.py @@ -30,6 +30,73 @@ class WorktimeUiBridgeTests(unittest.TestCase): 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)) + def test_get_latest_foreground_context_uses_recent_collector_health(self): + now = datetime(2026, 5, 27, 8, 0, 0, tzinfo=timezone.utc) + recent_events = [ + { + "timestamp": "2026-05-27T07:59:30Z", + "data": { + "signalType": "collector_health", + "foregroundProcess": "totalcmd", + "foregroundTitle": "Total Commander 6.01 - HARVEST", + }, + } + ] + with mock.patch.object(MODULE, "_req", return_value=recent_events): + ctx = MODULE.get_latest_foreground_context(now) + self.assertEqual(ctx["app"], "totalcmd.exe") + self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST") + + def test_transform_uses_foreground_context_for_active_sessions(self): + events = [ + { + "timestamp": "2026-05-27T07:59:30Z", + "duration": 0, + "data": { + "username": "user5", + "state": "Активно", + "sessionId": 3, + "sessionName": "rdp-tcp#0", + }, + } + ] + afk_events, win_events, last_ts = MODULE.transform( + events, + foreground_context={"app": "totalcmd.exe", "title": "Total Commander 6.01 - HARVEST"}, + ) + self.assertEqual(afk_events[0]["data"]["status"], "not-afk") + self.assertEqual(win_events[0]["data"]["app"], "totalcmd.exe") + self.assertEqual(win_events[0]["data"]["title"], "Total Commander 6.01 - HARVEST") + self.assertEqual(last_ts, "2026-05-27T07:59:30Z") + + def test_watcher_window_needs_bridge_sync_for_generic_bridge_rdp(self): + now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc) + latest_event = { + "timestamp": "2026-05-27T08:04:40Z", + "data": { + "app": "RDP", + "title": "RDP active (2): user5, администратор", + "source": "aw-worktime-ui-bridge", + }, + } + with mock.patch.object(MODULE, "get_latest_bucket_event", return_value=latest_event), \ + mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)): + self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now)) + + def test_watcher_window_does_not_override_real_watcher_stream(self): + now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc) + latest_event = { + "timestamp": "2026-05-27T08:04:40Z", + "data": { + "app": "notepad.exe", + "title": "Безымянный — Блокнот", + "source": "aw-watcher-window", + }, + } + with mock.patch.object(MODULE, "get_latest_bucket_event", return_value=latest_event), \ + mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)): + self.assertFalse(MODULE.watcher_window_needs_bridge_sync(now)) + if __name__ == "__main__": unittest.main() diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index 4968096..3446905 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -526,7 +526,7 @@ function Get-ActivityWatchSessionRecords { $sessionId = [int]$columns[$sessionIdIndex] $state = if ($columns.Count -gt ($sessionIdIndex + 1)) { [string]$columns[$sessionIdIndex + 1] } else { '' } - $isLive = $state -match '^(Active|Conn)$' + $isLive = $state -match '^(Active|Conn|Активно|Подкл\w*)$' $sessions.Add([pscustomobject]@{ SessionName = $sessionName diff --git a/windows/browser-domains-native-collector.ps1 b/windows/browser-domains-native-collector.ps1 index b9f3eb6..9308e5e 100755 --- a/windows/browser-domains-native-collector.ps1 +++ b/windows/browser-domains-native-collector.ps1 @@ -780,6 +780,39 @@ function Send-Heartbeat { Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -Json $event } +function Send-WindowHeartbeat { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + if (-not $Context) { + return + } + + $processName = [string]$Context.ProcessName + $title = [string]$Context.Title + if ([string]::IsNullOrWhiteSpace($processName) -and [string]::IsNullOrWhiteSpace($title)) { + return + } + + $bucketId = 'aw-watcher-window_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-watcher-window' -BucketType 'currentwindow' + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + app = if ([string]::IsNullOrWhiteSpace($processName)) { 'unknown.exe' } else { "$processName.exe" } + title = $title + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -Json $event +} + function Send-CategoryHeartbeat { param( [string]$Url, @@ -863,6 +896,9 @@ while ($true) { $detectedUrl = $null try { $context = Get-ForegroundWindowContext + if ($context) { + Send-WindowHeartbeat -Context $context + } if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) { $url = Get-BrowserUrlFromWindow -Handle $context.Handle if ($url) {