diff --git a/8x8 Update with warnings.ps1 b/8x8_Work_Updater.ps1 similarity index 97% rename from 8x8 Update with warnings.ps1 rename to 8x8_Work_Updater.ps1 index 7c5f486..b426acd 100644 --- a/8x8 Update with warnings.ps1 +++ b/8x8_Work_Updater.ps1 @@ -1,260 +1,260 @@ -# ============================================================================== -# Script Name: Update-8x8Work-DualSession.ps1 -# Description: Downloads 8x8, launches a zero-console interactive JCT600 GUI -# in the active user's session via VBScript wrapper (BOM fixed), -# waits for completion/override (1-hour countdown), silently installs, -# launches 8x8 as standard user, and cleans up tasks. -# ============================================================================== - -# Ensure script is running elevated -if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - Write-Error "This script must be executed with Administrator privileges." - exit 1 -} - -# Use C:\ProgramData\ so standard users have full read/execute access -$workDir = "C:\ProgramData\JCT600_8x8Update" -$flagFile = Join-Path $workDir "8x8_Update_Proceed.flag" -$guiScript = Join-Path $workDir "Show-8x8UserGui.ps1" -$vbsScript = Join-Path $workDir "LaunchGuiHidden.vbs" -$taskName = "JCT600_8x8_Update_Notification" - -if (-not (Test-Path $workDir)) { - New-Item -Path $workDir -ItemType Directory -Force | Out-Null -} - -# Clean stale flag file -if (Test-Path $flagFile) { Remove-Item $flagFile -Force } - -try { - # -------------------------------------------------------------------------- - # Step 1: Download 8x8 Setup Installer - # -------------------------------------------------------------------------- - $installerUrl = "https://work-desktop-assets.8x8.com/prod-publish/ga/work-64-msi-v8.35.2-6.msi" - $installerPath = Join-Path $workDir "8x8_Work_Installer.msi" - - Write-Host "[1/5] Downloading 8x8 Work installer..." -ForegroundColor Cyan - try { - Invoke-WebRequest -Uri $installerUrl -OutFile $installerPath -UseBasicParsing - Write-Host "Download complete: $installerPath" -ForegroundColor Green - } catch { - Write-Error "Failed to download installer from $installerUrl. Error: $_" - exit 1 - } - - # -------------------------------------------------------------------------- - # Step 2: Write User GUI & Zero-Console VBS Launcher - # -------------------------------------------------------------------------- - Write-Host "[2/5] Generating user-session GUI and hidden launcher..." -ForegroundColor Cyan - - $guiContent = @' -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.Drawing - -$signature = @" -[DllImport("user32.dll", SetLastError = true)] -public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); -"@ -$Win32SetWindowPos = Add-Type -memberDefinition $signature -Name "Win32SetWindowPos" -namespace Win32Functions -passThru - -$flagPath = "C:\ProgramData\JCT600_8x8Update\8x8_Update_Proceed.flag" - -$form = New-Object System.Windows.Forms.Form -$form.Text = "JCT600 IT Support — 8x8 Work Update" -$form.Size = New-Object System.Drawing.Size(440, 250) -$form.StartPosition = 'CenterScreen' -$form.FormBorderStyle = 'FixedDialog' -$form.MaximizeBox = $false -$form.MinimizeBox = $false -$form.ControlBox = $false -$form.TopMost = $true - -$script:allowClose = $false -$form.Add_FormClosing({ - param($sender, $e) - if (-not $script:allowClose) { $e.Cancel = $true } -}) - -$labelHeader = New-Object System.Windows.Forms.Label -$labelHeader.Text = "JCT600 — IT SUPPORT" -$labelHeader.Location = New-Object System.Drawing.Point(20, 15) -$labelHeader.Size = New-Object System.Drawing.Size(380, 25) -$labelHeader.Font = New-Object System.Drawing.Font("Segoe UI", 11, [System.Drawing.FontStyle]::Bold) -$labelHeader.ForeColor = [System.Drawing.Color]::Navy -$labelHeader.TextAlign = 'TopCenter' -$form.Controls.Add($labelHeader) - -$labelMsg = New-Object System.Windows.Forms.Label -$labelMsg.Text = "An automatic update for 8x8 Work has been scheduled by IT Support.`nPlease save active work or click 'Update Now' to begin." -$labelMsg.Location = New-Object System.Drawing.Point(20, 45) -$labelMsg.Size = New-Object System.Drawing.Size(380, 45) -$labelMsg.Font = New-Object System.Drawing.Font("Segoe UI", 9) -$labelMsg.TextAlign = 'TopCenter' -$form.Controls.Add($labelMsg) - -$labelTimer = New-Object System.Windows.Forms.Label -$labelTimer.Location = New-Object System.Drawing.Point(20, 95) -$labelTimer.Size = New-Object System.Drawing.Size(380, 40) -$labelTimer.Font = New-Object System.Drawing.Font("Segoe UI", 20, [System.Drawing.FontStyle]::Bold) -$labelTimer.ForeColor = [System.Drawing.Color]::DarkRed -$labelTimer.TextAlign = 'MiddleCenter' -$form.Controls.Add($labelTimer) - -$btnUpdateNow = New-Object System.Windows.Forms.Button -$btnUpdateNow.Text = "Update Now" -$btnUpdateNow.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold) -$btnUpdateNow.Size = New-Object System.Drawing.Size(140, 35) -$btnUpdateNow.Location = New-Object System.Drawing.Point(140, 150) -$btnUpdateNow.UseVisualStyleBackColor = $true - -$btnUpdateNow.Add_Click({ - $timer.Stop() - $script:allowClose = $true - New-Item -Path $flagPath -ItemType File -Force | Out-Null - $form.Close() -}) -$form.Controls.Add($btnUpdateNow) - -$form.Add_Shown({ - [Win32Functions.Win32SetWindowPos]::SetWindowPos($form.Handle, [IntPtr] -1, 0, 0, 0, 0, 0x0003) | Out-Null - $form.Activate() -}) - -$timer = New-Object System.Windows.Forms.Timer -$timer.Interval = 1000 -$script:remaining = 3600 # 1 Hour - -$timer.Add_Tick({ - if ($script:remaining -gt 0) { - $timeSpan = [TimeSpan]::FromSeconds($script:remaining) - $labelTimer.Text = $timeSpan.ToString("hh\:mm\:ss") - $script:remaining-- - [Win32Functions.Win32SetWindowPos]::SetWindowPos($form.Handle, [IntPtr] -1, 0, 0, 0, 0, 0x0003) | Out-Null - } else { - $timer.Stop() - $script:allowClose = $true - New-Item -Path $flagPath -ItemType File -Force | Out-Null - $form.Close() - } -}) - -$initialSpan = [TimeSpan]::FromSeconds($script:remaining) -$labelTimer.Text = $initialSpan.ToString("hh\:mm\:ss") - -$timer.Start() -$form.ShowDialog() | Out-Null -$timer.Dispose() -$form.Dispose() -'@ - - # Write UTF8 without BOM - $utf8NoBom = New-Object System.Text.UTF8Encoding($false) - [System.IO.File]::WriteAllText($guiScript, $guiContent, $utf8NoBom) - - # VBScript Wrapper (ASCII) - $vbsContent = @" -Set WshShell = CreateObject("WScript.Shell") -WshShell.Run "powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File ""$guiScript""", 0, False -"@ - [System.IO.File]::WriteAllText($vbsScript, $vbsContent, [System.Text.Encoding]::ASCII) - - # -------------------------------------------------------------------------- - # Step 3: Trigger Interactive GUI via VBS Wrapper in User's Session - # -------------------------------------------------------------------------- - Write-Host "[3/5] Launching dialog in active user's desktop session..." -ForegroundColor Cyan - - $loggedUser = (Get-CimInstance -ClassName Win32_ComputerSystem).UserName - if ([string]::IsNullOrEmpty($loggedUser)) { - $explorerProc = Get-Process -Name explorer -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($explorerProc) { - $loggedUser = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $($explorerProc.Id)" | Invoke-CimMethod -MethodName GetOwner).User - } - } - - if ([string]::IsNullOrEmpty($loggedUser)) { - Write-Warning "No active interactive user detected. Proceeding directly to silent update." - } else { - Write-Host "Detected active user: $loggedUser" -ForegroundColor Green - - $action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "`"$vbsScript`"" - $principal = New-ScheduledTaskPrincipal -UserId $loggedUser -LogonType Interactive -RunLevel Limited - - Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null - Start-ScheduledTask -TaskName $taskName - - Write-Host "Waiting for user dialog response or timer expiration (1 hour max)..." -ForegroundColor Yellow - while (-not (Test-Path $flagFile)) { - Start-Sleep -Seconds 2 - } - } - - # -------------------------------------------------------------------------- - # Step 4: Close Running 8x8 Processes & Install Silently - # -------------------------------------------------------------------------- - Write-Host "[4/5] Stopping 8x8 processes and installing silently..." -ForegroundColor Cyan - - $targetProcesses = Get-Process | Where-Object { - $_.ProcessName -like "*8x8*" -or $_.MainWindowTitle -like "*8x8*" - } - - if ($targetProcesses) { - foreach ($proc in $targetProcesses) { - Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue - } - Start-Sleep -Seconds 3 - } - - $msiArgs = "/i `"$installerPath`" /quiet /norestart" - $installJob = Start-Process msiexec.exe -ArgumentList $msiArgs -Wait -PassThru - - if ($installJob.ExitCode -ne 0 -and $installJob.ExitCode -ne 3010) { - Write-Error "Installation failed with exit code $($installJob.ExitCode)." - exit $installJob.ExitCode - } - - Write-Host "Installation completed successfully." -ForegroundColor Green - - # -------------------------------------------------------------------------- - # Step 5: Launch 8x8 Work under Active User Account - # -------------------------------------------------------------------------- - Write-Host "[5/5] Launching 8x8 Work in user session..." -ForegroundColor Cyan - - $possiblePaths = @( - "${env:ProgramFiles}\8x8 Inc\8x8 Work\8x8 Work.exe", - "${env:ProgramFiles(x86)}\8x8 Inc\8x8 Work\8x8 Work.exe", - "${env:ProgramFiles}\8x8 Work\8x8 Work.exe", - "$env:LocalAppData\Programs\8x8 Work\8x8 Work.exe" - ) - - $exePath = $possiblePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 - - if ($exePath) { - if (-not [string]::IsNullOrEmpty($loggedUser)) { - # Launch 8x8 via standard user Scheduled Task to avoid Admin elevation GPU context crashes - $launchTask = "JCT600_8x8_AppLaunch" - $launchAction = New-ScheduledTaskAction -Execute "$exePath" - $launchPrincipal = New-ScheduledTaskPrincipal -UserId $loggedUser -LogonType Interactive -RunLevel Limited - - Register-ScheduledTask -TaskName $launchTask -Action $launchAction -Principal $launchPrincipal -Force | Out-Null - Start-ScheduledTask -TaskName $launchTask - Start-Sleep -Seconds 3 - Unregister-ScheduledTask -TaskName $launchTask -Confirm:$false -ErrorAction SilentlyContinue - Write-Host "8x8 Work launched successfully for $loggedUser." -ForegroundColor Green - } else { - Start-Process -FilePath $exePath - } - } else { - Write-Warning "Could not locate 8x8 Work executable." - } - -} finally { - # -------------------------------------------------------------------------- - # Mandatory Cleanup - # -------------------------------------------------------------------------- - Write-Host "Cleaning up scheduled task and temporary files..." -ForegroundColor Gray - Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue - - if (Test-Path $workDir) { - Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue - } +# ============================================================================== +# Script Name: Update-8x8Work-DualSession.ps1 +# Description: Downloads 8x8, launches a zero-console interactive JCT600 GUI +# in the active user's session via VBScript wrapper (BOM fixed), +# waits for completion/override (1-hour countdown), silently installs, +# launches 8x8 as standard user, and cleans up tasks. +# ============================================================================== + +# Ensure script is running elevated +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Error "This script must be executed with Administrator privileges." + exit 1 +} + +# Use C:\ProgramData\ so standard users have full read/execute access +$workDir = "C:\ProgramData\JCT600_8x8Update" +$flagFile = Join-Path $workDir "8x8_Update_Proceed.flag" +$guiScript = Join-Path $workDir "Show-8x8UserGui.ps1" +$vbsScript = Join-Path $workDir "LaunchGuiHidden.vbs" +$taskName = "JCT600_8x8_Update_Notification" + +if (-not (Test-Path $workDir)) { + New-Item -Path $workDir -ItemType Directory -Force | Out-Null +} + +# Clean stale flag file +if (Test-Path $flagFile) { Remove-Item $flagFile -Force } + +try { + # -------------------------------------------------------------------------- + # Step 1: Download 8x8 Setup Installer + # -------------------------------------------------------------------------- + $installerUrl = "https://work-desktop-assets.8x8.com/prod-publish/ga/work-64-msi-v8.35.2-6.msi" + $installerPath = Join-Path $workDir "8x8_Work_Installer.msi" + + Write-Host "[1/5] Downloading 8x8 Work installer..." -ForegroundColor Cyan + try { + Invoke-WebRequest -Uri $installerUrl -OutFile $installerPath -UseBasicParsing + Write-Host "Download complete: $installerPath" -ForegroundColor Green + } catch { + Write-Error "Failed to download installer from $installerUrl. Error: $_" + exit 1 + } + + # -------------------------------------------------------------------------- + # Step 2: Write User GUI & Zero-Console VBS Launcher + # -------------------------------------------------------------------------- + Write-Host "[2/5] Generating user-session GUI and hidden launcher..." -ForegroundColor Cyan + + $guiContent = @' +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$signature = @" +[DllImport("user32.dll", SetLastError = true)] +public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); +"@ +$Win32SetWindowPos = Add-Type -memberDefinition $signature -Name "Win32SetWindowPos" -namespace Win32Functions -passThru + +$flagPath = "C:\ProgramData\JCT600_8x8Update\8x8_Update_Proceed.flag" + +$form = New-Object System.Windows.Forms.Form +$form.Text = "JCT600 IT Support — 8x8 Work Update" +$form.Size = New-Object System.Drawing.Size(440, 250) +$form.StartPosition = 'CenterScreen' +$form.FormBorderStyle = 'FixedDialog' +$form.MaximizeBox = $false +$form.MinimizeBox = $false +$form.ControlBox = $false +$form.TopMost = $true + +$script:allowClose = $false +$form.Add_FormClosing({ + param($sender, $e) + if (-not $script:allowClose) { $e.Cancel = $true } +}) + +$labelHeader = New-Object System.Windows.Forms.Label +$labelHeader.Text = "JCT600 — IT SUPPORT" +$labelHeader.Location = New-Object System.Drawing.Point(20, 15) +$labelHeader.Size = New-Object System.Drawing.Size(380, 25) +$labelHeader.Font = New-Object System.Drawing.Font("Segoe UI", 11, [System.Drawing.FontStyle]::Bold) +$labelHeader.ForeColor = [System.Drawing.Color]::Navy +$labelHeader.TextAlign = 'TopCenter' +$form.Controls.Add($labelHeader) + +$labelMsg = New-Object System.Windows.Forms.Label +$labelMsg.Text = "An automatic update for 8x8 Work has been scheduled by IT Support.`nPlease save active work or click 'Update Now' to begin." +$labelMsg.Location = New-Object System.Drawing.Point(20, 45) +$labelMsg.Size = New-Object System.Drawing.Size(380, 45) +$labelMsg.Font = New-Object System.Drawing.Font("Segoe UI", 9) +$labelMsg.TextAlign = 'TopCenter' +$form.Controls.Add($labelMsg) + +$labelTimer = New-Object System.Windows.Forms.Label +$labelTimer.Location = New-Object System.Drawing.Point(20, 95) +$labelTimer.Size = New-Object System.Drawing.Size(380, 40) +$labelTimer.Font = New-Object System.Drawing.Font("Segoe UI", 20, [System.Drawing.FontStyle]::Bold) +$labelTimer.ForeColor = [System.Drawing.Color]::DarkRed +$labelTimer.TextAlign = 'MiddleCenter' +$form.Controls.Add($labelTimer) + +$btnUpdateNow = New-Object System.Windows.Forms.Button +$btnUpdateNow.Text = "Update Now" +$btnUpdateNow.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold) +$btnUpdateNow.Size = New-Object System.Drawing.Size(140, 35) +$btnUpdateNow.Location = New-Object System.Drawing.Point(140, 150) +$btnUpdateNow.UseVisualStyleBackColor = $true + +$btnUpdateNow.Add_Click({ + $timer.Stop() + $script:allowClose = $true + New-Item -Path $flagPath -ItemType File -Force | Out-Null + $form.Close() +}) +$form.Controls.Add($btnUpdateNow) + +$form.Add_Shown({ + [Win32Functions.Win32SetWindowPos]::SetWindowPos($form.Handle, [IntPtr] -1, 0, 0, 0, 0, 0x0003) | Out-Null + $form.Activate() +}) + +$timer = New-Object System.Windows.Forms.Timer +$timer.Interval = 1000 +$script:remaining = 3600 # 1 Hour + +$timer.Add_Tick({ + if ($script:remaining -gt 0) { + $timeSpan = [TimeSpan]::FromSeconds($script:remaining) + $labelTimer.Text = $timeSpan.ToString("hh\:mm\:ss") + $script:remaining-- + [Win32Functions.Win32SetWindowPos]::SetWindowPos($form.Handle, [IntPtr] -1, 0, 0, 0, 0, 0x0003) | Out-Null + } else { + $timer.Stop() + $script:allowClose = $true + New-Item -Path $flagPath -ItemType File -Force | Out-Null + $form.Close() + } +}) + +$initialSpan = [TimeSpan]::FromSeconds($script:remaining) +$labelTimer.Text = $initialSpan.ToString("hh\:mm\:ss") + +$timer.Start() +$form.ShowDialog() | Out-Null +$timer.Dispose() +$form.Dispose() +'@ + + # Write UTF8 without BOM + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($guiScript, $guiContent, $utf8NoBom) + + # VBScript Wrapper (ASCII) + $vbsContent = @" +Set WshShell = CreateObject("WScript.Shell") +WshShell.Run "powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File ""$guiScript""", 0, False +"@ + [System.IO.File]::WriteAllText($vbsScript, $vbsContent, [System.Text.Encoding]::ASCII) + + # -------------------------------------------------------------------------- + # Step 3: Trigger Interactive GUI via VBS Wrapper in User's Session + # -------------------------------------------------------------------------- + Write-Host "[3/5] Launching dialog in active user's desktop session..." -ForegroundColor Cyan + + $loggedUser = (Get-CimInstance -ClassName Win32_ComputerSystem).UserName + if ([string]::IsNullOrEmpty($loggedUser)) { + $explorerProc = Get-Process -Name explorer -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($explorerProc) { + $loggedUser = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $($explorerProc.Id)" | Invoke-CimMethod -MethodName GetOwner).User + } + } + + if ([string]::IsNullOrEmpty($loggedUser)) { + Write-Warning "No active interactive user detected. Proceeding directly to silent update." + } else { + Write-Host "Detected active user: $loggedUser" -ForegroundColor Green + + $action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "`"$vbsScript`"" + $principal = New-ScheduledTaskPrincipal -UserId $loggedUser -LogonType Interactive -RunLevel Limited + + Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null + Start-ScheduledTask -TaskName $taskName + + Write-Host "Waiting for user dialog response or timer expiration (1 hour max)..." -ForegroundColor Yellow + while (-not (Test-Path $flagFile)) { + Start-Sleep -Seconds 2 + } + } + + # -------------------------------------------------------------------------- + # Step 4: Close Running 8x8 Processes & Install Silently + # -------------------------------------------------------------------------- + Write-Host "[4/5] Stopping 8x8 processes and installing silently..." -ForegroundColor Cyan + + $targetProcesses = Get-Process | Where-Object { + $_.ProcessName -like "*8x8*" -or $_.MainWindowTitle -like "*8x8*" + } + + if ($targetProcesses) { + foreach ($proc in $targetProcesses) { + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Seconds 3 + } + + $msiArgs = "/i `"$installerPath`" /quiet /norestart" + $installJob = Start-Process msiexec.exe -ArgumentList $msiArgs -Wait -PassThru + + if ($installJob.ExitCode -ne 0 -and $installJob.ExitCode -ne 3010) { + Write-Error "Installation failed with exit code $($installJob.ExitCode)." + exit $installJob.ExitCode + } + + Write-Host "Installation completed successfully." -ForegroundColor Green + + # -------------------------------------------------------------------------- + # Step 5: Launch 8x8 Work under Active User Account + # -------------------------------------------------------------------------- + Write-Host "[5/5] Launching 8x8 Work in user session..." -ForegroundColor Cyan + + $possiblePaths = @( + "${env:ProgramFiles}\8x8 Inc\8x8 Work\8x8 Work.exe", + "${env:ProgramFiles(x86)}\8x8 Inc\8x8 Work\8x8 Work.exe", + "${env:ProgramFiles}\8x8 Work\8x8 Work.exe", + "$env:LocalAppData\Programs\8x8 Work\8x8 Work.exe" + ) + + $exePath = $possiblePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 + + if ($exePath) { + if (-not [string]::IsNullOrEmpty($loggedUser)) { + # Launch 8x8 via standard user Scheduled Task to avoid Admin elevation GPU context crashes + $launchTask = "JCT600_8x8_AppLaunch" + $launchAction = New-ScheduledTaskAction -Execute "$exePath" + $launchPrincipal = New-ScheduledTaskPrincipal -UserId $loggedUser -LogonType Interactive -RunLevel Limited + + Register-ScheduledTask -TaskName $launchTask -Action $launchAction -Principal $launchPrincipal -Force | Out-Null + Start-ScheduledTask -TaskName $launchTask + Start-Sleep -Seconds 3 + Unregister-ScheduledTask -TaskName $launchTask -Confirm:$false -ErrorAction SilentlyContinue + Write-Host "8x8 Work launched successfully for $loggedUser." -ForegroundColor Green + } else { + Start-Process -FilePath $exePath + } + } else { + Write-Warning "Could not locate 8x8 Work executable." + } + +} finally { + # -------------------------------------------------------------------------- + # Mandatory Cleanup + # -------------------------------------------------------------------------- + Write-Host "Cleaning up scheduled task and temporary files..." -ForegroundColor Gray + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + + if (Test-Path $workDir) { + Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue + } } \ No newline at end of file