Add new features: DefaultBrowser, DynamicLock, SandwichReminder

- Implemented DefaultBrowser feature to notify users when the default browser does not match the configured app.
- Added DynamicLock feature to disable Dynamic Lock while connected to a specific network and re-enable it after disconnecting.
- Created SandwichReminder feature to prompt users to order a sandwich during work hours based on network and time settings.

Introduced helper libraries for configuration, elevation, logging, network utilities, and toast notifications.

- Config.ps1: Added functions for reading and writing configuration and state files.
- Elevation.ps1: Added functions to check for administrator privileges and request elevation.
- Logging.ps1: Implemented a shared logging utility for consistent logging across features.
- NetworkUtils.ps1: Added a function to check for DNS suffix connectivity.
- ToastHelper.ps1: Created a helper for displaying Windows toast notifications.

Implemented runner.ps1 as the main entry point for executing features based on configuration.
This commit is contained in:
Arne Moerman
2026-05-08 11:48:39 +02:00
commit 34ea1eb4b2
12 changed files with 1771 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
# ── Feature metadata ──────────────────────────────────────────────────────────
$FeatureMeta = @{
Name = 'DefaultBrowser'
Description = 'Notify when the default browser does not match the configured app'
Settings = @(
@{
Key = 'targetProgId'
Label = 'Target ProgId'
Type = 'string'
Default = 'OperaGXStable'
Description = 'ProgId of the desired default browser (e.g. OperaGXStable, ChromeHTML, FirefoxURL-308046B0AF4A39CB)'
}
)
}
# ── Feature implementation ────────────────────────────────────────────────────
function Invoke-Feature {
param(
[hashtable]$Config,
[hashtable]$State
)
if (-not $State) { $State = @{} }
if (-not $State.ContainsKey('lastShownDate')) { $State['lastShownDate'] = $null }
$today = (Get-Date).ToString('yyyy-MM-dd')
# Only notify once per day
if ($State['lastShownDate'] -eq $today) {
Write-Log -Level Info -Message 'Already checked today, skipping.' -Feature 'DefaultBrowser'
return $State
}
$regPath = 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice'
try {
$currentProgId = (Get-ItemProperty -Path $regPath -Name 'ProgId' -ErrorAction Stop).ProgId
}
catch {
Write-Log -Level Warn `
-Message "Could not read current default browser ProgId from registry: $_" `
-Feature 'DefaultBrowser'
return $State
}
if ($currentProgId -eq $Config['targetProgId']) {
Write-Log -Level Info `
-Message "Default browser OK: '$currentProgId'." `
-Feature 'DefaultBrowser'
$State['lastShownDate'] = $today
return $State
}
Write-Log -Level Warn `
-Message "Default browser mismatch — found '$currentProgId', expected '$($Config['targetProgId'])'." `
-Feature 'DefaultBrowser'
# Note: Windows 11 blocks programmatic default-browser changes via registry hash protection.
# We guide the user to the Settings page instead.
Show-ToastNotification `
-Title 'Default Browser' `
-Body ("Default browser is '$currentProgId'. Click below to set it to $($Config['targetProgId']).") `
-Buttons @(
@{ Label = 'Open Default Apps Settings'; Action = 'ms-settings:defaultapps' },
@{ Label = 'Dismiss'; Action = 'dismiss' }
)
$State['lastShownDate'] = $today
return $State
}
+99
View File
@@ -0,0 +1,99 @@
# ── Feature metadata ──────────────────────────────────────────────────────────
# $FeatureMeta is read by configure.ps1 for discovery, display, and config UI.
# Invoke-Feature is called by runner.ps1 on each scheduled cycle.
$FeatureMeta = @{
Name = 'DynamicLock'
Description = 'Disable Dynamic Lock while on a specific network; re-enable after disconnecting'
Settings = @(
@{
Key = 'network'
Label = 'DNS Suffix'
Type = 'string'
Default = 'h.arnemoerman.be'
Description = 'Connection-specific DNS suffix of the target network (e.g. h.arnemoerman.be)'
},
@{
Key = 'revertAfterMinutes'
Label = 'Revert After (minutes)'
Type = 'int'
Default = 10
Description = 'Re-enable Dynamic Lock after being disconnected for this many minutes'
}
)
}
# ── Feature implementation ────────────────────────────────────────────────────
function Invoke-Feature {
param(
[hashtable]$Config,
[hashtable]$State
)
if (-not $State) { $State = @{} }
if (-not $State.ContainsKey('isConnected')) { $State['isConnected'] = $false }
if (-not $State.ContainsKey('lastConnectedTime')) { $State['lastConnectedTime'] = $null }
if (-not $State.ContainsKey('dynamicLockDisabled')) { $State['dynamicLockDisabled'] = $false }
$regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
$isConnected = Test-DnsSuffixConnected -Suffix $Config['network']
if ($isConnected) {
$State['isConnected'] = $true
$State['lastConnectedTime'] = (Get-Date).ToString('o')
if (-not $State['dynamicLockDisabled']) {
if (-not (Test-Path $regPath)) {
New-Item -Path $regPath -Force | Out-Null
}
Set-ItemProperty -Path $regPath -Name 'EnableGoodbye' -Value 0 -Type DWord
$State['dynamicLockDisabled'] = $true
Write-Log -Level Info `
-Message "Dynamic Lock disabled (connected to '$($Config['network'])')" `
-Feature 'DynamicLock'
}
}
else {
$State['isConnected'] = $false
# If we disabled Dynamic Lock earlier, check whether we've been away long enough to re-enable
if ($State['dynamicLockDisabled'] -and $null -ne $State['lastConnectedTime']) {
try {
$lastConnected = [datetime]::Parse($State['lastConnectedTime'])
$minutesGone = ((Get-Date) - $lastConnected).TotalMinutes
if ($minutesGone -ge [double]$Config['revertAfterMinutes']) {
if (-not (Test-Path $regPath)) {
New-Item -Path $regPath -Force | Out-Null
}
Set-ItemProperty -Path $regPath -Name 'EnableGoodbye' -Value 1 -Type DWord
$State['dynamicLockDisabled'] = $false
Write-Log -Level Info `
-Message ("Dynamic Lock re-enabled (disconnected for {0} min, threshold {1} min)" -f
[int]$minutesGone, $Config['revertAfterMinutes']) `
-Feature 'DynamicLock'
}
else {
Write-Log -Level Info `
-Message ("Waiting to re-enable Dynamic Lock ({0}/{1} min elapsed)" -f
[int]$minutesGone, $Config['revertAfterMinutes']) `
-Feature 'DynamicLock'
}
}
catch {
Write-Log -Level Warn `
-Message "Could not parse lastConnectedTime '$($State['lastConnectedTime'])': $_" `
-Feature 'DynamicLock'
}
}
else {
Write-Log -Level Info `
-Message "Not connected to '$($Config['network'])'; Dynamic Lock state unchanged." `
-Feature 'DynamicLock'
}
}
return $State
}
+102
View File
@@ -0,0 +1,102 @@
# ── Feature metadata ──────────────────────────────────────────────────────────
$FeatureMeta = @{
Name = 'SandwichReminder'
Description = 'Ask if you want to order a sandwich when at work at the configured time'
Settings = @(
@{
Key = 'network'
Label = 'Work Network DNS Suffix'
Type = 'string'
Default = 'sioen.grp'
Description = 'DNS suffix that identifies the work network'
},
@{
Key = 'reminderTime'
Label = 'Reminder Time (HH:mm)'
Type = 'string'
Default = '09:00'
Description = 'Time to show the reminder in 24-hour format (e.g. 09:00)'
},
@{
Key = 'windowMinutes'
Label = 'Time Window (minutes)'
Type = 'int'
Default = 5
Description = 'Show the reminder if the runner fires within this many minutes of the reminder time'
},
@{
Key = 'url'
Label = 'Order URL'
Type = 'string'
Default = 'https://ylos-kitchen.unipage.eu'
Description = 'URL to open when clicking Yes'
}
)
}
# ── Feature implementation ────────────────────────────────────────────────────
function Invoke-Feature {
param(
[hashtable]$Config,
[hashtable]$State
)
if (-not $State) { $State = @{} }
if (-not $State.ContainsKey('lastShownDate')) { $State['lastShownDate'] = $null }
$today = (Get-Date).ToString('yyyy-MM-dd')
# Only show once per day
if ($State['lastShownDate'] -eq $today) {
Write-Log -Level Info -Message 'Already shown today, skipping.' -Feature 'SandwichReminder'
return $State
}
# Parse reminder time
try {
$targetTime = [datetime]::ParseExact($Config['reminderTime'], 'HH:mm', $null)
}
catch {
Write-Log -Level Error `
-Message "Invalid reminderTime format '$($Config['reminderTime'])' — expected HH:mm: $_" `
-Feature 'SandwichReminder'
return $State
}
# Check whether we are within the configured time window
$now = Get-Date
$todayTarget = Get-Date -Hour $targetTime.Hour -Minute $targetTime.Minute -Second 0
$diffMinutes = [Math]::Abs(($now - $todayTarget).TotalMinutes)
$window = [int]$Config['windowMinutes']
if ($diffMinutes -gt $window) {
Write-Log -Level Info `
-Message ("Outside time window (diff: {0:F1} min, window: ±{1} min), skipping." -f $diffMinutes, $window) `
-Feature 'SandwichReminder'
return $State
}
# Check network
if (-not (Test-DnsSuffixConnected -Suffix $Config['network'])) {
Write-Log -Level Info `
-Message "Not connected to work network ('$($Config['network'])'), skipping." `
-Feature 'SandwichReminder'
return $State
}
Write-Log -Level Info -Message 'Showing sandwich reminder toast.' -Feature 'SandwichReminder'
Show-ToastNotification `
-Title 'Sandwich Order' `
-Body 'Do you want to order a sandwich today?' `
-Buttons @(
@{ Label = 'Yes, order now!'; Action = $Config['url'] },
@{ Label = 'No thanks'; Action = 'dismiss' }
)
$State['lastShownDate'] = $today
return $State
}
+127
View File
@@ -0,0 +1,127 @@
# Config.ps1 — config.json and state.json read/write helpers
# Requires $InternalRoot to be defined in the calling script's scope before dot-sourcing.
function Get-ConfigPath { Join-Path $InternalRoot 'data\config.json' }
function Get-StatePath { Join-Path $InternalRoot 'data\state\state.json' }
# ── Config ────────────────────────────────────────────────────────────────────
function Get-Config {
$path = Get-ConfigPath
if (-not (Test-Path $path)) {
return @{ features = @{} }
}
try {
$raw = Get-Content -Path $path -Raw -Encoding UTF8 -ErrorAction Stop
return ConvertTo-DeepHashtable ($raw | ConvertFrom-Json)
}
catch {
Write-Log -Level Error -Message "Failed to read config.json, returning empty defaults: $_" -Feature 'Config'
return @{ features = @{} }
}
}
function Save-Config {
param([hashtable]$Config)
$path = Get-ConfigPath
$dir = Split-Path $path -Parent
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
try {
$Config | ConvertTo-Json -Depth 10 | Set-Content -Path $path -Encoding UTF8 -ErrorAction Stop
}
catch {
Write-Log -Level Error -Message "Failed to save config.json: $_" -Feature 'Config'
}
}
# ── State ─────────────────────────────────────────────────────────────────────
function Get-State {
$path = Get-StatePath
if (-not (Test-Path $path)) {
return @{}
}
try {
$raw = Get-Content -Path $path -Raw -Encoding UTF8 -ErrorAction Stop
return ConvertTo-DeepHashtable ($raw | ConvertFrom-Json)
}
catch {
Write-Log -Level Error -Message "Failed to read state.json, returning empty state: $_" -Feature 'Config'
return @{}
}
}
function Save-State {
param([hashtable]$State)
$path = Get-StatePath
$dir = Split-Path $path -Parent
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
try {
$State | ConvertTo-Json -Depth 10 | Set-Content -Path $path -Encoding UTF8 -ErrorAction Stop
}
catch {
Write-Log -Level Error -Message "Failed to save state.json: $_" -Feature 'Config'
}
}
# ── Feature config seeding ────────────────────────────────────────────────────
function Ensure-FeatureConfig {
<#
.SYNOPSIS
Ensures config.json contains an entry for the given feature, with all
settings keys present. Missing keys are filled from $FeatureMeta.Settings[*].Default.
Returns the (potentially modified) config hashtable.
#>
param(
[hashtable]$Config,
[hashtable]$FeatureMeta
)
$name = $FeatureMeta.Name
if (-not $Config.features.ContainsKey($name)) {
$Config.features[$name] = @{ enabled = $false }
}
foreach ($setting in $FeatureMeta.Settings) {
if (-not $Config.features[$name].ContainsKey($setting.Key)) {
$Config.features[$name][$setting.Key] = $setting.Default
}
}
return $Config
}
# ── Internal helper ───────────────────────────────────────────────────────────
function ConvertTo-DeepHashtable {
<#
.SYNOPSIS
Recursively converts PSCustomObject (from ConvertFrom-Json) into hashtables
so all config/state values are mutable and support .ContainsKey().
#>
param([object]$InputObject)
if ($null -eq $InputObject) { return $null }
if ($InputObject -is [hashtable]) { return $InputObject }
if ($InputObject -is [System.Collections.IEnumerable] -and
$InputObject -isnot [string]) {
return @($InputObject | ForEach-Object { ConvertTo-DeepHashtable $_ })
}
if ($InputObject -is [PSCustomObject]) {
$hash = @{}
foreach ($prop in $InputObject.PSObject.Properties) {
$hash[$prop.Name] = ConvertTo-DeepHashtable $prop.Value
}
return $hash
}
return $InputObject
}
+160
View File
@@ -0,0 +1,160 @@
# Elevation.ps1 — elevation helpers
# Requires $InternalRoot to be defined in the calling script's scope before dot-sourcing.
function Test-Administrator {
<#
.SYNOPSIS
Returns $true if the current process has administrator privileges.
#>
$id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object System.Security.Principal.WindowsPrincipal($id)
return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Request-Elevation {
<#
.SYNOPSIS
If not running as admin, spawns an elevated PowerShell process to re-run the current script.
The calling script should exit after this call (the elevated process takes over).
Returns $true if elevation was requested (parent process should exit).
Returns $false if already elevated.
#>
param(
[Parameter(Mandatory)]
[string]$ScriptPath,
[string[]]$ScriptArguments = @(),
[string]$BootstrapLogPath = $null,
[switch]$Wait,
[int]$TimeoutSeconds = 120
)
if (Test-Administrator) {
return @{
Requested = $false
Completed = $true
ExitCode = 0
Error = $null
}
}
Write-Host ''
Write-Host ' Elevation required. Requesting administrator privileges...' -ForegroundColor Yellow
Write-Host ''
try {
$logPath = if ($BootstrapLogPath) {
$BootstrapLogPath
} else {
Join-Path (Split-Path $ScriptPath -Parent) 'internal\data\logs\elevated-bootstrap.log'
}
$scriptPathEsc = $ScriptPath.Replace("'", "''")
$logPathEsc = $logPath.Replace("'", "''")
$argInvocationText = if ($ScriptArguments.Count -gt 0) {
($ScriptArguments | ForEach-Object {
# Keep parameter-like tokens (e.g. -AutoRegisterRunner) unquoted so
# PowerShell binds them as named/switch parameters.
if ($_ -match '^-[A-Za-z]') {
$_
}
else {
"'" + $_.Replace("'", "''") + "'"
}
}) -join ' '
}
else {
''
}
$invokeLine = if ([string]::IsNullOrWhiteSpace($argInvocationText)) {
"& '$scriptPathEsc'"
}
else {
"& '$scriptPathEsc' $argInvocationText"
}
$bootstrap = @"
`$ErrorActionPreference = 'Stop'
function Write-BootstrapLog([string]`$message) {
try {
`$dir = Split-Path '$logPathEsc' -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 '$logPathEsc' -Value `$line -Encoding UTF8
}
catch { }
}
Write-BootstrapLog "Elevated bootstrap started. ScriptPath=$scriptPathEsc"
try {
$invokeLine
`$code = if (`$LASTEXITCODE -is [int]) { `$LASTEXITCODE } else { 0 }
Write-BootstrapLog "Elevated bootstrap finished with ExitCode=`$code"
exit `$code
}
catch {
Write-BootstrapLog ("Elevated bootstrap unhandled error: " + `$_.Exception.Message)
Write-BootstrapLog ("Stack: " + `$_.ScriptStackTrace)
exit 1
}
"@
$encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($bootstrap))
$proc = Start-Process `
-FilePath 'powershell.exe' `
-ArgumentList "-NoProfile -ExecutionPolicy Bypass -EncodedCommand $encoded" `
-Verb RunAs `
-PassThru `
-ErrorAction Stop
if ($Wait) {
if ($TimeoutSeconds -gt 0) {
$null = $proc.WaitForExit($TimeoutSeconds * 1000)
}
else {
$proc.WaitForExit()
}
if (-not $proc.HasExited) {
return @{
Requested = $true
Completed = $false
ExitCode = $null
Error = $null
}
}
return @{
Requested = $true
Completed = $true
ExitCode = $proc.ExitCode
Error = $null
}
}
return @{
Requested = $true
Completed = $null
ExitCode = $null
Error = $null
}
}
catch {
Write-Host " Failed to request elevation: $_" -ForegroundColor Red
return @{
Requested = $false
Completed = $false
ExitCode = $null
Error = $_
}
}
}
+38
View File
@@ -0,0 +1,38 @@
# Logging.ps1 — shared logging utility
# Requires $InternalRoot to be defined in the calling script's scope before dot-sourcing.
function Write-Log {
[CmdletBinding()]
param(
[ValidateSet('Info', 'Warn', 'Error')]
[string]$Level = 'Info',
[Parameter(Mandatory)]
[string]$Message,
[string]$Feature = 'General'
)
$logDir = Join-Path $InternalRoot 'data\logs'
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
}
$logFile = Join-Path $logDir "automation-$(Get-Date -Format 'yyyy-MM-dd').log"
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$levelPad = $Level.ToUpper().PadRight(5)
$line = "[$timestamp] [$levelPad] [$Feature] $Message"
try {
Add-Content -Path $logFile -Value $line -Encoding UTF8 -ErrorAction Stop
}
catch {
# Logging must never crash the caller — silently ignore write failures
}
# Rotate: remove logs older than 7 days
Get-ChildItem -Path $logDir -Filter 'automation-*.log' -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) } |
Remove-Item -Force -ErrorAction SilentlyContinue
}
+35
View File
@@ -0,0 +1,35 @@
# NetworkUtils.ps1 — network detection utilities
# Requires $InternalRoot to be defined in the calling script's scope before dot-sourcing.
function Test-DnsSuffixConnected {
<#
.SYNOPSIS
Returns $true if any currently UP network adapter has a connection-specific
DNS suffix that contains the specified suffix string.
#>
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Mandatory)]
[string]$Suffix
)
try {
$clients = Get-DnsClient -ErrorAction Stop
foreach ($client in $clients) {
if ($client.ConnectionSpecificSuffix -notlike "*$Suffix*") { continue }
# Verify the adapter is actually connected/up
$adapter = Get-NetAdapter -InterfaceIndex $client.InterfaceIndex -ErrorAction SilentlyContinue
if ($adapter -and $adapter.Status -eq 'Up') {
return $true
}
}
return $false
}
catch {
Write-Log -Level Warn -Message "DNS suffix check failed for '$Suffix': $_" -Feature 'NetworkUtils'
return $false
}
}
+81
View File
@@ -0,0 +1,81 @@
# ToastHelper.ps1 — Windows toast notification helper (WinRT, no external modules)
# Requires $InternalRoot to be defined in the calling script's scope before dot-sourcing.
function Show-ToastNotification {
<#
.SYNOPSIS
Shows a Windows toast notification with optional action buttons.
.PARAMETER Buttons
Array of hashtables with keys:
Label — button text
Action — URI to invoke (http://, https://, ms-settings:, etc.)
Use empty string or 'dismiss' for a dismiss button.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Title,
[Parameter(Mandatory)]
[string]$Body,
[object[]]$Buttons = @()
)
try {
# Load WinRT types.
# Note: [TypeName, Assembly, ContentType=WindowsRuntime] is valid PowerShell 5.1+ syntax
# for WinRT interop. Static parsers may flag it as an error — this is a known false positive.
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
# Use PowerShell's own AUMID — registered in Start Menu, ensures toast delivery
$AppId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
# Build actions XML
$actionsXml = ''
if ($Buttons.Count -gt 0) {
$actionsXml = '<actions>'
foreach ($btn in $Buttons) {
$label = [System.Security.SecurityElement]::Escape($btn.Label)
$isDismiss = ([string]$btn.Action -eq '' -or [string]$btn.Action -eq 'dismiss')
if ($isDismiss) {
$actionsXml += "<action content=""$label"" arguments=""dismiss"" activationType=""system""/>"
} else {
$action = [System.Security.SecurityElement]::Escape($btn.Action)
$activationType = 'protocol' # covers http://, https://, ms-settings:, etc.
$actionsXml += "<action content=""$label"" arguments=""$action"" activationType=""$activationType""/>"
}
}
$actionsXml += '</actions>'
}
$escapedTitle = [System.Security.SecurityElement]::Escape($Title)
$escapedBody = [System.Security.SecurityElement]::Escape($Body)
$toastXml = @"
<toast>
<visual>
<binding template="ToastGeneric">
<text>$escapedTitle</text>
<text>$escapedBody</text>
</binding>
</visual>
$actionsXml
</toast>
"@
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($toastXml)
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($AppId).Show($toast)
Write-Log -Level Info -Message "Toast shown: '$Title'" -Feature 'ToastHelper'
}
catch {
Write-Log -Level Error -Message "Failed to show toast '$Title': $_" -Feature 'ToastHelper'
}
}
+69
View File
@@ -0,0 +1,69 @@
#Requires -Version 5.1
# runner.ps1 — background entry point; called by the scheduled task.
# All output goes to the log file only (no console window).
$ErrorActionPreference = 'Stop'
$script:InternalRoot = $PSScriptRoot # runner.ps1 lives in internal\
# ── Load shared libraries ─────────────────────────────────────────────────────
. (Join-Path $InternalRoot 'lib\Logging.ps1')
. (Join-Path $InternalRoot 'lib\NetworkUtils.ps1')
. (Join-Path $InternalRoot 'lib\ToastHelper.ps1')
. (Join-Path $InternalRoot 'lib\Config.ps1')
Write-Log -Level Info -Message '────── Runner started ──────' -Feature 'Runner'
# ── Load config and state ─────────────────────────────────────────────────────
$config = Get-Config
$state = Get-State
# ── Run each enabled feature ──────────────────────────────────────────────────
$featuresDir = Join-Path $InternalRoot 'features'
foreach ($featureKey in $config.features.Keys) {
$featureConfig = $config.features[$featureKey]
if (-not $featureConfig['enabled']) {
continue
}
$featureFile = Join-Path $featuresDir "$featureKey.ps1"
if (-not (Test-Path $featureFile)) {
Write-Log -Level Warn `
-Message "Feature '$featureKey' is enabled but '$featureFile' was not found. Skipping." `
-Feature 'Runner'
continue
}
try {
# Dot-source the feature — defines $FeatureMeta and Invoke-Feature in local scope.
# Each iteration overwrites Invoke-Feature, which is fine because we call it immediately.
$ErrorActionPreference = 'Stop'
. $featureFile
$featureState = if ($state.ContainsKey($featureKey)) { $state[$featureKey] } else { @{} }
$updatedState = Invoke-Feature -Config $featureConfig -State $featureState
if ($null -ne $updatedState) {
$state[$featureKey] = $updatedState
}
Write-Log -Level Info -Message "Feature '$featureKey' completed." -Feature 'Runner'
}
catch {
Write-Log -Level Error `
-Message "Feature '$featureKey' threw an unhandled exception: $_" `
-Feature 'Runner'
# Continue to the next feature regardless of this failure
}
finally {
$ErrorActionPreference = 'Stop'
}
}
# ── Persist updated state ─────────────────────────────────────────────────────
Save-State $state
Write-Log -Level Info -Message '────── Runner finished ──────' -Feature 'Runner'