gitdone-agent 0.8.11 → 0.8.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +410 -1
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// npx gitdone-agent --key=gdo_xxx --root=C:\path\to\projects --install
|
|
7
7
|
// Optional WoW progress sync:
|
|
8
8
|
// npx gitdone-agent --wow="C:\Program Files (x86)\World of Warcraft\_retail_" --install
|
|
9
|
+
// Supported-game activity tracking is automatic on Windows.
|
|
9
10
|
//
|
|
10
11
|
// Then pick which discovered repos to track from gitdone.eu/github. Add more
|
|
11
12
|
// roots anytime with --root (repeatable). The running agent reads its config
|
|
@@ -31,7 +32,7 @@ import { randomUUID, createHash } from 'node:crypto'
|
|
|
31
32
|
// Reported to the server on every sync so the web UI can flag outdated agents.
|
|
32
33
|
// Keep in lockstep with packages/agent/package.json. The server's offline
|
|
33
34
|
// fallback is bumped only after this release has actually reached npm.
|
|
34
|
-
const AGENT_VERSION = '0.8.
|
|
35
|
+
const AGENT_VERSION = '0.8.13'
|
|
35
36
|
|
|
36
37
|
const AGENT_DIR = join(homedir(), '.gitdone-agent')
|
|
37
38
|
const CONFIG_PATH = join(AGENT_DIR, 'config.json')
|
|
@@ -39,6 +40,8 @@ const CONFIG_PATH = join(AGENT_DIR, 'config.json')
|
|
|
39
40
|
const CONFIG_BAK_PATH = CONFIG_PATH + '.bak'
|
|
40
41
|
const STABLE_AGENT = join(AGENT_DIR, 'agent.mjs')
|
|
41
42
|
const LOG_PATH = join(AGENT_DIR, 'agent.log')
|
|
43
|
+
const GAME_MONITOR_PATH = join(AGENT_DIR, 'game-monitor.ps1')
|
|
44
|
+
const GAME_OVERLAY_STATE_PATH = join(AGENT_DIR, 'game-overlay-state.json')
|
|
42
45
|
|
|
43
46
|
function ensureAgentDir() {
|
|
44
47
|
if (!existsSync(AGENT_DIR)) mkdirSync(AGENT_DIR, { recursive: true })
|
|
@@ -203,6 +206,7 @@ function parseArgs() {
|
|
|
203
206
|
uninstall: 'uninstall' in flags,
|
|
204
207
|
doctor: 'doctor' in flags,
|
|
205
208
|
testWow: flags['test-wow'] ? resolve(flags['test-wow']) : undefined,
|
|
209
|
+
testGames: 'test-games' in flags,
|
|
206
210
|
}
|
|
207
211
|
}
|
|
208
212
|
|
|
@@ -1212,6 +1216,399 @@ async function pushWowProgress(cfg) {
|
|
|
1212
1216
|
// deadline, a proxy/network connection that never completes can block the
|
|
1213
1217
|
// event-posting loop forever, so no heartbeat reaches the server and the
|
|
1214
1218
|
// watchdog incorrectly declares a live AI turn stalled.
|
|
1219
|
+
// Each detector is locked to a verified executable hash and only opens the
|
|
1220
|
+
// process with query/read rights. The first profile reuses gd-657's live-tested
|
|
1221
|
+
// heartbeat. Future games are added here and to the website catalogue.
|
|
1222
|
+
const GAME_PROFILES = [
|
|
1223
|
+
{
|
|
1224
|
+
id: 'ac-black-flag-resynced',
|
|
1225
|
+
shortName: 'Black Flag Resynced',
|
|
1226
|
+
processNames: ['ACBlackFlag', 'ACBlackFlag_Plus'],
|
|
1227
|
+
sha256: 'EE40622D0F25126A11BF05DBEEC9C128B9B3A67373BC1FA5692E4C4C2D4665E3',
|
|
1228
|
+
heartbeatRvas: [0x0CCF9590, 0x0CCF9098],
|
|
1229
|
+
pauseDelayMs: 1350,
|
|
1230
|
+
},
|
|
1231
|
+
]
|
|
1232
|
+
|
|
1233
|
+
const GAME_REPORT_HEARTBEAT_MS = 5_000
|
|
1234
|
+
const gameLastReport = new Map()
|
|
1235
|
+
let gameReportQueue = Promise.resolve()
|
|
1236
|
+
let gameDisplays = []
|
|
1237
|
+
let gameOverlaySettings = {
|
|
1238
|
+
enabled: false,
|
|
1239
|
+
monitor: 0,
|
|
1240
|
+
position: 'top-right',
|
|
1241
|
+
fontFamily: 'Segoe UI',
|
|
1242
|
+
fontSize: 24,
|
|
1243
|
+
transparent: true,
|
|
1244
|
+
}
|
|
1245
|
+
let activeGameOverlay = null
|
|
1246
|
+
let lastOverlayStateJson = ''
|
|
1247
|
+
|
|
1248
|
+
function applyGameOverlaySettings(value) {
|
|
1249
|
+
if (!value || typeof value !== 'object') return
|
|
1250
|
+
gameOverlaySettings = {
|
|
1251
|
+
enabled: value.enabled === true,
|
|
1252
|
+
monitor: Number.isInteger(value.monitor) ? Math.max(0, Math.min(15, value.monitor)) : 0,
|
|
1253
|
+
position: ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'].includes(value.position) ? value.position : 'top-right',
|
|
1254
|
+
fontFamily: typeof value.fontFamily === 'string' ? value.fontFamily.slice(0, 64) : 'Segoe UI',
|
|
1255
|
+
fontSize: Number.isInteger(value.fontSize) ? Math.max(14, Math.min(72, value.fontSize)) : 24,
|
|
1256
|
+
transparent: value.transparent !== false,
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function localDayStartIso() {
|
|
1261
|
+
const day = new Date()
|
|
1262
|
+
day.setHours(0, 0, 0, 0)
|
|
1263
|
+
return day.toISOString()
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function formatGameClock(milliseconds) {
|
|
1267
|
+
const seconds = Math.max(0, Math.floor(milliseconds / 1000))
|
|
1268
|
+
const hh = String(Math.floor(seconds / 3600)).padStart(2, '0')
|
|
1269
|
+
const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0')
|
|
1270
|
+
const ss = String(seconds % 60).padStart(2, '0')
|
|
1271
|
+
return `${hh}:${mm}:${ss}`
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function writeGameOverlayState() {
|
|
1275
|
+
const now = Date.now()
|
|
1276
|
+
const runtime = activeGameOverlay
|
|
1277
|
+
const todayMs = runtime
|
|
1278
|
+
? runtime.todayMs + (runtime.playing ? Math.max(0, now - runtime.asOf) : 0)
|
|
1279
|
+
: 0
|
|
1280
|
+
const profile = runtime ? GAME_PROFILES.find((item) => item.id === runtime.gameId) : null
|
|
1281
|
+
const state = {
|
|
1282
|
+
...gameOverlaySettings,
|
|
1283
|
+
visible: gameOverlaySettings.enabled && runtime?.playing === true,
|
|
1284
|
+
text: profile ? `${profile.shortName} · Днес ${formatGameClock(todayMs)}` : '',
|
|
1285
|
+
}
|
|
1286
|
+
const json = JSON.stringify(state)
|
|
1287
|
+
if (json === lastOverlayStateJson) return
|
|
1288
|
+
lastOverlayStateJson = json
|
|
1289
|
+
try {
|
|
1290
|
+
ensureAgentDir()
|
|
1291
|
+
const temp = GAME_OVERLAY_STATE_PATH + '.tmp'
|
|
1292
|
+
writeFileSync(temp, json, 'utf8')
|
|
1293
|
+
renameSync(temp, GAME_OVERLAY_STATE_PATH)
|
|
1294
|
+
} catch (err) {
|
|
1295
|
+
log(`✗ game overlay state write failed: ${err.message}`)
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function observeGameActivity(gameId, state, observedAt) {
|
|
1300
|
+
const at = Date.parse(observedAt) || Date.now()
|
|
1301
|
+
const playing = state === 'playing'
|
|
1302
|
+
if (!activeGameOverlay || activeGameOverlay.gameId !== gameId) {
|
|
1303
|
+
if (playing) activeGameOverlay = { gameId, playing: true, todayMs: 0, asOf: at }
|
|
1304
|
+
} else if (activeGameOverlay.playing !== playing) {
|
|
1305
|
+
if (activeGameOverlay.playing) {
|
|
1306
|
+
activeGameOverlay.todayMs += Math.max(0, at - activeGameOverlay.asOf)
|
|
1307
|
+
}
|
|
1308
|
+
activeGameOverlay.playing = playing
|
|
1309
|
+
activeGameOverlay.asOf = at
|
|
1310
|
+
}
|
|
1311
|
+
writeGameOverlayState()
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function gameMonitorScript() {
|
|
1315
|
+
const profiles = Buffer.from(JSON.stringify(GAME_PROFILES), 'utf8').toString('base64')
|
|
1316
|
+
const overlayStatePath = Buffer.from(GAME_OVERLAY_STATE_PATH, 'utf8').toString('base64')
|
|
1317
|
+
return String.raw`
|
|
1318
|
+
$ErrorActionPreference = 'Stop'
|
|
1319
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
1320
|
+
Add-Type -AssemblyName System.Drawing
|
|
1321
|
+
Add-Type -TypeDefinition @'
|
|
1322
|
+
using System;
|
|
1323
|
+
using System.Runtime.InteropServices;
|
|
1324
|
+
public static class GitDoneGameMemory {
|
|
1325
|
+
[DllImport("kernel32.dll", SetLastError=true)]
|
|
1326
|
+
public static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
|
|
1327
|
+
[DllImport("kernel32.dll", SetLastError=true)]
|
|
1328
|
+
public static extern bool ReadProcessMemory(IntPtr process, IntPtr address, byte[] buffer, int size, out UIntPtr read);
|
|
1329
|
+
[DllImport("kernel32.dll")]
|
|
1330
|
+
public static extern bool CloseHandle(IntPtr handle);
|
|
1331
|
+
[DllImport("user32.dll", SetLastError=true)]
|
|
1332
|
+
public static extern int GetWindowLong(IntPtr window, int index);
|
|
1333
|
+
[DllImport("user32.dll", SetLastError=true)]
|
|
1334
|
+
public static extern int SetWindowLong(IntPtr window, int index, int value);
|
|
1335
|
+
public static UInt32 ReadUInt32(IntPtr process, UInt64 address) {
|
|
1336
|
+
var bytes = new byte[4]; UIntPtr read;
|
|
1337
|
+
if (!ReadProcessMemory(process, new IntPtr(unchecked((long)address)), bytes, 4, out read) || read.ToUInt64() != 4)
|
|
1338
|
+
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
|
1339
|
+
return BitConverter.ToUInt32(bytes, 0);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
'@
|
|
1343
|
+
$profilesJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${profiles}'))
|
|
1344
|
+
$profiles = @($profilesJson | ConvertFrom-Json)
|
|
1345
|
+
$overlayStatePath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${overlayStatePath}'))
|
|
1346
|
+
$attached = @{}
|
|
1347
|
+
|
|
1348
|
+
$form = New-Object System.Windows.Forms.Form
|
|
1349
|
+
$form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None
|
|
1350
|
+
$form.ShowInTaskbar = $false
|
|
1351
|
+
$form.TopMost = $true
|
|
1352
|
+
$form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual
|
|
1353
|
+
$form.BackColor = [System.Drawing.Color]::Black
|
|
1354
|
+
$form.AutoScaleMode = [System.Windows.Forms.AutoScaleMode]::Dpi
|
|
1355
|
+
$label = New-Object System.Windows.Forms.Label
|
|
1356
|
+
$label.AutoSize = $true
|
|
1357
|
+
$label.ForeColor = [System.Drawing.Color]::White
|
|
1358
|
+
$label.BackColor = [System.Drawing.Color]::Transparent
|
|
1359
|
+
$label.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
|
|
1360
|
+
$form.Controls.Add($label)
|
|
1361
|
+
$null = $form.Handle
|
|
1362
|
+
$extendedStyle = [GitDoneGameMemory]::GetWindowLong($form.Handle, -20)
|
|
1363
|
+
# WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE: never steal a click,
|
|
1364
|
+
# never take keyboard focus from the game, and stay out of Alt+Tab.
|
|
1365
|
+
[GitDoneGameMemory]::SetWindowLong($form.Handle, -20, $extendedStyle -bor 0x20 -bor 0x80 -bor 0x08000000) | Out-Null
|
|
1366
|
+
$form.Hide()
|
|
1367
|
+
$lastOverlayWrite = [DateTime]::MinValue
|
|
1368
|
+
$overlayWasVisible = $false
|
|
1369
|
+
|
|
1370
|
+
$screens = @([System.Windows.Forms.Screen]::AllScreens)
|
|
1371
|
+
$displayRows = @()
|
|
1372
|
+
for ($index = 0; $index -lt $screens.Count; $index++) {
|
|
1373
|
+
$screen = $screens[$index]
|
|
1374
|
+
$displayRows += [pscustomobject]@{
|
|
1375
|
+
index = $index
|
|
1376
|
+
name = $screen.DeviceName
|
|
1377
|
+
primary = $screen.Primary
|
|
1378
|
+
width = $screen.Bounds.Width
|
|
1379
|
+
height = $screen.Bounds.Height
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
[pscustomobject]@{ type='displays'; displays=$displayRows } | ConvertTo-Json -Depth 4 -Compress
|
|
1383
|
+
[Console]::Out.Flush()
|
|
1384
|
+
|
|
1385
|
+
function Update-Overlay {
|
|
1386
|
+
try {
|
|
1387
|
+
if (!(Test-Path -LiteralPath $overlayStatePath)) { $form.Hide(); $script:overlayWasVisible = $false; return }
|
|
1388
|
+
$write = (Get-Item -LiteralPath $overlayStatePath).LastWriteTimeUtc
|
|
1389
|
+
if ($write -eq $lastOverlayWrite) { return }
|
|
1390
|
+
$script:lastOverlayWrite = $write
|
|
1391
|
+
$state = Get-Content -LiteralPath $overlayStatePath -Raw | ConvertFrom-Json
|
|
1392
|
+
if (!$state.enabled -or !$state.visible -or [string]::IsNullOrWhiteSpace($state.text)) {
|
|
1393
|
+
$form.Hide()
|
|
1394
|
+
$script:overlayWasVisible = $false
|
|
1395
|
+
return
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
$screenIndex = [Math]::Max(0, [Math]::Min($screens.Count - 1, [int]$state.monitor))
|
|
1399
|
+
$screen = $screens[$screenIndex]
|
|
1400
|
+
$label.Text = [string]$state.text
|
|
1401
|
+
$label.Font = [System.Drawing.Font]::new([string]$state.fontFamily, [single]$state.fontSize, [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Pixel)
|
|
1402
|
+
$paddingX = if ($state.transparent) { 3 } else { 12 }
|
|
1403
|
+
$paddingY = if ($state.transparent) { 2 } else { 7 }
|
|
1404
|
+
$label.Location = [System.Drawing.Point]::new([int]$paddingX, [int]$paddingY)
|
|
1405
|
+
$form.ClientSize = [System.Drawing.Size]::new([int]($label.PreferredWidth + 2 * $paddingX), [int]($label.PreferredHeight + 2 * $paddingY))
|
|
1406
|
+
if ($state.transparent) {
|
|
1407
|
+
$form.BackColor = [System.Drawing.Color]::Magenta
|
|
1408
|
+
$form.TransparencyKey = [System.Drawing.Color]::Magenta
|
|
1409
|
+
} else {
|
|
1410
|
+
$form.TransparencyKey = [System.Drawing.Color]::Empty
|
|
1411
|
+
$form.BackColor = [System.Drawing.Color]::FromArgb(22, 27, 38)
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
$margin = 24
|
|
1415
|
+
$area = $screen.WorkingArea
|
|
1416
|
+
$x = switch ([string]$state.position) {
|
|
1417
|
+
'top-left' { $area.Left + $margin; break }
|
|
1418
|
+
'bottom-left' { $area.Left + $margin; break }
|
|
1419
|
+
'top-center' { $area.Left + [Math]::Floor(($area.Width - $form.Width) / 2); break }
|
|
1420
|
+
'bottom-center' { $area.Left + [Math]::Floor(($area.Width - $form.Width) / 2); break }
|
|
1421
|
+
default { $area.Right - $form.Width - $margin }
|
|
1422
|
+
}
|
|
1423
|
+
$y = if ([string]$state.position -like 'bottom-*') { $area.Bottom - $form.Height - $margin } else { $area.Top + $margin }
|
|
1424
|
+
$form.Location = [System.Drawing.Point]::new([int]$x, [int]$y)
|
|
1425
|
+
if (!$form.Visible) { $form.Show() }
|
|
1426
|
+
if (!$script:overlayWasVisible -and $form.Visible) {
|
|
1427
|
+
[pscustomobject]@{ type='overlay'; visible=$true; monitor=$screenIndex; position=[string]$state.position } | ConvertTo-Json -Compress
|
|
1428
|
+
[Console]::Out.Flush()
|
|
1429
|
+
$script:overlayWasVisible = $true
|
|
1430
|
+
}
|
|
1431
|
+
} catch {
|
|
1432
|
+
$form.Hide()
|
|
1433
|
+
$script:overlayWasVisible = $false
|
|
1434
|
+
[pscustomobject]@{ type='overlay'; visible=$false; error=$_.Exception.Message } | ConvertTo-Json -Compress
|
|
1435
|
+
[Console]::Out.Flush()
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function Emit-State($profile, $state, $detail) {
|
|
1440
|
+
[pscustomobject]@{
|
|
1441
|
+
gameId = $profile.id
|
|
1442
|
+
state = $state
|
|
1443
|
+
detail = $detail
|
|
1444
|
+
observedAt = [DateTime]::UtcNow.ToString('o')
|
|
1445
|
+
} | ConvertTo-Json -Compress
|
|
1446
|
+
[Console]::Out.Flush()
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
while ($true) {
|
|
1450
|
+
[System.Windows.Forms.Application]::DoEvents()
|
|
1451
|
+
Update-Overlay
|
|
1452
|
+
foreach ($profile in $profiles) {
|
|
1453
|
+
$process = $null
|
|
1454
|
+
foreach ($processName in @($profile.processNames)) {
|
|
1455
|
+
$process = Get-Process -Name $processName -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
1456
|
+
if ($null -ne $process) { break }
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
$entry = $attached[$profile.id]
|
|
1460
|
+
if ($null -eq $process) {
|
|
1461
|
+
if ($null -ne $entry -and $entry.Handle -ne [IntPtr]::Zero) { [GitDoneGameMemory]::CloseHandle($entry.Handle) | Out-Null }
|
|
1462
|
+
$attached.Remove($profile.id)
|
|
1463
|
+
Emit-State $profile 'not_running' 'process not found'
|
|
1464
|
+
continue
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
try {
|
|
1468
|
+
if ($null -eq $entry -or $entry.Pid -ne $process.Id) {
|
|
1469
|
+
if ($null -ne $entry -and $entry.Handle -ne [IntPtr]::Zero) { [GitDoneGameMemory]::CloseHandle($entry.Handle) | Out-Null }
|
|
1470
|
+
$path = $process.MainModule.FileName
|
|
1471
|
+
$hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash
|
|
1472
|
+
if ($hash -ne $profile.sha256) {
|
|
1473
|
+
$entry = [pscustomobject]@{ Pid=$process.Id; Handle=[IntPtr]::Zero; Unsupported=$true }
|
|
1474
|
+
$attached[$profile.id] = $entry
|
|
1475
|
+
Emit-State $profile 'unsupported' 'executable hash does not match a verified profile'
|
|
1476
|
+
continue
|
|
1477
|
+
}
|
|
1478
|
+
$handle = [GitDoneGameMemory]::OpenProcess(0x1010, $false, $process.Id)
|
|
1479
|
+
if ($handle -eq [IntPtr]::Zero) { throw 'OpenProcess failed' }
|
|
1480
|
+
$entry = [pscustomobject]@{
|
|
1481
|
+
Pid = $process.Id
|
|
1482
|
+
Handle = $handle
|
|
1483
|
+
Unsupported = $false
|
|
1484
|
+
Base = [UInt64]$process.MainModule.BaseAddress.ToInt64()
|
|
1485
|
+
LastHeartbeat = $null
|
|
1486
|
+
LastChangeAt = [DateTime]::UtcNow
|
|
1487
|
+
ObservedChange = $false
|
|
1488
|
+
}
|
|
1489
|
+
$attached[$profile.id] = $entry
|
|
1490
|
+
}
|
|
1491
|
+
if ($entry.Unsupported) {
|
|
1492
|
+
Emit-State $profile 'unsupported' 'executable hash does not match a verified profile'
|
|
1493
|
+
continue
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
$heartbeat = [UInt64]0
|
|
1497
|
+
$shift = 0
|
|
1498
|
+
foreach ($rva in @($profile.heartbeatRvas)) {
|
|
1499
|
+
$part = [GitDoneGameMemory]::ReadUInt32($entry.Handle, $entry.Base + [UInt64]$rva)
|
|
1500
|
+
$heartbeat = $heartbeat -bxor ([UInt64]$part -shl $shift)
|
|
1501
|
+
$shift = ($shift + 32) % 64
|
|
1502
|
+
}
|
|
1503
|
+
$now = [DateTime]::UtcNow
|
|
1504
|
+
if ($null -eq $entry.LastHeartbeat) {
|
|
1505
|
+
$entry.LastHeartbeat = $heartbeat
|
|
1506
|
+
$entry.LastChangeAt = $now
|
|
1507
|
+
Emit-State $profile 'detecting' 'waiting for a game heartbeat change'
|
|
1508
|
+
} elseif ($entry.LastHeartbeat -ne $heartbeat) {
|
|
1509
|
+
$entry.LastHeartbeat = $heartbeat
|
|
1510
|
+
$entry.LastChangeAt = $now
|
|
1511
|
+
$entry.ObservedChange = $true
|
|
1512
|
+
Emit-State $profile 'playing' 'game world is updating'
|
|
1513
|
+
} elseif (($now - $entry.LastChangeAt).TotalMilliseconds -ge [int]$profile.pauseDelayMs) {
|
|
1514
|
+
Emit-State $profile 'paused' 'game world is paused or in a menu'
|
|
1515
|
+
} elseif ($entry.ObservedChange) {
|
|
1516
|
+
Emit-State $profile 'playing' 'game world is updating'
|
|
1517
|
+
} else {
|
|
1518
|
+
Emit-State $profile 'detecting' 'waiting for a game heartbeat change'
|
|
1519
|
+
}
|
|
1520
|
+
} catch {
|
|
1521
|
+
if ($null -ne $entry -and $entry.Handle -ne [IntPtr]::Zero) { [GitDoneGameMemory]::CloseHandle($entry.Handle) | Out-Null }
|
|
1522
|
+
$attached.Remove($profile.id)
|
|
1523
|
+
Emit-State $profile 'error' $_.Exception.Message
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
Start-Sleep -Milliseconds 250
|
|
1527
|
+
}
|
|
1528
|
+
`
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function queueGameReport(cfg, reading) {
|
|
1532
|
+
if (!GAME_PROFILES.some((profile) => profile.id === reading?.gameId)) return
|
|
1533
|
+
const state = reading.state === 'detecting' ? 'paused' : reading.state
|
|
1534
|
+
if (!['playing', 'paused', 'not_running', 'unsupported', 'error'].includes(state)) return
|
|
1535
|
+
observeGameActivity(reading.gameId, state, reading.observedAt)
|
|
1536
|
+
|
|
1537
|
+
const now = Date.now()
|
|
1538
|
+
const previous = gameLastReport.get(reading.gameId)
|
|
1539
|
+
if (previous?.state === state && (state !== 'playing' || now - previous.at < GAME_REPORT_HEARTBEAT_MS)) return
|
|
1540
|
+
gameLastReport.set(reading.gameId, { state, at: now })
|
|
1541
|
+
|
|
1542
|
+
gameReportQueue = gameReportQueue
|
|
1543
|
+
.then(async () => {
|
|
1544
|
+
const result = await api(cfg, '/api/v1/games/activity', {
|
|
1545
|
+
machineId: cfg.machineId,
|
|
1546
|
+
gameId: reading.gameId,
|
|
1547
|
+
state,
|
|
1548
|
+
observedAt: reading.observedAt || new Date().toISOString(),
|
|
1549
|
+
dayStart: localDayStartIso(),
|
|
1550
|
+
})
|
|
1551
|
+
applyGameOverlaySettings(result.gameOverlay)
|
|
1552
|
+
if (activeGameOverlay?.gameId === reading.gameId &&
|
|
1553
|
+
activeGameOverlay.playing === (state === 'playing') &&
|
|
1554
|
+
typeof result.todayMs === 'number') {
|
|
1555
|
+
activeGameOverlay.todayMs = Math.max(0, result.todayMs)
|
|
1556
|
+
activeGameOverlay.asOf = Date.parse(result.asOf) || Date.now()
|
|
1557
|
+
}
|
|
1558
|
+
writeGameOverlayState()
|
|
1559
|
+
if (previous?.state !== state) log(`🎮 ${reading.gameId}: ${state}`)
|
|
1560
|
+
})
|
|
1561
|
+
.catch((err) => log(`✗ game activity sync failed: ${err.message}`))
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
async function startGameActivityMonitor(cfg, printOnly = false) {
|
|
1565
|
+
if (process.platform !== 'win32') {
|
|
1566
|
+
if (printOnly) console.log('Game activity detection is currently available on Windows only.')
|
|
1567
|
+
return
|
|
1568
|
+
}
|
|
1569
|
+
for (;;) {
|
|
1570
|
+
try {
|
|
1571
|
+
ensureAgentDir()
|
|
1572
|
+
writeFileSync(GAME_MONITOR_PATH, gameMonitorScript(), 'utf8')
|
|
1573
|
+
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', GAME_MONITOR_PATH], {
|
|
1574
|
+
windowsHide: true,
|
|
1575
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1576
|
+
})
|
|
1577
|
+
const lines = readline.createInterface({ input: child.stdout })
|
|
1578
|
+
lines.on('line', (line) => {
|
|
1579
|
+
try {
|
|
1580
|
+
const reading = JSON.parse(line)
|
|
1581
|
+
if (reading.type === 'displays' && Array.isArray(reading.displays)) {
|
|
1582
|
+
gameDisplays = reading.displays
|
|
1583
|
+
if (printOnly) console.log(`Displays: ${JSON.stringify(gameDisplays)}`)
|
|
1584
|
+
return
|
|
1585
|
+
}
|
|
1586
|
+
if (reading.type === 'overlay') {
|
|
1587
|
+
if (printOnly) console.log(reading.error ? `Overlay error: ${reading.error}` : `Overlay: ${reading.visible ? 'visible' : 'hidden'} on monitor ${Number(reading.monitor) + 1}`)
|
|
1588
|
+
else if (reading.error) log(`✗ game overlay error: ${reading.error}`)
|
|
1589
|
+
return
|
|
1590
|
+
}
|
|
1591
|
+
if (printOnly) console.log(`${reading.observedAt} ${reading.gameId}: ${reading.state} — ${reading.detail}`)
|
|
1592
|
+
else queueGameReport(cfg, reading)
|
|
1593
|
+
} catch { /* ignore non-JSON PowerShell host noise */ }
|
|
1594
|
+
})
|
|
1595
|
+
let stderr = ''
|
|
1596
|
+
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-2000) })
|
|
1597
|
+
const exitCode = await new Promise((resolveExit) => child.once('exit', resolveExit))
|
|
1598
|
+
lines.close()
|
|
1599
|
+
for (const profile of GAME_PROFILES) {
|
|
1600
|
+
const reading = { gameId: profile.id, state: 'error', observedAt: new Date().toISOString() }
|
|
1601
|
+
if (printOnly) console.error(`${reading.observedAt} ${profile.id}: monitor stopped`)
|
|
1602
|
+
else queueGameReport(cfg, reading)
|
|
1603
|
+
}
|
|
1604
|
+
log(`… game monitor stopped (${exitCode}${stderr ? `: ${stderr.trim()}` : ''}); restarting`)
|
|
1605
|
+
} catch (err) {
|
|
1606
|
+
log(`… game monitor unavailable (${err.message}); retrying`)
|
|
1607
|
+
}
|
|
1608
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 3000))
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1215
1612
|
const API_REQUEST_TIMEOUT_MS = 10_000
|
|
1216
1613
|
|
|
1217
1614
|
async function apiOnce(cfg, path, body) {
|
|
@@ -3023,16 +3420,19 @@ async function sync(cfg, discovered) {
|
|
|
3023
3420
|
agentVersion: AGENT_VERSION,
|
|
3024
3421
|
roots: cfg.roots,
|
|
3025
3422
|
repos: full ? discovered : [],
|
|
3423
|
+
gameDisplays,
|
|
3026
3424
|
...(usage ? { usage } : {}),
|
|
3027
3425
|
...(codexUsage ? { codexUsage } : {}),
|
|
3028
3426
|
})
|
|
3029
3427
|
if (full) { syncRepoSig = sig; syncFullCountdown = SYNC_FULL_TICKS }
|
|
3030
3428
|
else syncFullCountdown--
|
|
3429
|
+
applyGameOverlaySettings(data.gameOverlay)
|
|
3031
3430
|
return {
|
|
3032
3431
|
tracked: data.tracked ?? [],
|
|
3033
3432
|
latestVersion: data.latestVersion,
|
|
3034
3433
|
downloadUrl: data.downloadUrl,
|
|
3035
3434
|
downloadSha256: data.downloadSha256,
|
|
3435
|
+
gameOverlay: data.gameOverlay,
|
|
3036
3436
|
}
|
|
3037
3437
|
}
|
|
3038
3438
|
|
|
@@ -3232,6 +3632,9 @@ async function runLoop(cfg) {
|
|
|
3232
3632
|
// Fast command path runs alongside the snapshot poll. Fire-and-forget: it owns
|
|
3233
3633
|
// its own reconnect loop and never rejects.
|
|
3234
3634
|
streamCommands(cfg)
|
|
3635
|
+
writeGameOverlayState()
|
|
3636
|
+
setInterval(writeGameOverlayState, 1000)
|
|
3637
|
+
startGameActivityMonitor(cfg)
|
|
3235
3638
|
|
|
3236
3639
|
await tick()
|
|
3237
3640
|
setInterval(tick, cfg.interval * 1000)
|
|
@@ -3246,6 +3649,12 @@ async function main() {
|
|
|
3246
3649
|
process.exit(0)
|
|
3247
3650
|
}
|
|
3248
3651
|
|
|
3652
|
+
if (args.testGames) {
|
|
3653
|
+
const cfg = buildConfig(args)
|
|
3654
|
+
await startGameActivityMonitor(cfg, true)
|
|
3655
|
+
return
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3249
3658
|
if (args.doctor) {
|
|
3250
3659
|
runDoctor()
|
|
3251
3660
|
process.exit(0)
|