Files
PowershellAutomation/configure.ps1
T
Arne Moerman 6c10d359d2 Enhance SandwichReminder-AutoOrder feature with direct checkout navigation and improved logic
- Added new configuration options for direct checkout navigation, including `useDirectCheckoutNavigation`, `checkoutPath`, and `checkoutOpenDelayMs`.
- Updated the auto-order flow to navigate directly to the checkout page, skipping the mini-cart and date/time steps.
- Improved keyboard automation logic for item remark and order confirmation processes.
- Removed the old SandwichAutoOrder.ps1 file as its functionality has been integrated into SandwichReminder-AutoOrder.ps1.
- Introduced a new runner-launcher.vbs to start the runner.ps1 without a visible console window, ensuring WinForms dialogs can appear.
- Implemented a mutex mechanism in runner.ps1 to prevent concurrent execution of the same feature.
2026-05-11 13:59:22 +02:00

902 lines
35 KiB
PowerShell

#Requires -Version 5.1
# configure.ps1 — PowerShell Automation Center
# The only file a user needs to interact with; everything else lives in internal\.
param(
[switch]$AutoRegisterRunner,
[switch]$AutoRemoveRunner
)
$ErrorActionPreference = 'Continue'
$script:InternalRoot = Join-Path $PSScriptRoot 'internal'
$script:TaskName = 'PSAutomation-Runner'
$script:TaskPath = '\PowerShell Automation\'
$script:RunnerIntervalMinutes = 2
$script:ConfigureScriptPath = if ($PSCommandPath) { $PSCommandPath } else { $MyInvocation.MyCommand.Path }
$script:RegisterResultPath = Join-Path $script:InternalRoot 'data\logs\last-register-result.json'
$script:RemoveResultPath = Join-Path $script:InternalRoot 'data\logs\last-remove-result.json'
$script:ElevatedDiagPath = Join-Path $script:InternalRoot 'data\logs\elevated-process.log'
function Write-ElevatedDiag {
param([string]$Message)
try {
$dir = Split-Path $script:ElevatedDiagPath -Parent
if (-not (Test-Path $dir)) {
New-Item -Path $dir -ItemType Directory -Force | Out-Null
}
$line = "[{0}] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Add-Content -Path $script:ElevatedDiagPath -Value $line -Encoding UTF8
}
catch {
# Never fail caller due to diagnostics write issues
}
}
function Write-OperationResultEarly {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][bool]$Success,
[Parameter(Mandatory)][string]$Message
)
try {
$dir = Split-Path $Path -Parent
if (-not (Test-Path $dir)) {
New-Item -Path $dir -ItemType Directory -Force | Out-Null
}
@{
timestamp = (Get-Date).ToString('o')
success = $Success
message = $Message
} | ConvertTo-Json -Depth 5 | Set-Content -Path $Path -Encoding UTF8
}
catch {
# Best-effort fallback only
}
}
# ── Validate installation ─────────────────────────────────────────────────────
foreach ($required in @('lib\Logging.ps1', 'lib\Config.ps1')) {
$p = Join-Path $script:InternalRoot $required
if (-not (Test-Path $p)) {
Write-Host "ERROR: Cannot find 'internal\$required'. Installation may be incomplete." -ForegroundColor Red
exit 1
}
}
. (Join-Path $script:InternalRoot 'lib\Logging.ps1')
. (Join-Path $script:InternalRoot 'lib\Config.ps1')
. (Join-Path $script:InternalRoot 'lib\Elevation.ps1')
# Detect whether raw keyboard input is available (console host)
$script:CanUseRawInput = ($Host.Name -eq 'ConsoleHost' -and $null -ne $Host.UI.RawUI)
function Test-ConsoleReadKeyAvailable {
try {
$null = [Console]::KeyAvailable
return $true
}
catch {
return $false
}
}
# ═════════════════════════════════════════════════════════════════════════════
# Utility
# ═════════════════════════════════════════════════════════════════════════════
function Read-SingleKey {
if ($script:CanUseRawInput) {
return $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
}
# Secondary path: direct console API (works in some non-ConsoleHost scenarios)
if (Test-ConsoleReadKeyAvailable) {
$k = [Console]::ReadKey($true)
return [PSCustomObject]@{
Character = $k.KeyChar
VirtualKeyCode = [int]$k.Key
}
}
# Final fallback: line input (requires Enter)
$line = Read-Host -Prompt ' '
return [PSCustomObject]@{
Character = if ($line.Length -gt 0) { $line[0] } else { [char]0 }
VirtualKeyCode = 0
}
}
function Write-Header {
param([string]$Title)
$bar = '═' * 52
Clear-Host
Write-Host $bar -ForegroundColor Cyan
Write-Host " $Title" -ForegroundColor Cyan
Write-Host $bar -ForegroundColor Cyan
Write-Host ''
}
function Write-Hint {
param([string]$Text)
Write-Host " $Text" -ForegroundColor DarkGray
}
function Pause-ForKey {
param([string]$Prompt = '')
if ([string]::IsNullOrWhiteSpace($Prompt)) {
if ($script:CanUseRawInput -or (Test-ConsoleReadKeyAvailable)) {
$Prompt = 'Press any key to return...'
}
else {
$Prompt = 'Press Enter to return...'
}
}
Write-Host ''
Write-Hint $Prompt
$null = Read-SingleKey
}
function Get-RunnerTask {
# Prefer configured task path; fall back to root path for legacy tasks.
$task = Get-ScheduledTask -TaskName $script:TaskName -TaskPath $script:TaskPath -ErrorAction SilentlyContinue
if ($task) { return $task }
return Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue
}
function Write-OperationResult {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][bool]$Success,
[Parameter(Mandatory)][string]$Message
)
$dir = Split-Path $Path -Parent
if (-not (Test-Path $dir)) {
New-Item -Path $dir -ItemType Directory -Force | Out-Null
}
@{
timestamp = (Get-Date).ToString('o')
success = $Success
message = $Message
} | ConvertTo-Json -Depth 5 | Set-Content -Path $Path -Encoding UTF8
}
function Read-OperationResult {
param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path $Path)) { return $null }
try {
return (Get-Content -Path $Path -Raw -Encoding UTF8 | ConvertFrom-Json)
}
catch {
return $null
}
}
# ═════════════════════════════════════════════════════════════════════════════
# Feature discovery
# ═════════════════════════════════════════════════════════════════════════════
function Get-FeatureFiles {
$featuresDir = Join-Path $script:InternalRoot 'features'
if (-not (Test-Path $featuresDir)) {
return @()
}
return @(Get-ChildItem -Path $featuresDir -Filter '*.ps1' -ErrorAction SilentlyContinue |
Sort-Object Name)
}
function Get-FeatureCacheSignature {
param([System.IO.FileInfo[]]$FeatureFiles)
return (($FeatureFiles | ForEach-Object {
'{0}:{1}' -f $_.FullName, $_.LastWriteTimeUtc.Ticks
}) -join '|')
}
function Load-FeaturesFromFiles {
param([System.IO.FileInfo[]]$FeatureFiles)
$features = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($file in $FeatureFiles) {
try {
. $file.FullName # defines $FeatureMeta + Invoke-Feature
$meta = $FeatureMeta # copy before the next iteration overwrites it
$features.Add([PSCustomObject]@{
File = $file.FullName
Meta = $meta
})
}
catch {
Write-Log -Level Warn `
-Message "Failed to load feature '$($file.Name)': $_" `
-Feature 'Configure'
}
}
return @($features)
}
function Complete-FeaturePrewarm {
param([switch]$Wait)
if (-not $script:FeaturesPrewarmTask) {
return $false
}
$task = $script:FeaturesPrewarmTask
if ($Wait) {
$null = $task.Handle.AsyncWaitHandle.WaitOne()
}
if (-not $task.Handle.IsCompleted) {
return $false
}
try {
$result = $task.PowerShell.EndInvoke($task.Handle)
if ($result.Count -gt 0) {
$payload = $result[0]
$script:FeaturesCache = @($payload.Features)
$script:FeaturesCacheSignature = [string]$payload.Signature
foreach ($errMsg in @($payload.Errors)) {
Write-Log -Level Warn -Message $errMsg -Feature 'Configure'
}
}
}
catch {
Write-Log -Level Warn -Message "Feature prewarm completion failed: $_" -Feature 'Configure'
}
finally {
try { $task.PowerShell.Dispose() } catch {}
$script:FeaturesPrewarmTask = $null
}
return $true
}
function Start-FeaturePrewarmAsync {
if ($script:FeaturesPrewarmTask) {
return
}
$featureFiles = Get-FeatureFiles
if ($featureFiles.Count -eq 0) {
$script:FeaturesCache = @()
$script:FeaturesCacheSignature = ''
return
}
$ps = [powershell]::Create()
$scriptBlock = @'
param([string]$FeaturesDir)
$featureFiles = Get-ChildItem -Path $FeaturesDir -Filter '*.ps1' -ErrorAction SilentlyContinue |
Sort-Object Name
$signature = (($featureFiles | ForEach-Object {
'{0}:{1}' -f $_.FullName, $_.LastWriteTimeUtc.Ticks
}) -join '|')
$features = [System.Collections.Generic.List[PSCustomObject]]::new()
$errors = [System.Collections.Generic.List[string]]::new()
foreach ($file in $featureFiles) {
try {
. $file.FullName
$meta = $FeatureMeta
$features.Add([PSCustomObject]@{
File = $file.FullName
Meta = $meta
})
}
catch {
$errors.Add(("Failed to load feature '{0}' during prewarm: {1}" -f $file.Name, $_.Exception.Message))
}
}
[PSCustomObject]@{
Signature = $signature
Features = @($features)
Errors = @($errors)
}
'@
$null = $ps.AddScript($scriptBlock).AddArgument((Join-Path $script:InternalRoot 'features'))
$handle = $ps.BeginInvoke()
$script:FeaturesPrewarmTask = @{
PowerShell = $ps
Handle = $handle
}
}
function Get-Features {
<#
.SYNOPSIS
Dot-sources every .ps1 in internal\features\ and collects their $FeatureMeta.
Returns an ordered list of [PSCustomObject]@{ File; Meta }.
#>
param([switch]$Refresh)
$featureFiles = Get-FeatureFiles
$cacheSignature = Get-FeatureCacheSignature -FeatureFiles $featureFiles
if (-not $Refresh -and $script:FeaturesCache -and $script:FeaturesCacheSignature -eq $cacheSignature) {
return $script:FeaturesCache
}
if (-not $Refresh) {
[void](Complete-FeaturePrewarm)
if ($script:FeaturesCache -and $script:FeaturesCacheSignature -eq $cacheSignature) {
return $script:FeaturesCache
}
if ($script:FeaturesPrewarmTask) {
Write-Hint 'Loading feature metadata, please wait...'
[void](Complete-FeaturePrewarm -Wait)
if ($script:FeaturesCache -and $script:FeaturesCacheSignature -eq $cacheSignature) {
return $script:FeaturesCache
}
}
}
$script:FeaturesCache = Load-FeaturesFromFiles -FeatureFiles $featureFiles
$script:FeaturesCacheSignature = $cacheSignature
return $script:FeaturesCache
}
# ═════════════════════════════════════════════════════════════════════════════
# Option 1 — Check registration state
# ═════════════════════════════════════════════════════════════════════════════
function Show-RegistrationState {
Write-Header 'Registration State'
Write-Log -Level Info -Message 'Checking runner registration state.' -Feature 'Configure'
$task = Get-RunnerTask
if (-not $task) {
Write-Host " Task '$($script:TaskName)' is NOT registered." -ForegroundColor Yellow
Write-Hint " Run option 2 to register it."
Write-Log -Level Warn -Message "Task '$($script:TaskName)' not found." -Feature 'Configure'
Pause-ForKey
return
}
$info = Get-ScheduledTaskInfo -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction SilentlyContinue
$stateColor = switch ($task.State) {
'Ready' { 'Green' }
'Running' { 'Cyan' }
default { 'Yellow' }
}
Write-Host " Task: " -NoNewline; Write-Host $task.TaskName -ForegroundColor Green
Write-Host " Task Path: $($task.TaskPath)"
Write-Host " State: " -NoNewline; Write-Host $task.State -ForegroundColor $stateColor
if ($info) {
$resultColor = if ($info.LastTaskResult -eq 0) { 'Green' } else { 'Yellow' }
Write-Host " Last Run: $($info.LastRunTime)"
Write-Host " Result: " -NoNewline
Write-Host ("0x{0:X}" -f $info.LastTaskResult) -ForegroundColor $resultColor
Write-Host " Next Run: $($info.NextRunTime)"
}
$runnerPath = Join-Path $script:InternalRoot 'runner.ps1'
$runnerLauncherPath = Join-Path $script:InternalRoot 'runner-launcher.vbs'
Write-Host ''
Write-Host " Runner: $runnerPath" -ForegroundColor DarkGray
Write-Log -Level Info -Message ("Task found at path '{0}' with state '{1}'." -f $task.TaskPath, $task.State) -Feature 'Configure'
Pause-ForKey
}
# ═════════════════════════════════════════════════════════════════════════════
# Option 2 — Register runner (scheduled task)
# ═════════════════════════════════════════════════════════════════════════════
function Register-Runner {
param(
[switch]$NoPause,
[switch]$ForceReRegister
)
Write-Header 'Register Runner'
Write-Log -Level Info -Message 'Register-Runner invoked.' -Feature 'Configure'
$runnerPath = Join-Path $script:InternalRoot 'runner.ps1'
$runnerLauncherPath = Join-Path $script:InternalRoot 'runner-launcher.vbs'
# Request elevation only for this operation, when needed.
if (-not (Test-Administrator)) {
Remove-Item -Path $script:RegisterResultPath -Force -ErrorAction SilentlyContinue
$elevationResult = Request-Elevation `
-ScriptPath $script:ConfigureScriptPath `
-ScriptArguments @('-AutoRegisterRunner') `
-BootstrapLogPath $script:ElevatedDiagPath `
-Wait `
-TimeoutSeconds 120
Write-Log -Level Info -Message ("Elevation requested for Register-Runner. Completed={0}; ExitCode={1}" -f $elevationResult['Completed'], $elevationResult['ExitCode']) -Feature 'Configure'
if ($elevationResult['Requested']) {
if (-not $elevationResult['Completed']) {
Write-Host ' Elevated registration did not complete within 120 seconds.' -ForegroundColor Yellow
Write-Hint ' You can check status manually with option 1 in a moment.'
Write-Log -Level Warn -Message 'Elevated registration timed out after 120 seconds.' -Feature 'Configure'
if (-not $NoPause) { Pause-ForKey }
return
}
$result = Read-OperationResult -Path $script:RegisterResultPath
if ($result) {
if (-not $result.success) {
Write-Host " Elevated registration failed: $($result.message)" -ForegroundColor Red
Write-Log -Level Error -Message ("Elevated registration failure reported: {0}" -f $result.message) -Feature 'Configure'
}
else {
Write-Log -Level Info -Message ("Elevated registration success reported: {0}" -f $result.message) -Feature 'Configure'
}
}
else {
$exitCode = $elevationResult['ExitCode']
Write-Host " Elevated registration exited with code $exitCode and did not return a result file." -ForegroundColor Red
Write-Hint " Check internal\\data\\logs\\elevated-process.log for the bootstrap error trace."
Write-Log -Level Warn -Message ("No elevated registration result file found. ExitCode={0}" -f $exitCode) -Feature 'Configure'
}
Write-Host ' Elevated registration finished. Verifying registration state...' -ForegroundColor Green
Start-Sleep -Milliseconds 400
Show-RegistrationState
return
}
Write-Host ' ERROR: This operation requires administrator privileges.' -ForegroundColor Red
Write-Log -Level Error -Message 'Elevation request was denied or failed.' -Feature 'Configure'
if (-not $NoPause) { Pause-ForKey }
return
}
if (-not (Test-Path $runnerPath)) {
Write-Host " ERROR: runner.ps1 not found at: $runnerPath" -ForegroundColor Red
Write-Log -Level Error -Message "runner.ps1 missing at '$runnerPath'." -Feature 'Configure'
Write-OperationResult -Path $script:RegisterResultPath -Success $false -Message "runner.ps1 missing at '$runnerPath'."
if (-not $NoPause) { Pause-ForKey }
return
}
if (-not (Test-Path $runnerLauncherPath)) {
Write-Host " ERROR: runner-launcher.vbs not found at: $runnerLauncherPath" -ForegroundColor Red
Write-Log -Level Error -Message "runner-launcher.vbs missing at '$runnerLauncherPath'." -Feature 'Configure'
Write-OperationResult -Path $script:RegisterResultPath -Success $false -Message "runner-launcher.vbs missing at '$runnerLauncherPath'."
if (-not $NoPause) { Pause-ForKey }
return
}
$existing = Get-RunnerTask
if ($existing) {
Write-Host " Task '$($script:TaskName)' already exists (state: $($existing.State))." -ForegroundColor Yellow
Write-Log -Level Info -Message ("Existing task found at '{0}'." -f $existing.TaskPath) -Feature 'Configure'
if (-not $ForceReRegister) {
Write-Host ''
Write-Host ' Re-register? (Y / N) ' -ForegroundColor White -NoNewline
$key = Read-SingleKey
Write-Host ''
if ([string]$key.Character -notin 'y', 'Y') {
Write-OperationResult -Path $script:RegisterResultPath -Success $false -Message 'Registration canceled by user.'
return
}
}
try {
Unregister-ScheduledTask -TaskName $existing.TaskName -TaskPath $existing.TaskPath -Confirm:$false -ErrorAction Stop
Write-Log -Level Info -Message 'Existing task removed before re-registration.' -Feature 'Configure'
}
catch {
Write-Host " ERROR: Could not remove existing task: $_" -ForegroundColor Red
Write-Log -Level Error -Message "Failed to remove existing task: $_" -Feature 'Configure'
Write-OperationResult -Path $script:RegisterResultPath -Success $false -Message "Failed to remove existing task: $_"
if (-not $NoPause) { Pause-ForKey }
return
}
}
try {
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$action = New-ScheduledTaskAction `
-Execute 'wscript.exe' `
-Argument "//B //Nologo `"$runnerLauncherPath`" `"$runnerPath`""
# Logon trigger — copy Repetition from a -Once trigger (reliable workaround)
$logonTrigger = New-ScheduledTaskTrigger -AtLogOn
$repeatTrigger = New-ScheduledTaskTrigger -Once -At '00:00' `
-RepetitionInterval (New-TimeSpan -Minutes $script:RunnerIntervalMinutes)
$logonTrigger.Repetition = $repeatTrigger.Repetition
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Hours 1) `
-MultipleInstances IgnoreNew `
-StartWhenAvailable
$principal = New-ScheduledTaskPrincipal `
-UserId $currentUser `
-LogonType Interactive `
-RunLevel Limited
Register-ScheduledTask `
-TaskName $script:TaskName `
-TaskPath $script:TaskPath `
-Action $action `
-Trigger $logonTrigger `
-Settings $settings `
-Principal $principal `
-Force `
-ErrorAction Stop | Out-Null
# Verify creation before reporting success
$createdTask = Get-RunnerTask
if (-not $createdTask) {
throw "Register-ScheduledTask returned without creating '$($script:TaskName)'."
}
# Start the task so runner begins executing
Start-ScheduledTask -TaskName $script:TaskName -TaskPath $script:TaskPath -ErrorAction Stop
Write-Host " Task '$($script:TaskName)' registered and started successfully." -ForegroundColor Green
Write-Host ''
Write-Hint (" Triggers: at logon + every {0} minutes" -f $script:RunnerIntervalMinutes)
Write-Hint " Runs: hidden PowerShell, current user, no elevation"
Write-Log -Level Info `
-Message ("Scheduled task '{0}' registered at path '{1}' for user '{2}'." -f $script:TaskName, $createdTask.TaskPath, $currentUser) `
-Feature 'Configure'
Write-OperationResult -Path $script:RegisterResultPath -Success $true -Message 'Runner registration completed successfully.'
}
catch {
Write-Host " ERROR: Failed to register task: $_" -ForegroundColor Red
Write-Log -Level Error -Message "Failed to register scheduled task: $_" -Feature 'Configure'
Write-OperationResult -Path $script:RegisterResultPath -Success $false -Message ("Failed to register scheduled task: $_")
Write-Host ''
Write-Hint " Tip: if this persists, check internal\data\logs\automation-<date>.log for details."
}
if (-not $NoPause) { Pause-ForKey }
}
# ═════════════════════════════════════════════════════════════════════════════
# Option 3 — Remove runner
# ═════════════════════════════════════════════════════════════════════════════
function Remove-Runner {
param([switch]$NoPause)
Write-Header 'Remove Runner'
Write-Log -Level Info -Message 'Remove-Runner invoked.' -Feature 'Configure'
if (-not (Test-Administrator)) {
Remove-Item -Path $script:RemoveResultPath -Force -ErrorAction SilentlyContinue
$elevationResult = Request-Elevation `
-ScriptPath $script:ConfigureScriptPath `
-ScriptArguments @('-AutoRemoveRunner') `
-BootstrapLogPath $script:ElevatedDiagPath `
-Wait `
-TimeoutSeconds 120
if ($elevationResult['Requested']) {
if (-not $elevationResult['Completed']) {
Write-Host ' Elevated removal did not complete within 120 seconds.' -ForegroundColor Yellow
if (-not $NoPause) { Pause-ForKey }
return
}
$result = Read-OperationResult -Path $script:RemoveResultPath
if ($result -and -not $result.success) {
Write-Host " Elevated removal failed: $($result.message)" -ForegroundColor Red
}
Write-Host ' Elevated removal finished. Verifying registration state...' -ForegroundColor Green
Start-Sleep -Milliseconds 400
Show-RegistrationState
return
}
Write-Host ' ERROR: This operation requires administrator privileges.' -ForegroundColor Red
if (-not $NoPause) { Pause-ForKey }
return
}
$task = Get-RunnerTask
if (-not $task) {
Write-Host " Task '$($script:TaskName)' is not registered." -ForegroundColor Yellow
Write-OperationResult -Path $script:RemoveResultPath -Success $true -Message 'Task already absent.'
if (-not $NoPause) { Pause-ForKey }
return
}
try {
Unregister-ScheduledTask -TaskName $task.TaskName -TaskPath $task.TaskPath -Confirm:$false -ErrorAction Stop
Write-Host " Task '$($script:TaskName)' removed successfully." -ForegroundColor Green
Write-Log -Level Info -Message ("Task '{0}' removed from path '{1}'." -f $task.TaskName, $task.TaskPath) -Feature 'Configure'
Write-OperationResult -Path $script:RemoveResultPath -Success $true -Message 'Runner removed successfully.'
}
catch {
Write-Host " ERROR: Failed to remove task: $_" -ForegroundColor Red
Write-Log -Level Error -Message "Failed to remove task: $_" -Feature 'Configure'
Write-OperationResult -Path $script:RemoveResultPath -Success $false -Message ("Failed to remove task: $_")
}
if (-not $NoPause) { Pause-ForKey }
}
# ═════════════════════════════════════════════════════════════════════════════
# Option 4 — Feature config sub-menu (called when user presses C)
# ═════════════════════════════════════════════════════════════════════════════
function Show-FeatureConfigMenu {
param(
[PSCustomObject]$Feature,
[hashtable]$Config
)
$name = $Feature.Meta.Name
Write-Header "Configure: $name"
Write-Hint $Feature.Meta.Description
Write-Host ''
if ($Feature.Meta.Settings.Count -eq 0) {
Write-Hint "This feature has no configurable settings."
Pause-ForKey
return
}
foreach ($setting in $Feature.Meta.Settings) {
$currentValue = $Config.features[$name][$setting.Key]
Write-Host " $($setting.Label)" -ForegroundColor White
Write-Hint " $($setting.Description)"
Write-Host " Current: " -NoNewline
Write-Host $currentValue -ForegroundColor Yellow
Write-Host ''
$input = Read-Host " New value (Enter to keep)"
if ($input -ne '') {
try {
$newValue = switch ($setting.Type) {
'int' { [int]$input }
'bool' { [System.Convert]::ToBoolean($input) }
default { $input }
}
$Config.features[$name][$setting.Key] = $newValue
}
catch {
Write-Host " Invalid value for type '$($setting.Type)' — keeping current." -ForegroundColor Red
}
}
Write-Host ''
}
Save-Config $Config
Write-Host ' Settings saved.' -ForegroundColor Green
Start-Sleep -Milliseconds 600
}
# ═════════════════════════════════════════════════════════════════════════════
# Option 4 — Feature configuration list
# ═════════════════════════════════════════════════════════════════════════════
function Show-FeatureMenu {
$features = Get-Features
if ($features.Count -eq 0) {
Write-Header 'Configure Features'
Write-Host ' No feature files found in internal\features\.' -ForegroundColor Yellow
Pause-ForKey
return
}
# Seed any missing config entries from feature metadata
$config = Get-Config
foreach ($feature in $features) {
$config = Ensure-FeatureConfig -Config $config -FeatureMeta $feature.Meta
}
Save-Config $config
while ($true) {
Write-Header 'Configure Features'
Write-Hint 'Type feature number to toggle, C<number> to configure, Q to go back'
Write-Host ''
for ($i = 0; $i -lt $features.Count; $i++) {
$num = $i + 1
$name = $features[$i].Meta.Name
$desc = $features[$i].Meta.Description
$enabled = $config.features[$name]['enabled']
$chk = if ($enabled) { '[X]' } else { '[ ]' }
$shortDesc = if ($desc.Length -gt 50) { $desc.Substring(0, 47) + '...' } else { $desc }
Write-Host " $num $chk $name" -ForegroundColor White -NoNewline
Write-Host " $shortDesc" -ForegroundColor DarkGray
}
Write-Host ''
$input = Read-Host 'Enter command'
$input = $input.Trim()
if ($input -eq '' -or $input -eq 'q' -or $input -eq 'Q') {
return
}
elseif ($input -match '^c(\d+)$' -or $input -match '^C(\d+)$') {
# C<number> — configure
$idx = [int]$Matches[1] - 1
if ($idx -ge 0 -and $idx -lt $features.Count) {
Show-FeatureConfigMenu -Feature $features[$idx] -Config $config
$config = Get-Config
}
}
elseif ($input -match '^\d+$') {
# Just a number — toggle
$idx = [int]$input - 1
if ($idx -ge 0 -and $idx -lt $features.Count) {
$name = $features[$idx].Meta.Name
$config.features[$name]['enabled'] = -not $config.features[$name]['enabled']
Save-Config $config
$newState = if ($config.features[$name]['enabled']) { 'enabled' } else { 'disabled' }
Write-Log -Level Info `
-Message "Feature '$name' $newState via configure.ps1." `
-Feature 'Configure'
}
}
}
}
function Initialize-FeatureCache {
try {
Start-FeaturePrewarmAsync
Write-Log -Level Info -Message 'Feature cache prewarm started in background.' -Feature 'Configure'
}
catch {
Write-Log -Level Warn -Message "Feature cache prewarm failed: $_" -Feature 'Configure'
}
}
# ═════════════════════════════════════════════════════════════════════════════
# Main menu
# ═════════════════════════════════════════════════════════════════════════════
function Invoke-RunnerNow {
$runnerPath = Join-Path $script:InternalRoot 'runner.ps1'
$runnerLauncherPath = Join-Path $script:InternalRoot 'runner-launcher.vbs'
$task = Get-RunnerTask
if (-not (Test-Path $runnerPath)) {
Write-Host " ERROR: runner.ps1 not found at: $runnerPath" -ForegroundColor Red
Write-Log -Level Error -Message "runner.ps1 missing at '$runnerPath'." -Feature 'Configure'
Write-Host ''
Pause-ForKey
return
}
if (-not (Test-Path $runnerLauncherPath)) {
Write-Host " ERROR: runner-launcher.vbs not found at: $runnerLauncherPath" -ForegroundColor Red
Write-Log -Level Error -Message "runner-launcher.vbs missing at '$runnerLauncherPath'." -Feature 'Configure'
Write-Host ''
Pause-ForKey
return
}
try {
Write-Host ''
if ($task) {
Write-Host " Executing registered runner task now..." -ForegroundColor Cyan
Start-ScheduledTask -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction Stop
Write-Host " Task execution started (runs async). Check logs in a moment." -ForegroundColor Green
Write-Hint " Note: The 2-minute repetition timer only starts on next logon/restart."
Write-Log -Level Info -Message "Runner task invoked manually from configure menu." -Feature 'Configure'
}
else {
Write-Host " Task not registered; executing runner directly (hidden)..." -ForegroundColor Yellow
Start-Process -FilePath 'wscript.exe' -ArgumentList "//B //Nologo `"$runnerLauncherPath`" `"$runnerPath`"" -WindowStyle Hidden
Write-Host " Runner launch started (runs async). Check logs in a moment." -ForegroundColor Green
Write-Log -Level Info -Message "Runner invoked manually from configure menu via hidden launcher (task absent)." -Feature 'Configure'
}
}
catch {
Write-Host " ERROR: Failed to start runner: $_" -ForegroundColor Red
Write-Log -Level Error -Message "Failed to start runner manually: $_" -Feature 'Configure'
}
Write-Host ''
Pause-ForKey
}
function Show-MainMenu {
while ($true) {
Write-Header 'PowerShell Automation Center'
Write-Host ' 1 Check registration state'
Write-Host ' 2 Register runner'
Write-Host ' 3 Remove registered runner'
Write-Host ' 4 Configure features'
Write-Host ' 5 Execute runner now'
Write-Host ''
Write-Hint ' Q Exit'
Write-Host ''
$key = Read-SingleKey
$ch = [string]$key.Character
switch ($ch) {
'1' { Show-RegistrationState }
'2' { Register-Runner }
'3' { Remove-Runner }
'4' { Show-FeatureMenu }
'5' { Invoke-RunnerNow }
{ $_ -in 'q', 'Q' } {
Clear-Host
return
}
}
}
}
if ($AutoRegisterRunner) {
try {
Write-ElevatedDiag 'AutoRegisterRunner started.'
Register-Runner -NoPause -ForceReRegister
if (-not (Test-Path $script:RegisterResultPath)) {
Write-OperationResultEarly -Path $script:RegisterResultPath -Success $true -Message 'Runner registration completed (fallback success result).'
}
Write-ElevatedDiag 'AutoRegisterRunner finished without unhandled exceptions.'
Pause-ForKey -Prompt 'Press any key to close elevated window...'
exit 0
}
catch {
$msg = "Unhandled error in AutoRegisterRunner: $($_.Exception.Message)"
Write-ElevatedDiag $msg
Write-ElevatedDiag ("Stack: {0}" -f $_.ScriptStackTrace)
Write-OperationResultEarly -Path $script:RegisterResultPath -Success $false -Message $msg
Pause-ForKey -Prompt 'Press any key to close elevated window...'
exit 1
}
}
if ($AutoRemoveRunner) {
try {
Write-ElevatedDiag 'AutoRemoveRunner started.'
Remove-Runner -NoPause
if (-not (Test-Path $script:RemoveResultPath)) {
Write-OperationResultEarly -Path $script:RemoveResultPath -Success $true -Message 'Runner removal completed (fallback success result).'
}
Write-ElevatedDiag 'AutoRemoveRunner finished without unhandled exceptions.'
Pause-ForKey -Prompt 'Press any key to close elevated window...'
exit 0
}
catch {
$msg = "Unhandled error in AutoRemoveRunner: $($_.Exception.Message)"
Write-ElevatedDiag $msg
Write-ElevatedDiag ("Stack: {0}" -f $_.ScriptStackTrace)
Write-OperationResultEarly -Path $script:RemoveResultPath -Success $false -Message $msg
Pause-ForKey -Prompt 'Press any key to close elevated window...'
exit 1
}
}
if (-not $AutoRegisterRunner -and -not $AutoRemoveRunner) {
Initialize-FeatureCache
}
Show-MainMenu