param( [ValidateSet('Install', 'Repair', 'Run', 'Status', 'Uninstall')] [string]$Mode = 'Run', [string]$PulseUrl = 'https://glabs.kovamedia.io.vn', [string]$LogPath = '', [string]$FirebasePassword = '' ) $ErrorActionPreference = 'Stop' $TaskName = 'G-Labs Pulse Log Sync' $AppDirectory = Join-Path $env:LOCALAPPDATA 'GLabsPulseLogSync' $InstalledScript = Join-Path $AppDirectory 'glabs-pulse-log-agent.ps1' $ConfigPath = Join-Path $AppDirectory 'config.json' $StatePath = Join-Path $AppDirectory 'state.json' $AgentLogPath = Join-Path $AppDirectory 'agent.log' $SourceScript = $PSCommandPath function Find-LogPath { param([string]$RequestedPath) if ($RequestedPath -and (Test-Path -LiteralPath $RequestedPath)) { return (Resolve-Path -LiteralPath $RequestedPath).Path } $roots = @( (Join-Path $env:USERPROFILE 'Documents\G-Labs Studio'), (Join-Path $env:USERPROFILE 'Documents\G-Labs'), (Join-Path $env:APPDATA 'G-Labs Studio'), (Join-Path $env:LOCALAPPDATA 'G-Labs Studio') ) | Where-Object { Test-Path -LiteralPath $_ } $candidate = $roots | ForEach-Object { Get-ChildItem -LiteralPath $_ -File -Recurse -ErrorAction SilentlyContinue } | Where-Object { $_.Extension -in @('.log', '.txt') } | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 if ($candidate) { return $candidate.DirectoryName } throw 'Không tìm thấy thư mục log. Trong G-Labs hãy bấm "Mở thư mục log", rồi chạy cài đặt lại và dán đường dẫn đó.' } function Get-PlainTextSecret { param([string]$EncryptedValue) $secure = ConvertTo-SecureString $EncryptedValue $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } } $script:FirebaseIdToken = '' $script:FirebaseTokenExpiresAt = [DateTime]::MinValue $script:FirebaseLogRecords = New-Object 'System.Collections.Generic.Queue[object]' $script:TaskSignatures = @{} $script:TaskRetention = @{} $script:HealthSignature = '' $script:LastStatusPush = [DateTime]::MinValue $script:LastRetentionSweep = [DateTime]::MinValue function Get-FirebaseIdToken { param([object]$Config) if ($script:FirebaseIdToken -and (Get-Date) -lt $script:FirebaseTokenExpiresAt) { return $script:FirebaseIdToken } $password = (Get-PlainTextSecret ([string]$Config.encrypted_firebase_password)).Trim() $body = @{ email = [string]$Config.firebase_email; password = $password; returnSecureToken = $true } | ConvertTo-Json -Compress try { $result = Invoke-RestMethod -Uri "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=$($Config.firebase_api_key)" -Method Post -ContentType 'application/json' -Body $body -TimeoutSec 20 } catch { $detail = [string]$_.ErrorDetails.Message if (-not $detail -and $_.Exception.Response) { try { $reader = New-Object IO.StreamReader($_.Exception.Response.GetResponseStream()) $detail = $reader.ReadToEnd() $reader.Dispose() } catch {} } if (-not $detail) { $detail = $_.Exception.Message } throw "Firebase login failed: $detail" } $script:FirebaseIdToken = [string]$result.idToken $script:FirebaseTokenExpiresAt = (Get-Date).AddSeconds([Math]::Max(300, [int]$result.expiresIn - 120)) return $script:FirebaseIdToken } function Invoke-FirebaseWrite { param([object]$Config, [string]$Path, [string]$Method, [object]$Value) $token = Get-FirebaseIdToken $Config $uri = "$($Config.firebase_database_url.TrimEnd('/'))/$Path.json?auth=$([Uri]::EscapeDataString($token))" $body = $Value | ConvertTo-Json -Depth 20 -Compress try { Invoke-RestMethod -Uri $uri -Method $Method -ContentType 'application/json; charset=utf-8' -Body $body -TimeoutSec 20 | Out-Null } catch { if ($_.Exception.Response.StatusCode.value__ -eq 401) { $script:FirebaseIdToken = '' } throw } } function Send-FirebaseHeartbeat { param([object]$Config) Invoke-FirebaseWrite -Config $Config -Path 'glabs/status/heartbeat' -Method Put -Value @{ source = $env:COMPUTERNAME updated_at = [DateTimeOffset]::Now.ToUnixTimeSeconds() agent_version = 8 } } function Get-FirebaseTaskKey { param([string]$TaskId) $bytes = [Text.Encoding]::UTF8.GetBytes($TaskId) $hash = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString($hash.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant() } finally { $hash.Dispose() } } function Sync-GLabsTasksToFirebase { param([object]$Config, [string]$ApiKey) $headers = @{ 'X-API-Key' = $ApiKey } $health = Invoke-RestMethod -Uri "$($Config.glabs_api_url.TrimEnd('/'))/api/health" -Headers $headers -TimeoutSec 12 $taskResponse = Invoke-RestMethod -Uri "$($Config.glabs_api_url.TrimEnd('/'))/api/tasks" -Headers $headers -TimeoutSec 12 $tasks = if ($taskResponse.tasks) { @($taskResponse.tasks) } else { @($taskResponse) } $updates = @{} $stateChanged = $false $now = [DateTimeOffset]::Now.ToUnixTimeSeconds() $retentionSeconds = [Math]::Max(1, [int]$Config.retention_hours) * 3600 $cutoff = $now - $retentionSeconds foreach ($knownKey in @($script:TaskRetention.Keys)) { if ([int64]($script:TaskRetention[$knownKey]) -lt $cutoff) { $updates[$knownKey] = $null $script:TaskRetention.Remove($knownKey) $stateChanged = $true } } foreach ($task in $tasks) { $id = [string]$task.task_id if (-not $id) { continue } $key = Get-FirebaseTaskKey $id $active = [string]$task.status -in @('pending', 'running') $metricAt = if ([int64]($task.completed_at) -gt 0) { [int64]($task.completed_at) } else { [int64]($task.created_at) } if ($active -and $metricAt -lt $cutoff) { $metricAt = if ($script:TaskRetention.ContainsKey($key) -and [int64]($script:TaskRetention[$key]) -ge $cutoff) { [int64]($script:TaskRetention[$key]) } else { $now } } if (-not $active -and ($metricAt -le 0 -or $metricAt -lt $cutoff)) { if (-not $script:TaskSignatures.ContainsKey($id) -or $script:TaskSignatures[$id] -ne '__expired__') { $updates[$key] = $null $script:TaskSignatures[$id] = '__expired__' } if ($script:TaskRetention.ContainsKey($key)) { $script:TaskRetention.Remove($key); $stateChanged = $true } continue } $task | Add-Member -NotePropertyName metric_at -NotePropertyValue $metricAt -Force $json = $task | ConvertTo-Json -Depth 12 -Compress $signature = Get-LineId 'task' $id $json if (-not $script:TaskSignatures.ContainsKey($id) -or $script:TaskSignatures[$id] -ne $signature) { $task | Add-Member -NotePropertyName last_seen_at -NotePropertyValue $now -Force $updates[$key] = $task $script:TaskSignatures[$id] = $signature } if (-not $script:TaskRetention.ContainsKey($key) -or [int64]($script:TaskRetention[$key]) -ne $metricAt) { $script:TaskRetention[$key] = $metricAt $stateChanged = $true } } if ($updates.Count -gt 0) { Invoke-FirebaseWrite -Config $Config -Path 'glabs/tasks' -Method Patch -Value $updates } $healthSignature = $health | ConvertTo-Json -Depth 8 -Compress if ($healthSignature -ne $script:HealthSignature -or ((Get-Date) - $script:LastStatusPush).TotalSeconds -ge 30) { Invoke-FirebaseWrite -Config $Config -Path 'glabs/status' -Method Patch -Value @{ health = $health heartbeat = @{ source = $env:COMPUTERNAME; updated_at = $now } } $script:HealthSignature = $healthSignature $script:LastStatusPush = Get-Date } return $stateChanged } function Get-LineId { param([string]$FilePath, [string]$SequenceToken, [string]$Line) $bytes = [Text.Encoding]::UTF8.GetBytes("$FilePath|$SequenceToken|$Line") $hash = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString($hash.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant() } finally { $hash.Dispose() } } function Get-CircularTimeDistanceSeconds { param([double]$First, [double]$Second) $difference = [Math]::Abs($First - $Second) % 86400 return [Math]::Min($difference, 86400 - $difference) } function Test-LogTimestampsAreUtc { param([System.IO.FileInfo]$File, [System.Collections.Generic.List[string]]$Lines) $offsetMinutes = [int][TimeZoneInfo]::Local.GetUtcOffset([DateTime]::Now).TotalMinutes if ($offsetMinutes -eq 0 -or -not $Lines -or $Lines.Count -eq 0) { return $false } for ($index = $Lines.Count - 1; $index -ge 0; $index--) { if ($Lines[$index] -match '^\[(\d{2}):(\d{2}):(\d{2})\]') { $rawSeconds = ([int]$Matches[1] * 3600) + ([int]$Matches[2] * 60) + [int]$Matches[3] $referenceSeconds = $File.LastWriteTime.TimeOfDay.TotalSeconds $asLocalDistance = Get-CircularTimeDistanceSeconds $rawSeconds $referenceSeconds $convertedSeconds = ($rawSeconds + ($offsetMinutes * 60) + 86400) % 86400 $asUtcDistance = Get-CircularTimeDistanceSeconds $convertedSeconds $referenceSeconds # Require a clear improvement so already-local timestamps are never # shifted merely because the file was copied or briefly inactive. return ($asUtcDistance + 900 -lt $asLocalDistance) } } return $false } function Convert-UtcPrefixToLocal { param([string]$Line) if ($Line -notmatch '^\[(\d{2}):(\d{2}):(\d{2})\](.*)$') { return $Line } $offset = [TimeZoneInfo]::Local.GetUtcOffset([DateTime]::Now) $time = [DateTime]::Today.AddHours([int]$Matches[1]).AddMinutes([int]$Matches[2]).AddSeconds([int]$Matches[3]).Add($offset) return "[$($time.ToString('HH:mm:ss'))]$($Matches[4])" } function Save-JsonFile { param([string]$Path, [object]$Value) $Value | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $Path -Encoding UTF8 } function Save-AgentState { param([hashtable]$FileOffsets) $taskRetention = [ordered]@{} foreach ($key in $script:TaskRetention.Keys) { $taskRetention[$key] = [int64]($script:TaskRetention[$key]) } Save-JsonFile $StatePath ([ordered]@{ version = 3 files = $FileOffsets log_records = @($script:FirebaseLogRecords.ToArray()) tasks = $taskRetention }) } function Write-AgentDiagnostic { param([string]$Message) try { if ((Test-Path -LiteralPath $AgentLogPath) -and (Get-Item -LiteralPath $AgentLogPath).Length -gt 1048576) { Move-Item -LiteralPath $AgentLogPath -Destination "$AgentLogPath.old" -Force } Add-Content -LiteralPath $AgentLogPath -Encoding UTF8 -Value "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $Message" } catch {} } function Add-GLabsAutomationNames { param( [System.Windows.Automation.AutomationElement]$Window, [System.Collections.Generic.List[string]]$Names ) $nodes = $Window.FindAll( [System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition ) $visited = 0 # Large Google account tables can contain well over 6,000 automation # nodes. Scan the complete tree, but only retain text that can be useful to # the Usage parser so the HTTP payload remains compact. for ($nodeIndex = 0; $nodeIndex -lt $nodes.Count -and $visited -lt 30000 -and $Names.Count -lt 8000; $nodeIndex++) { $visited++ try { $name = [string]$nodes.Item($nodeIndex).Current.Name if (-not $name -or -not $name.Trim()) { continue } $value = $name.Trim() if ( $value -match '@' -or $value -match '(?i)Đang hoạt động|Dang hoat dong|Tín dụng Flow|Tin dung Flow|Thời lượng Vids|Thoi luong Vids|Số lượt Pics|So luot Pics|Free:\s*đã dùng|Free:\s*da dung|Token:\s*còn|Token:\s*con' -or $value -match '(?i)^(?:FREE|PRO|ULTRA(?:\s*x?\d+)?)$' -or $value -match '^\d[\d.,]*(?:\s*(?:giây|ảnh|tài khoản|K|M|B))?$' -or $value -match '^\d[\d.,]*\s*/\s*\d[\d.,]*$' -or $value -match '^\d{1,2}/\d{1,2}/\d{4}' ) { $Names.Add($value) } } catch {} } } function Get-GLabsNavigationItem { param([System.Windows.Automation.AutomationElement]$Window, [string]$Name) $condition = [System.Windows.Automation.PropertyCondition]::new( [System.Windows.Automation.AutomationElement]::NameProperty, $Name ) return $Window.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $condition) } function Test-GLabsNavigationSelected { param([System.Windows.Automation.AutomationElement]$Item) if (-not $Item) { return $false } try { $pattern = $Item.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) return [bool]$pattern.Current.IsSelected } catch { return $false } } function Invoke-GLabsNavigationItem { param([System.Windows.Automation.AutomationElement]$Item) if (-not $Item) { return $false } try { $pattern = $Item.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) $pattern.Select() return $true } catch {} try { $pattern = $Item.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) $pattern.Invoke() return $true } catch {} try { $pattern = $Item.GetCurrentPattern([System.Windows.Automation.LegacyIAccessiblePattern]::Pattern) $pattern.DoDefaultAction() return $true } catch { return $false } } function Get-GLabsUsageSnapshot { try { Add-Type -AssemblyName UIAutomationClient -ErrorAction SilentlyContinue Add-Type -AssemblyName UIAutomationTypes -ErrorAction SilentlyContinue $desktop = [System.Windows.Automation.AutomationElement]::RootElement $windows = $desktop.FindAll( [System.Windows.Automation.TreeScope]::Children, [System.Windows.Automation.Condition]::TrueCondition ) $names = New-Object 'System.Collections.Generic.List[string]' for ($windowIndex = 0; $windowIndex -lt $windows.Count; $windowIndex++) { $window = $windows.Item($windowIndex) $windowName = [string]$window.Current.Name if ($windowName -notmatch '(?i)G-Labs|Cài đặt hệ thống|Cai dat he thong') { continue } if ($windowName) { $names.Add($windowName) } $googleItem = Get-GLabsNavigationItem -Window $window -Name 'Tài khoản Google' $museItem = Get-GLabsNavigationItem -Window $window -Name 'Tài khoản Muse' $originalItem = if (Test-GLabsNavigationSelected $googleItem) { $googleItem } elseif (Test-GLabsNavigationSelected $museItem) { $museItem } else { $null } $capturedSection = $false foreach ($item in @($googleItem, $museItem)) { if (-not $item) { continue } if (Invoke-GLabsNavigationItem $item) { Start-Sleep -Milliseconds 450 Add-GLabsAutomationNames -Window $window -Names $names $capturedSection = $true } } if ($originalItem) { [void](Invoke-GLabsNavigationItem $originalItem) } if (-not $capturedSection) { Add-GLabsAutomationNames -Window $window -Names $names } } if ($names.Count -eq 0) { return $null } return [ordered]@{ captured_at = [DateTimeOffset]::Now.ToUnixTimeSeconds() computer = $env:COMPUTERNAME elements = @($names) } } catch { Write-AgentDiagnostic "Usage snapshot: $($_.Exception.Message)" return $null } } function Send-GLabsUsageSnapshot { param([object]$Config, [string]$ApiKey) $snapshot = Get-GLabsUsageSnapshot if (-not $snapshot) { return } $body = $snapshot | ConvertTo-Json -Depth 5 -Compress Invoke-RestMethod -Uri "$($Config.pulse_url)/api/collector/usage" -Method Post ` -Headers @{ 'X-API-Key' = $ApiKey } -ContentType 'application/json; charset=utf-8' ` -Body $body -TimeoutSec 20 | Out-Null } function Send-CollectorHeartbeat { param([object]$Config, [string]$ApiKey) $body = @{ source = $env:COMPUTERNAME } | ConvertTo-Json -Compress Invoke-RestMethod -Uri "$($Config.pulse_url)/api/collector/heartbeat" -Method Post ` -Headers @{ 'X-API-Key' = $ApiKey } -ContentType 'application/json; charset=utf-8' ` -Body $body -TimeoutSec 20 | Out-Null } function Register-AgentTask { $arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$InstalledScript`" -Mode Run" $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments $logonTrigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME # This watchdog trigger is ignored while the agent is healthy. If the # PowerShell process exits, Task Scheduler starts it again within 5 minutes. $watchdogTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` -RepetitionInterval (New-TimeSpan -Minutes 5) ` -RepetitionDuration (New-TimeSpan -Days 3650) $settings = New-ScheduledTaskSettingsSet ` -RestartCount 999 ` -RestartInterval (New-TimeSpan -Minutes 1) ` -ExecutionTimeLimit ([TimeSpan]::Zero) ` -MultipleInstances IgnoreNew ` -StartWhenAvailable ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -WakeToRun Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger @($logonTrigger, $watchdogTrigger) -Settings $settings -Description 'Đồng bộ nguyên văn nhật ký G-Labs lên G-Labs Pulse; tự phục hồi mỗi 5 phút.' -Force | Out-Null } function Install-Agent { New-Item -ItemType Directory -Path $AppDirectory -Force | Out-Null $resolvedLogPath = $null if ($LogPath) { $resolvedLogPath = Find-LogPath $LogPath } else { try { $resolvedLogPath = Find-LogPath '' } catch {} if ($resolvedLogPath) { Write-Host "Đã tìm thấy thư mục log: $resolvedLogPath" -ForegroundColor Green $answer = Read-Host 'Nhấn Enter để dùng thư mục này, hoặc dán đường dẫn khác' if ($answer) { $resolvedLogPath = Find-LogPath $answer } } else { Write-Host 'Trong G-Labs hãy bấm "Mở thư mục log" và sao chép đường dẫn thư mục.' -ForegroundColor Yellow $answer = Read-Host 'Dán đường dẫn thư mục log' $resolvedLogPath = Find-LogPath $answer } } $secureKey = Read-Host 'Dán API key trong tab Webhook API của G-Labs' -AsSecureString $encryptedKey = ConvertFrom-SecureString $secureKey if ($FirebasePassword) { $firebasePasswordSecure = ConvertTo-SecureString $FirebasePassword.Trim() -AsPlainText -Force } else { $firebasePasswordSecure = Read-Host 'Dán mật khẩu đồng bộ agent (không phải mật khẩu official.tungnt)' -AsSecureString } $encryptedFirebasePassword = ConvertFrom-SecureString $firebasePasswordSecure Copy-Item -LiteralPath $SourceScript -Destination $InstalledScript -Force Save-JsonFile $ConfigPath ([ordered]@{ pulse_url = $PulseUrl.TrimEnd('/') log_path = $resolvedLogPath encrypted_api_key = $encryptedKey glabs_api_url = 'https://api-glabs.kovamedia.io.vn' firebase_api_key = 'AIzaSyAnstwTjTq9BK8gQLJgIrLmv0phGU0uV_Y' firebase_database_url = 'https://gen-lang-client-0430568609-default-rtdb.asia-southeast1.firebasedatabase.app' firebase_email = 'glabs-agent@kovamedia.io.vn' encrypted_firebase_password = $encryptedFirebasePassword poll_seconds = 1 usage_poll_seconds = 0 heartbeat_seconds = 30 retention_hours = 24 log_max_rows = 1200 }) Register-AgentTask Start-ScheduledTask -TaskName $TaskName Write-Host 'Đã cài và khởi động đồng bộ log G-Labs Pulse.' -ForegroundColor Green } function Repair-Agent { if (-not (Test-Path -LiteralPath $ConfigPath)) { throw 'Chưa có cấu hình cũ để sửa. Hãy chạy lại với -Mode Install.' } New-Item -ItemType Directory -Path $AppDirectory -Force | Out-Null Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue $sourceFullPath = (Resolve-Path -LiteralPath $SourceScript).Path $installedFullPath = if (Test-Path -LiteralPath $InstalledScript) { (Resolve-Path -LiteralPath $InstalledScript).Path } else { $InstalledScript } if ($sourceFullPath -ne $installedFullPath) { Copy-Item -LiteralPath $SourceScript -Destination $InstalledScript -Force } $config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json if ($config.PSObject.Properties.Name -contains 'poll_seconds') { $config.poll_seconds = 1 } else { $config | Add-Member -NotePropertyName poll_seconds -NotePropertyValue 1 } if ($config.PSObject.Properties.Name -contains 'usage_poll_seconds') { $config.usage_poll_seconds = 0 } else { $config | Add-Member -NotePropertyName usage_poll_seconds -NotePropertyValue 0 } if ($config.PSObject.Properties.Name -contains 'heartbeat_seconds') { $config.heartbeat_seconds = 30 } else { $config | Add-Member -NotePropertyName heartbeat_seconds -NotePropertyValue 30 } if ($config.PSObject.Properties.Name -contains 'retention_hours') { $config.retention_hours = 24 } else { $config | Add-Member -NotePropertyName retention_hours -NotePropertyValue 24 } if ($config.PSObject.Properties.Name -contains 'log_max_rows') { $config.log_max_rows = 1200 } else { $config | Add-Member -NotePropertyName log_max_rows -NotePropertyValue 1200 } if ($FirebasePassword) { $firebasePasswordSecure = ConvertTo-SecureString $FirebasePassword.Trim() -AsPlainText -Force } else { $firebasePasswordSecure = Read-Host 'Dán mật khẩu đồng bộ agent (không phải mật khẩu official.tungnt)' -AsSecureString } $encryptedFirebasePassword = ConvertFrom-SecureString $firebasePasswordSecure foreach ($entry in @{ glabs_api_url='https://api-glabs.kovamedia.io.vn'; firebase_api_key='AIzaSyAnstwTjTq9BK8gQLJgIrLmv0phGU0uV_Y'; firebase_database_url='https://gen-lang-client-0430568609-default-rtdb.asia-southeast1.firebasedatabase.app'; firebase_email='glabs-agent@kovamedia.io.vn'; encrypted_firebase_password=$encryptedFirebasePassword }.GetEnumerator()) { if ($config.PSObject.Properties.Name -contains $entry.Key) { $config.($entry.Key) = $entry.Value } else { $config | Add-Member -NotePropertyName $entry.Key -NotePropertyValue $entry.Value } } Save-JsonFile $ConfigPath $config Write-Host 'Đang kiểm tra đăng nhập và ghi heartbeat Firebase...' -ForegroundColor Cyan $script:FirebaseIdToken = '' $script:FirebaseTokenExpiresAt = [DateTime]::MinValue [void](Get-FirebaseIdToken -Config $config) Send-FirebaseHeartbeat -Config $config Invoke-FirebaseWrite -Config $config -Path 'glabs/tasks' -Method Delete -Value $null Invoke-FirebaseWrite -Config $config -Path 'glabs/logs' -Method Delete -Value $null Write-Host 'Firebase hợp lệ; lịch sử cũ đã xóa và heartbeat đã được ghi.' -ForegroundColor Green # The previous state contains Cloudflare-era byte offsets. Preserve it for # rollback, then rebuild a bounded recent-log window for Firebase. if (Test-Path -LiteralPath $StatePath) { Move-Item -LiteralPath $StatePath -Destination "$StatePath.pre-firebase" -Force } Register-AgentTask Start-ScheduledTask -TaskName $TaskName Write-AgentDiagnostic 'Agent repaired and started.' Write-Host 'Đã sửa và khởi động lại đồng bộ. Không cần nhập lại API key.' -ForegroundColor Green } function Run-Agent { if (-not (Test-Path -LiteralPath $ConfigPath)) { throw 'Chưa cài đặt cấu hình đồng bộ.' } $config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json $apiKey = Get-PlainTextSecret $config.encrypted_api_key $lastUsagePush = [DateTime]::MinValue $lastHeartbeatPush = [DateTime]::MinValue $lastTaskPush = [DateTime]::MinValue $fileOffsets = @{} if (Test-Path -LiteralPath $StatePath) { try { $saved = Get-Content -LiteralPath $StatePath -Raw -Encoding UTF8 | ConvertFrom-Json # Version 1 stored line numbers and required re-reading the complete # file every three seconds. Version 2 tails files by byte offset. if ([int]$saved.version -ge 2 -and $saved.files) { $saved.files.psobject.Properties | ForEach-Object { $fileOffsets[$_.Name] = [int64]$_.Value } } if ([int]$saved.version -ge 3) { foreach ($record in @($saved.log_records)) { if ($record.key) { $script:FirebaseLogRecords.Enqueue([pscustomobject]@{ key = [string]$record.key; received_at = [int64]$record.received_at }) } } if ($saved.tasks) { $saved.tasks.psobject.Properties | ForEach-Object { $script:TaskRetention[$_.Name] = [int64]$_.Value } } } } catch { $fileOffsets = @{} } } while ($true) { try { $heartbeatInterval = if ($config.heartbeat_seconds) { [Math]::Max(15, [int]$config.heartbeat_seconds) } else { 30 } if (((Get-Date) - $lastHeartbeatPush).TotalSeconds -ge $heartbeatInterval) { Send-FirebaseHeartbeat -Config $config $lastHeartbeatPush = Get-Date } } catch { Write-AgentDiagnostic "Firebase heartbeat: $($_.Exception.Message)" } try { $directory = Find-LogPath ([string]$config.log_path) $files = if (Test-Path -LiteralPath $directory -PathType Leaf) { @(Get-Item -LiteralPath $directory) } else { $availableLogs = @(Get-ChildItem -LiteralPath $directory -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in @('.log', '.txt') }) $canonicalLog = $availableLogs | Where-Object { $_.Name -ieq 'latest.log' } | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 if ($canonicalLog) { @($canonicalLog) } else { @($availableLogs | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1) } } # latest.log is the canonical stream shown by G-Labs Studio. The # app_*.log files can duplicate the same events using UTC timestamps. foreach ($file in ($files | Sort-Object LastWriteTimeUtc -Descending)) { $stream = [IO.File]::Open($file.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Read, ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) try { $offset = if ($fileOffsets.ContainsKey($file.FullName)) { [int64]$fileOffsets[$file.FullName] } else { 0 } if ($offset -lt 0 -or $offset -gt $stream.Length) { $offset = 0 } # On first observation (or after rotation), retain only the # newest 512 KiB. Firebase only keeps a bounded live window; # Pulse must catch up to the live tail immediately. if ($offset -eq 0 -and $stream.Length -gt 524288) { $offset = $stream.Length - 524288 } [void]$stream.Seek($offset, [IO.SeekOrigin]::Begin) $reader = [IO.StreamReader]::new($stream, [Text.Encoding]::UTF8, $true, 4096, $true) try { if ($offset -gt 0 -and -not $fileOffsets.ContainsKey($file.FullName)) { [void]$reader.ReadLine() # discard a partial first line } $newLines = New-Object 'System.Collections.Generic.List[string]' while (($line = $reader.ReadLine()) -ne $null) { $newLines.Add([string]$line) } $nextOffset = [int64]$stream.Position } finally { $reader.Dispose() } } finally { $stream.Dispose() } $timestampsAreUtc = Test-LogTimestampsAreUtc $file $newLines $cursor = 0 while ($cursor -lt $newLines.Count) { $take = [Math]::Min(100, $newLines.Count - $cursor) $items = @() for ($index = 0; $index -lt $take; $index++) { $lineIndex = $cursor + $index $rawLine = [string]$newLines[$lineIndex] $line = if ($timestampsAreUtc) { Convert-UtcPrefixToLocal $rawLine } else { $rawLine } $items += [ordered]@{ event_id = Get-LineId $file.FullName "$offset|$lineIndex" $rawLine text = $line file_name = $file.Name } } $receivedAt = [DateTimeOffset]::Now.ToUnixTimeSeconds() $firebaseUpdates = @{} foreach ($item in $items) { $key = [string]$item.event_id $firebaseUpdates[$key] = @{ line_text = [string]$item.text; file_name = [string]$item.file_name; received_at = $receivedAt; source = $env:COMPUTERNAME } $script:FirebaseLogRecords.Enqueue([pscustomobject]@{ key = $key; received_at = $receivedAt }) } $logLimit = if ($config.log_max_rows) { [Math]::Max(100, [int]$config.log_max_rows) } else { 1200 } $retentionCutoff = [DateTimeOffset]::Now.ToUnixTimeSeconds() - ([Math]::Max(1, [int]$config.retention_hours) * 3600) while ($script:FirebaseLogRecords.Count -gt 0 -and ($script:FirebaseLogRecords.Count -gt $logLimit -or [int64]($script:FirebaseLogRecords.Peek().received_at) -lt $retentionCutoff)) { $expired = $script:FirebaseLogRecords.Dequeue() $firebaseUpdates[[string]$expired.key] = $null } Invoke-FirebaseWrite -Config $config -Path 'glabs/logs' -Method Patch -Value $firebaseUpdates Save-AgentState $fileOffsets $cursor += $take } $fileOffsets[$file.FullName] = $nextOffset Save-AgentState $fileOffsets } if (((Get-Date) - $script:LastRetentionSweep).TotalSeconds -ge 60) { $expiredLogUpdates = @{} $logLimit = if ($config.log_max_rows) { [Math]::Max(100, [int]$config.log_max_rows) } else { 1200 } $retentionCutoff = [DateTimeOffset]::Now.ToUnixTimeSeconds() - ([Math]::Max(1, [int]$config.retention_hours) * 3600) while ($script:FirebaseLogRecords.Count -gt 0 -and ($script:FirebaseLogRecords.Count -gt $logLimit -or [int64]($script:FirebaseLogRecords.Peek().received_at) -lt $retentionCutoff)) { $expired = $script:FirebaseLogRecords.Dequeue() $expiredLogUpdates[[string]$expired.key] = $null } if ($expiredLogUpdates.Count -gt 0) { Invoke-FirebaseWrite -Config $config -Path 'glabs/logs' -Method Patch -Value $expiredLogUpdates Save-AgentState $fileOffsets } $script:LastRetentionSweep = Get-Date } } catch { # Keep the process alive when the network or G-Labs is temporarily # unavailable, while retaining enough detail for Status diagnostics. Write-AgentDiagnostic $_.Exception.Message } try { if (((Get-Date) - $lastTaskPush).TotalSeconds -ge 2) { $taskStateChanged = Sync-GLabsTasksToFirebase -Config $config -ApiKey $apiKey if ($taskStateChanged) { Save-AgentState $fileOffsets } $lastTaskPush = Get-Date } } catch { Write-AgentDiagnostic "Realtime task sync: $($_.Exception.Message)" } # Account Usage is temporarily disabled. Keep the collection code above # so it can be re-enabled without changing the installation format. Start-Sleep -Seconds ([Math]::Max(1, [int]$config.poll_seconds)) } } switch ($Mode) { 'Install' { Install-Agent } 'Repair' { Repair-Agent } 'Run' { Run-Agent } 'Status' { $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($task) { $info = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue [pscustomobject]@{ TaskName = $task.TaskName State = $task.State LastRunTime = $info.LastRunTime LastTaskResult = $info.LastTaskResult NextRunTime = $info.NextRunTime LogPath = $AgentLogPath } | Format-List if (Test-Path -LiteralPath $AgentLogPath) { Write-Host 'Lỗi gần nhất:' -ForegroundColor Yellow Get-Content -LiteralPath $AgentLogPath -Tail 5 -Encoding UTF8 } } else { Write-Host 'Chưa cài đặt.' } } 'Uninstall' { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue Write-Host 'Đã gỡ tác vụ đồng bộ. Dữ liệu log đã lưu trên Firebase không bị xóa.' -ForegroundColor Yellow } }