fix(rdp): decouple activity view from disconnected sessions
This commit is contained in:
+241
-1
@@ -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 =
|
||||
'<h4>RDP summary</h4>' +
|
||||
'<p>Этот блок строится из server-side worktime отчёта и не зависит от того, жива ли локальная интерактивная RDP-сессия.</p>' +
|
||||
'<div class="aw-ru-rdp-grid">' +
|
||||
'<section class="aw-ru-rdp-card"><h5>Активное время</h5><div class="aw-ru-rdp-value">' + escapeHtml(formatDurationSeconds(totalActiveSeconds)) + '</div></section>' +
|
||||
'<section class="aw-ru-rdp-card"><h5>Активных пользователей</h5><div class="aw-ru-rdp-value">' + escapeHtml(String(activeUsers.length)) + '</div></section>' +
|
||||
'<section class="aw-ru-rdp-card"><h5>Последняя активность</h5><div class="aw-ru-rdp-value" style="font-size:16px;">' + escapeHtml(formatIsoForUi(latestActivity)) + '</div></section>' +
|
||||
'</div>' +
|
||||
'<table class="aw-ru-rdp-table">' +
|
||||
'<thead><tr><th>Пользователь</th><th>Активное время</th><th>Первая активность</th><th>Последняя активность</th></tr></thead>' +
|
||||
'<tbody>' +
|
||||
(topRows.length ? topRows.map(function (row) {
|
||||
return '<tr>' +
|
||||
'<td>' + escapeHtml(row.user || row.user_id || "") + '</td>' +
|
||||
'<td>' + escapeHtml(row.active_hhmm || formatDurationSeconds(row.active_seconds || 0)) + '</td>' +
|
||||
'<td>' + escapeHtml(formatIsoForUi(row.first_activity || "")) + '</td>' +
|
||||
'<td>' + escapeHtml(formatIsoForUi(row.last_activity || "")) + '</td>' +
|
||||
'</tr>';
|
||||
}).join("") : '<tr><td colspan="4">Нет активных пользователей в отчёте.</td></tr>') +
|
||||
'</tbody>' +
|
||||
'</table>' +
|
||||
'<div class="aw-ru-rdp-links">' +
|
||||
'<a href="' + escapeHtml(buildRdpReportUrl("html", day)) + '">HTML</a>' +
|
||||
'<a href="' + escapeHtml(buildRdpReportUrl("csv", day)) + '">CSV</a>' +
|
||||
'<a href="' + escapeHtml(buildRdpReportUrl("", day)) + '">JSON</a>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user