diff --git a/config.psd1 b/config.psd1
index 68d16db..16482ae 100644
--- a/config.psd1
+++ b/config.psd1
@@ -18,4 +18,7 @@
# Must match the profile folder name and scene collection filename
ProfileName = 'ITMonitor'
SceneCollection = 'ITMonitor'
+
+ # IT helpdesk contact number shown in the startup notification
+ ITContactNumber = '555-555-5555'
}
diff --git a/scripts/Invoke-ReplaySave.ps1 b/scripts/Invoke-ReplaySave.ps1
index c36f1b6..28bbbd0 100644
--- a/scripts/Invoke-ReplaySave.ps1
+++ b/scripts/Invoke-ReplaySave.ps1
@@ -23,7 +23,7 @@ if ($Port -eq 0) {
try {
$logDir = [System.Environment]::ExpandEnvironmentVariables($config.LogPath)
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
- Start-Transcript -Path (Join-Path $logDir 'OBSReplayBuffer-Save.log') -Append -ErrorAction Stop
+ Start-Transcript -Path (Join-Path $logDir 'OBSReplayBuffer-Save.log') -Force -ErrorAction Stop
} catch {
# Log path unavailable — continue without transcript
}
@@ -57,6 +57,7 @@ try {
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] Received Identified (op 2)."
# Send SaveReplayBuffer request (opcode 6)
+ $saveRequestTime = Get-Date
$request = [System.Text.Encoding]::UTF8.GetBytes('{"op":6,"d":{"requestType":"SaveReplayBuffer","requestId":"itrecord-1"}}')
$ws.SendAsync($request, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $cts.Token).Wait()
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] Sent SaveReplayBuffer request (op 6)."
@@ -71,7 +72,108 @@ try {
}
$ws.CloseAsync([System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, 'Done', $cts.Token).Wait()
- Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] WebSocket closed. Save completed successfully."
+ Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] WebSocket closed."
+
+ # --- Verify replay file was written to disk ---
+ $captureDir = Join-Path $config.UNCPath $env:USERNAME
+ $fileTimeout = [datetime]::UtcNow.AddSeconds(30)
+ $savedFile = $null
+ while (-not $savedFile) {
+ if ([datetime]::UtcNow -ge $fileTimeout) {
+ throw "OBS accepted the save request but no replay file appeared in '$captureDir' within 30 seconds."
+ }
+ $savedFile = Get-ChildItem -Path $captureDir -Filter '*.mkv' -ErrorAction SilentlyContinue |
+ Where-Object { $_.LastWriteTime -ge $saveRequestTime } |
+ Sort-Object LastWriteTime -Descending |
+ Select-Object -First 1
+ if (-not $savedFile) { Start-Sleep -Seconds 2 }
+ }
+ $fileSizeMB = [math]::Round($savedFile.Length / 1MB, 2)
+ Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] Replay file confirmed: $($savedFile.FullName) ($fileSizeMB MB)"
+
+ # --- Notify user that the replay was saved ---
+ $notifyScript = {
+ param($fileName, $fileSizeMB, $contactNumber)
+
+ Add-Type -AssemblyName PresentationFramework
+ Add-Type -AssemblyName WindowsBase
+ Add-Type -AssemblyName System.Drawing
+
+ $hBitmap = [System.Drawing.SystemIcons]::Shield.ToBitmap().GetHbitmap()
+ $wpfImage = [System.Windows.Interop.Imaging]::CreateBitmapSourceFromHBitmap(
+ $hBitmap, [IntPtr]::Zero, [System.Windows.Int32Rect]::Empty,
+ [System.Windows.Media.Imaging.BitmapSizeOptions]::FromEmptyOptions()
+ )
+ [System.Runtime.InteropServices.Marshal]::DeleteObject($hBitmap)
+
+ [xml]$xaml = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+ $window = [System.Windows.Markup.XamlReader]::Load([System.Xml.XmlNodeReader]::new($xaml))
+ $imgHeader = $window.FindName('ImgHeader')
+ $txtBody = $window.FindName('TxtBody')
+ $txtContact = $window.FindName('TxtContact')
+ $btnOK = $window.FindName('BtnOK')
+
+ $imgHeader.Source = $wpfImage
+ $txtContact.Text = "Questions? Contact IT at $contactNumber"
+
+ $txtBody.Inlines.Add("Your replay has been saved ($fileSizeMB MB):`n")
+ $bold1 = New-Object System.Windows.Documents.Run($fileName)
+ $bold1.FontWeight = [System.Windows.FontWeights]::Bold
+ $txtBody.Inlines.Add($bold1)
+ $txtBody.Inlines.Add("`n`nIT can retrieve this file if needed for a support case.")
+
+ $script:tick = 30
+ $btnOK.Content = "OK (30)"
+ $timer = New-Object System.Windows.Threading.DispatcherTimer
+ $timer.Interval = [TimeSpan]::FromSeconds(1)
+ $timer.Add_Tick({
+ $script:tick--
+ $btnOK.Content = "OK ($script:tick)"
+ if ($script:tick -le 0) {
+ $timer.Stop()
+ $window.Close()
+ }
+ })
+ $timer.Start()
+ $btnOK.Add_Click({ $timer.Stop(); $window.Close() })
+ $window.ShowDialog() | Out-Null
+ }
+
+ $notifyRunspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $notifyRunspace.ApartmentState = [System.Threading.ApartmentState]::STA
+ $notifyRunspace.Open()
+ $notifyPS = [System.Management.Automation.PowerShell]::Create()
+ $notifyPS.Runspace = $notifyRunspace
+ $notifyPS.AddScript($notifyScript) | Out-Null
+ $notifyPS.AddArgument($savedFile.Name) | Out-Null
+ $notifyPS.AddArgument($fileSizeMB) | Out-Null
+ $notifyPS.AddArgument($config.ITContactNumber) | Out-Null
+ $notifyPS.Invoke()
+ $notifyPS.Dispose()
+ $notifyRunspace.Close()
+ Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] User notification dismissed."
return $true
}
diff --git a/scripts/Show-ReplayTray.ps1 b/scripts/Show-ReplayTray.ps1
index 32d0e4c..e544311 100644
--- a/scripts/Show-ReplayTray.ps1
+++ b/scripts/Show-ReplayTray.ps1
@@ -17,7 +17,7 @@ try {
$config = Import-PowerShellDataFile -Path (Join-Path $PSScriptRoot '..\config.psd1')
$logDir = [System.Environment]::ExpandEnvironmentVariables($config.LogPath)
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
- Start-Transcript -Path (Join-Path $logDir 'OBSReplayBuffer-Tray.log') -Append -ErrorAction Stop
+ Start-Transcript -Path (Join-Path $logDir 'OBSReplayBuffer-Tray.log') -Force -ErrorAction Stop
} catch {
# Log path unavailable — continue without transcript
}
diff --git a/scripts/Start-OBSReplayBuffer.ps1 b/scripts/Start-OBSReplayBuffer.ps1
index eb02e8a..7de1295 100644
--- a/scripts/Start-OBSReplayBuffer.ps1
+++ b/scripts/Start-OBSReplayBuffer.ps1
@@ -19,7 +19,7 @@ $config = Import-PowerShellDataFile -Path $configPath
try {
$logDir = [System.Environment]::ExpandEnvironmentVariables($config.LogPath)
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
- Start-Transcript -Path (Join-Path $logDir 'OBSReplayBuffer.log') -Append -ErrorAction Stop
+ Start-Transcript -Path (Join-Path $logDir 'OBSReplayBuffer.log') -Force -ErrorAction Stop
} catch {
# Log path unavailable — continue without transcript
}
@@ -34,10 +34,15 @@ $requiredPaths = @(
$config.OBSExecutable
)
foreach ($path in $requiredPaths) {
- if (-not (Test-Path -Path $path)) {
- Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [ERROR] Required path not accessible: $path"
- Stop-Transcript -ErrorAction SilentlyContinue
- exit 1
+ $timeout = [datetime]::UtcNow.AddMinutes(2)
+ while (-not (Test-Path -Path $path)) {
+ if ([datetime]::UtcNow -ge $timeout) {
+ Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [ERROR] Required path not accessible after 2 min timeout: $path"
+ Stop-Transcript -ErrorAction SilentlyContinue
+ exit 1
+ }
+ Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [WARN] Path not yet accessible, retrying: $path"
+ Start-Sleep -Seconds 5
}
}
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] All required paths verified."
@@ -181,9 +186,158 @@ $obsArgs = @(
$config.SceneCollection
)
+$obsLaunchTime = Get-Date
Start-Process -FilePath $config.OBSExecutable -ArgumentList $obsArgs -WindowStyle Hidden -WorkingDirectory (Split-Path $config.OBSExecutable)
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] OBS launched: $($config.OBSExecutable)"
+# --- Wait for OBS process to appear ---
+$obsTimeout = [datetime]::UtcNow.AddSeconds(30)
+while (-not (Get-Process -Name 'obs64' -ErrorAction SilentlyContinue)) {
+ if ([datetime]::UtcNow -ge $obsTimeout) {
+ Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [ERROR] OBS process did not appear within 30 seconds."
+ Stop-Transcript -ErrorAction SilentlyContinue
+ exit 1
+ }
+ Start-Sleep -Seconds 2
+}
+Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] OBS process detected."
+
+# --- Locate the OBS log file created for this session ---
+$obsLogDir = "$env:APPDATA\obs-studio\logs"
+$logTimeout = [datetime]::UtcNow.AddSeconds(30)
+$obsLogFile = $null
+while (-not $obsLogFile) {
+ if ([datetime]::UtcNow -ge $logTimeout) {
+ Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [ERROR] OBS did not create a log file within 30 seconds."
+ Stop-Transcript -ErrorAction SilentlyContinue
+ exit 1
+ }
+ $obsLogFile = Get-ChildItem -Path $obsLogDir -Filter '*.txt' -ErrorAction SilentlyContinue |
+ Where-Object { $_.CreationTime -ge $obsLaunchTime } |
+ Sort-Object CreationTime -Descending |
+ Select-Object -First 1
+ if (-not $obsLogFile) { Start-Sleep -Seconds 2 }
+}
+Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] Monitoring OBS log: $($obsLogFile.FullName)"
+
+# --- Poll OBS log for replay buffer start confirmation ---
+# Matches "Replay Buffer Output started" (older OBS) and "[output 'ReplayBuffer']: started" (newer OBS)
+$rbTimeout = [datetime]::UtcNow.AddMinutes(2)
+$rbStarted = $false
+while (-not $rbStarted) {
+ if ([datetime]::UtcNow -ge $rbTimeout) {
+ Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [ERROR] Replay Buffer did not start within 2 minutes. See: $($obsLogFile.FullName)"
+ Stop-Transcript -ErrorAction SilentlyContinue
+ exit 1
+ }
+ if (Select-String -Path $obsLogFile.FullName -Pattern 'replay.buffer.*start' -Quiet -CaseSensitive:$false -ErrorAction SilentlyContinue) {
+ $rbStarted = $true
+ } else {
+ Start-Sleep -Seconds 3
+ }
+}
+Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] Replay Buffer confirmed started."
+
+# --- Notify user that the replay buffer is active ---
+# Runs in a dedicated STA runspace because WPF requires STA apartment state
+$notifyScript = {
+ param($contactNumber, $bufferMinutes)
+
+ Add-Type -AssemblyName PresentationFramework
+ Add-Type -AssemblyName WindowsBase
+ Add-Type -AssemblyName System.Drawing
+
+ # Convert the Shield system icon (same one used by the tray) to a WPF BitmapSource
+ $hBitmap = [System.Drawing.SystemIcons]::Shield.ToBitmap().GetHbitmap()
+ $wpfImage = [System.Windows.Interop.Imaging]::CreateBitmapSourceFromHBitmap(
+ $hBitmap, [IntPtr]::Zero, [System.Windows.Int32Rect]::Empty,
+ [System.Windows.Media.Imaging.BitmapSizeOptions]::FromEmptyOptions()
+ )
+ [System.Runtime.InteropServices.Marshal]::DeleteObject($hBitmap)
+
+ [xml]$xaml = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+ $window = [System.Windows.Markup.XamlReader]::Load([System.Xml.XmlNodeReader]::new($xaml))
+ $imgHeader = $window.FindName('ImgHeader')
+ $imgTray = $window.FindName('ImgTray')
+ $txtBody = $window.FindName('TxtBody')
+ $txtContact = $window.FindName('TxtContact')
+ $btnOK = $window.FindName('BtnOK')
+
+ $imgHeader.Source = $wpfImage
+ $imgTray.Source = $wpfImage
+ $txtContact.Text = "Questions? Contact IT at $contactNumber"
+
+ # Build body text with inline bold runs
+ $txtBody.Inlines.Add("Your screen is being recorded into a rolling $bufferMinutes-minute replay buffer. Nothing is saved until you request it.`n`nTo save a clip, ")
+ $bold1 = New-Object System.Windows.Documents.Run('right-click the shield icon')
+ $bold1.FontWeight = [System.Windows.FontWeights]::Bold
+ $txtBody.Inlines.Add($bold1)
+ $txtBody.Inlines.Add(' in your system tray (bottom-right of your screen) and select ')
+ $bold2 = New-Object System.Windows.Documents.Run('Save Replay')
+ $bold2.FontWeight = [System.Windows.FontWeights]::Bold
+ $txtBody.Inlines.Add($bold2)
+ $txtBody.Inlines.Add('.')
+
+ # Countdown timer — auto-dismisses after 30 seconds
+ $script:tick = 30
+ $btnOK.Content = "OK (30)"
+ $timer = New-Object System.Windows.Threading.DispatcherTimer
+ $timer.Interval = [TimeSpan]::FromSeconds(1)
+ $timer.Add_Tick({
+ $script:tick--
+ $btnOK.Content = "OK ($script:tick)"
+ if ($script:tick -le 0) {
+ $timer.Stop()
+ $window.Close()
+ }
+ })
+ $timer.Start()
+ $btnOK.Add_Click({ $timer.Stop(); $window.Close() })
+ $window.ShowDialog() | Out-Null
+}
+
+$notifyRunspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+$notifyRunspace.ApartmentState = [System.Threading.ApartmentState]::STA
+$notifyRunspace.Open()
+$notifyPS = [System.Management.Automation.PowerShell]::Create()
+$notifyPS.Runspace = $notifyRunspace
+$notifyPS.AddScript($notifyScript) | Out-Null
+$notifyPS.AddArgument($config.ITContactNumber) | Out-Null
+$notifyPS.AddArgument([math]::Round($config.BufferSeconds / 60)) | Out-Null
+$notifyPS.Invoke()
+$notifyPS.Dispose()
+$notifyRunspace.Close()
+Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] User notification dismissed."
+
# --- Launch tray icon as a separate background process ---
$trayScript = Join-Path $PSScriptRoot 'Show-ReplayTray.ps1'
Start-Process -FilePath 'powershell.exe' -ArgumentList @(