gitdone-agent 0.8.12 → 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.
Files changed (2) hide show
  1. package/index.js +207 -2
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -32,7 +32,7 @@ import { randomUUID, createHash } from 'node:crypto'
32
32
  // Reported to the server on every sync so the web UI can flag outdated agents.
33
33
  // Keep in lockstep with packages/agent/package.json. The server's offline
34
34
  // fallback is bumped only after this release has actually reached npm.
35
- const AGENT_VERSION = '0.8.12'
35
+ const AGENT_VERSION = '0.8.13'
36
36
 
37
37
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
38
38
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -41,6 +41,7 @@ const CONFIG_BAK_PATH = CONFIG_PATH + '.bak'
41
41
  const STABLE_AGENT = join(AGENT_DIR, 'agent.mjs')
42
42
  const LOG_PATH = join(AGENT_DIR, 'agent.log')
43
43
  const GAME_MONITOR_PATH = join(AGENT_DIR, 'game-monitor.ps1')
44
+ const GAME_OVERLAY_STATE_PATH = join(AGENT_DIR, 'game-overlay-state.json')
44
45
 
45
46
  function ensureAgentDir() {
46
47
  if (!existsSync(AGENT_DIR)) mkdirSync(AGENT_DIR, { recursive: true })
@@ -1221,6 +1222,7 @@ async function pushWowProgress(cfg) {
1221
1222
  const GAME_PROFILES = [
1222
1223
  {
1223
1224
  id: 'ac-black-flag-resynced',
1225
+ shortName: 'Black Flag Resynced',
1224
1226
  processNames: ['ACBlackFlag', 'ACBlackFlag_Plus'],
1225
1227
  sha256: 'EE40622D0F25126A11BF05DBEEC9C128B9B3A67373BC1FA5692E4C4C2D4665E3',
1226
1228
  heartbeatRvas: [0x0CCF9590, 0x0CCF9098],
@@ -1231,11 +1233,91 @@ const GAME_PROFILES = [
1231
1233
  const GAME_REPORT_HEARTBEAT_MS = 5_000
1232
1234
  const gameLastReport = new Map()
1233
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
+ }
1234
1313
 
1235
1314
  function gameMonitorScript() {
1236
1315
  const profiles = Buffer.from(JSON.stringify(GAME_PROFILES), 'utf8').toString('base64')
1316
+ const overlayStatePath = Buffer.from(GAME_OVERLAY_STATE_PATH, 'utf8').toString('base64')
1237
1317
  return String.raw`
1238
1318
  $ErrorActionPreference = 'Stop'
1319
+ Add-Type -AssemblyName System.Windows.Forms
1320
+ Add-Type -AssemblyName System.Drawing
1239
1321
  Add-Type -TypeDefinition @'
1240
1322
  using System;
1241
1323
  using System.Runtime.InteropServices;
@@ -1246,6 +1328,10 @@ public static class GitDoneGameMemory {
1246
1328
  public static extern bool ReadProcessMemory(IntPtr process, IntPtr address, byte[] buffer, int size, out UIntPtr read);
1247
1329
  [DllImport("kernel32.dll")]
1248
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);
1249
1335
  public static UInt32 ReadUInt32(IntPtr process, UInt64 address) {
1250
1336
  var bytes = new byte[4]; UIntPtr read;
1251
1337
  if (!ReadProcessMemory(process, new IntPtr(unchecked((long)address)), bytes, 4, out read) || read.ToUInt64() != 4)
@@ -1256,8 +1342,100 @@ public static class GitDoneGameMemory {
1256
1342
  '@
1257
1343
  $profilesJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${profiles}'))
1258
1344
  $profiles = @($profilesJson | ConvertFrom-Json)
1345
+ $overlayStatePath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${overlayStatePath}'))
1259
1346
  $attached = @{}
1260
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
+
1261
1439
  function Emit-State($profile, $state, $detail) {
1262
1440
  [pscustomobject]@{
1263
1441
  gameId = $profile.id
@@ -1269,6 +1447,8 @@ function Emit-State($profile, $state, $detail) {
1269
1447
  }
1270
1448
 
1271
1449
  while ($true) {
1450
+ [System.Windows.Forms.Application]::DoEvents()
1451
+ Update-Overlay
1272
1452
  foreach ($profile in $profiles) {
1273
1453
  $process = $null
1274
1454
  foreach ($processName in @($profile.processNames)) {
@@ -1352,6 +1532,7 @@ function queueGameReport(cfg, reading) {
1352
1532
  if (!GAME_PROFILES.some((profile) => profile.id === reading?.gameId)) return
1353
1533
  const state = reading.state === 'detecting' ? 'paused' : reading.state
1354
1534
  if (!['playing', 'paused', 'not_running', 'unsupported', 'error'].includes(state)) return
1535
+ observeGameActivity(reading.gameId, state, reading.observedAt)
1355
1536
 
1356
1537
  const now = Date.now()
1357
1538
  const previous = gameLastReport.get(reading.gameId)
@@ -1360,12 +1541,21 @@ function queueGameReport(cfg, reading) {
1360
1541
 
1361
1542
  gameReportQueue = gameReportQueue
1362
1543
  .then(async () => {
1363
- await api(cfg, '/api/v1/games/activity', {
1544
+ const result = await api(cfg, '/api/v1/games/activity', {
1364
1545
  machineId: cfg.machineId,
1365
1546
  gameId: reading.gameId,
1366
1547
  state,
1367
1548
  observedAt: reading.observedAt || new Date().toISOString(),
1549
+ dayStart: localDayStartIso(),
1368
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()
1369
1559
  if (previous?.state !== state) log(`🎮 ${reading.gameId}: ${state}`)
1370
1560
  })
1371
1561
  .catch((err) => log(`✗ game activity sync failed: ${err.message}`))
@@ -1388,6 +1578,16 @@ async function startGameActivityMonitor(cfg, printOnly = false) {
1388
1578
  lines.on('line', (line) => {
1389
1579
  try {
1390
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
+ }
1391
1591
  if (printOnly) console.log(`${reading.observedAt} ${reading.gameId}: ${reading.state} — ${reading.detail}`)
1392
1592
  else queueGameReport(cfg, reading)
1393
1593
  } catch { /* ignore non-JSON PowerShell host noise */ }
@@ -3220,16 +3420,19 @@ async function sync(cfg, discovered) {
3220
3420
  agentVersion: AGENT_VERSION,
3221
3421
  roots: cfg.roots,
3222
3422
  repos: full ? discovered : [],
3423
+ gameDisplays,
3223
3424
  ...(usage ? { usage } : {}),
3224
3425
  ...(codexUsage ? { codexUsage } : {}),
3225
3426
  })
3226
3427
  if (full) { syncRepoSig = sig; syncFullCountdown = SYNC_FULL_TICKS }
3227
3428
  else syncFullCountdown--
3429
+ applyGameOverlaySettings(data.gameOverlay)
3228
3430
  return {
3229
3431
  tracked: data.tracked ?? [],
3230
3432
  latestVersion: data.latestVersion,
3231
3433
  downloadUrl: data.downloadUrl,
3232
3434
  downloadSha256: data.downloadSha256,
3435
+ gameOverlay: data.gameOverlay,
3233
3436
  }
3234
3437
  }
3235
3438
 
@@ -3429,6 +3632,8 @@ async function runLoop(cfg) {
3429
3632
  // Fast command path runs alongside the snapshot poll. Fire-and-forget: it owns
3430
3633
  // its own reconnect loop and never rejects.
3431
3634
  streamCommands(cfg)
3635
+ writeGameOverlayState()
3636
+ setInterval(writeGameOverlayState, 1000)
3432
3637
  startGameActivityMonitor(cfg)
3433
3638
 
3434
3639
  await tick()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.8.12",
3
+ "version": "0.8.13",
4
4
  "description": "Local gitDone companion for repository and optional World of Warcraft progress sync",
5
5
  "type": "module",
6
6
  "files": [