352 lines
15 KiB
PowerShell
352 lines
15 KiB
PowerShell
#Requires -Version 5.1
|
|
<#
|
|
.SYNOPSIS
|
|
Launches OBS Studio hidden with the replay buffer running, then starts the tray icon.
|
|
Intended to be called by DEM as a logon task (fire and forget — do not wait for completion).
|
|
|
|
.NOTES
|
|
Reads settings from config.psd1 one level above this script.
|
|
Writes the OBS profile basic.ini dynamically so the correct UNC path and buffer
|
|
duration are baked in at session start.
|
|
#>
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$configPath = Join-Path $PSScriptRoot '..\config.psd1'
|
|
$config = Import-PowerShellDataFile -Path $configPath
|
|
|
|
# --- Start transcript logging ---
|
|
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') -Force -ErrorAction Stop
|
|
} catch {
|
|
# Log path unavailable — continue without transcript
|
|
}
|
|
|
|
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] Starting OBS Replay Buffer for user: $env:USERNAME"
|
|
|
|
# --- Verify required project files are accessible ---
|
|
$requiredPaths = @(
|
|
(Join-Path $PSScriptRoot '..\obs-config\global.ini')
|
|
(Join-Path $PSScriptRoot "..\obs-config\scenes\$($config.SceneCollection).json")
|
|
(Join-Path $PSScriptRoot 'Show-ReplayTray.ps1')
|
|
$config.OBSExecutable
|
|
)
|
|
foreach ($path in $requiredPaths) {
|
|
$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."
|
|
|
|
# --- Skip if OBS is already running (handles reconnect scenarios) ---
|
|
if (Get-Process -Name 'obs64' -ErrorAction SilentlyContinue) {
|
|
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] OBS already running - exiting."
|
|
Stop-Transcript -ErrorAction SilentlyContinue
|
|
exit 0
|
|
}
|
|
|
|
# --- Create user capture subfolder on UNC share ---
|
|
$userCapturePath = Join-Path $config.UNCPath $env:USERNAME
|
|
if (-not (Test-Path -Path $userCapturePath)) {
|
|
New-Item -ItemType Directory -Path $userCapturePath -Force | Out-Null
|
|
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] Created capture folder: $userCapturePath"
|
|
} else {
|
|
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] Capture folder exists: $userCapturePath"
|
|
}
|
|
|
|
# --- Detect primary monitor resolution ---
|
|
Add-Type -AssemblyName System.Windows.Forms
|
|
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
|
|
$width = $screen.Bounds.Width
|
|
$height = $screen.Bounds.Height
|
|
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] Detected resolution: ${width}x${height}"
|
|
|
|
# --- Deploy global OBS config and WebSocket plugin config ---
|
|
$obsConfigRoot = "$env:APPDATA\obs-studio"
|
|
$sourceConfig = Join-Path $PSScriptRoot '..\obs-config'
|
|
|
|
New-Item -ItemType Directory -Path $obsConfigRoot -Force | Out-Null
|
|
Copy-Item -Path "$sourceConfig\global.ini" -Destination "$obsConfigRoot\global.ini" -Force
|
|
|
|
# --- Detect primary monitor device ID for OBS monitor capture ---
|
|
# Uses EnumDisplayDevices with EDD_GET_DEVICE_INTERFACE_NAME to get the exact
|
|
# device interface path for the active session's display — same source OBS uses.
|
|
Add-Type -TypeDefinition @"
|
|
using System;
|
|
using System.Runtime.InteropServices;
|
|
public class DisplayHelper {
|
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
public struct DISPLAY_DEVICE {
|
|
public int cb;
|
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string DeviceName;
|
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceString;
|
|
public int StateFlags;
|
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceID;
|
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceKey;
|
|
}
|
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
public static extern bool EnumDisplayDevices(string lpDevice, uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags);
|
|
public static string GetPrimaryMonitorInterfacePath() {
|
|
var adapter = new DISPLAY_DEVICE(); adapter.cb = Marshal.SizeOf(adapter);
|
|
for (uint i = 0; EnumDisplayDevices(null, i, ref adapter, 0); i++) {
|
|
if ((adapter.StateFlags & 0x4) != 0) { // DISPLAY_DEVICE_PRIMARY_DEVICE
|
|
var monitor = new DISPLAY_DEVICE(); monitor.cb = Marshal.SizeOf(monitor);
|
|
if (EnumDisplayDevices(adapter.DeviceName, 0, ref monitor, 0x1)) // EDD_GET_DEVICE_INTERFACE_NAME
|
|
return monitor.DeviceID;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
}
|
|
"@
|
|
|
|
$monitorDeviceId = [DisplayHelper]::GetPrimaryMonitorInterfacePath()
|
|
if ($monitorDeviceId) {
|
|
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] Detected monitor ID: $monitorDeviceId"
|
|
} else {
|
|
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [WARN] No monitor detected - monitor_id will be empty."
|
|
}
|
|
|
|
# --- Deploy scene collection with dynamic monitor ID ---
|
|
$scenesDir = "$obsConfigRoot\basic\scenes"
|
|
New-Item -ItemType Directory -Path $scenesDir -Force | Out-Null
|
|
$sceneJson = Get-Content -Path "$sourceConfig\scenes\$($config.SceneCollection).json" -Raw | ConvertFrom-Json
|
|
$sceneJson.sources | Where-Object { $_.id -eq 'monitor_capture' } | ForEach-Object {
|
|
$_.settings | Add-Member -MemberType NoteProperty -Name 'monitor_id' -Value $monitorDeviceId -Force
|
|
}
|
|
$sceneJson | ConvertTo-Json -Depth 20 | Set-Content -Path "$scenesDir\$($config.SceneCollection).json" -Encoding UTF8
|
|
|
|
|
|
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] OBS config files deployed to: $obsConfigRoot"
|
|
|
|
# --- Write OBS profile (basic.ini) ---
|
|
$profileDir = "$env:APPDATA\obs-studio\basic\profiles\$($config.ProfileName)"
|
|
New-Item -ItemType Directory -Path $profileDir -Force | Out-Null
|
|
|
|
# OBS strips one leading backslash from paths — prepend an extra one for UNC paths
|
|
$obsFilePath = if ($userCapturePath.StartsWith('\\')) { '\' + $userCapturePath } else { $userCapturePath }
|
|
|
|
Set-Content -Path "$profileDir\basic.ini" -Encoding UTF8 -Value @(
|
|
'[General]'
|
|
"Name=$($config.ProfileName)"
|
|
''
|
|
'[Output]'
|
|
'Mode=Simple'
|
|
'FilenameFormatting=%CCYY-%MM-%DD_%hh-%mm-%ss'
|
|
''
|
|
'[SimpleOutput]'
|
|
"FilePath=$obsFilePath"
|
|
'RecFormat2=mkv'
|
|
'RecQuality=Small'
|
|
'RecEncoder=x264'
|
|
'RecRB=true'
|
|
'RecRBPrefix=Replay'
|
|
"RecRBTime=$($config.BufferSeconds)"
|
|
'RecRBSize=512'
|
|
'ABitrate=160'
|
|
'VBitrate=2500'
|
|
'UseAdvanced=false'
|
|
'Preset=veryfast'
|
|
'StreamAudioEncoder=aac'
|
|
'RecAudioEncoder=aac'
|
|
''
|
|
'[Video]'
|
|
"BaseCX=$width"
|
|
"BaseCY=$height"
|
|
"OutputCX=$width"
|
|
"OutputCY=$height"
|
|
'FPSType=1'
|
|
'FPSNum=30'
|
|
'FPSDen=1'
|
|
'ScaleType=bicubic'
|
|
'ColorFormat=NV12'
|
|
'ColorSpace=709'
|
|
'ColorRange=Partial'
|
|
)
|
|
|
|
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] OBS profile written: $profileDir\basic.ini"
|
|
|
|
# --- Launch OBS hidden ---
|
|
$obsArgs = @(
|
|
'--minimize-to-tray'
|
|
'--startreplaybuffer'
|
|
'--disable-updater'
|
|
'--profile'
|
|
$config.ProfileName
|
|
'--collection'
|
|
$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 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
Title="IT Screen Recorder" Height="320" Width="440"
|
|
WindowStartupLocation="CenterScreen" ResizeMode="NoResize"
|
|
Topmost="True" ShowInTaskbar="False">
|
|
<Grid Margin="20">
|
|
<Grid.RowDefinitions>
|
|
<RowDefinition Height="Auto"/>
|
|
<RowDefinition Height="*"/>
|
|
<RowDefinition Height="Auto"/>
|
|
<RowDefinition Height="Auto"/>
|
|
<RowDefinition Height="Auto"/>
|
|
</Grid.RowDefinitions>
|
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,14">
|
|
<Image x:Name="ImgHeader" Width="32" Height="32" Margin="0,0,10,0" VerticalAlignment="Center"/>
|
|
<TextBlock Text="Screen Recording is Active" FontSize="16" FontWeight="SemiBold" FontFamily="Segoe UI" VerticalAlignment="Center"/>
|
|
</StackPanel>
|
|
<TextBlock x:Name="TxtBody" Grid.Row="1" TextWrapping="Wrap" FontSize="12" FontFamily="Segoe UI" LineHeight="22"/>
|
|
<Border Grid.Row="2" Background="#F0F0F0" CornerRadius="4" Padding="10,8" Margin="0,12,0,12">
|
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
|
<Image x:Name="ImgTray" Width="22" Height="22" Margin="0,0,8,0"/>
|
|
<TextBlock Text="Look for this icon in your system tray" FontSize="11" FontFamily="Segoe UI" VerticalAlignment="Center" Foreground="#555"/>
|
|
</StackPanel>
|
|
</Border>
|
|
<TextBlock x:Name="TxtContact" Grid.Row="3" FontSize="11" FontFamily="Segoe UI" Foreground="#555" HorizontalAlignment="Center" Margin="0,0,0,16"/>
|
|
<Button x:Name="BtnOK" Grid.Row="4" Width="100" HorizontalAlignment="Center" IsDefault="True"/>
|
|
</Grid>
|
|
</Window>
|
|
"@
|
|
|
|
$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 @(
|
|
'-ExecutionPolicy', 'Bypass'
|
|
'-NonInteractive'
|
|
'-WindowStyle', 'Hidden'
|
|
'-File', $trayScript
|
|
) -WindowStyle Hidden
|
|
Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] Tray icon launched."
|
|
|
|
Stop-Transcript -ErrorAction SilentlyContinue
|