From 34ea1eb4b2cba71ab0879965dec39dda8d88cb82 Mon Sep 17 00:00:00 2001 From: Arne Moerman Date: Fri, 8 May 2026 11:48:39 +0200 Subject: [PATCH] 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. --- .gitignore | 16 + README.md | 288 +++++++++++ configure.ps1 | 683 +++++++++++++++++++++++++ internal/features/DefaultBrowser.ps1 | 73 +++ internal/features/DynamicLock.ps1 | 99 ++++ internal/features/SandwichReminder.ps1 | 102 ++++ internal/lib/Config.ps1 | 127 +++++ internal/lib/Elevation.ps1 | 160 ++++++ internal/lib/Logging.ps1 | 38 ++ internal/lib/NetworkUtils.ps1 | 35 ++ internal/lib/ToastHelper.ps1 | 81 +++ internal/runner.ps1 | 69 +++ 12 files changed, 1771 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 configure.ps1 create mode 100644 internal/features/DefaultBrowser.ps1 create mode 100644 internal/features/DynamicLock.ps1 create mode 100644 internal/features/SandwichReminder.ps1 create mode 100644 internal/lib/Config.ps1 create mode 100644 internal/lib/Elevation.ps1 create mode 100644 internal/lib/Logging.ps1 create mode 100644 internal/lib/NetworkUtils.ps1 create mode 100644 internal/lib/ToastHelper.ps1 create mode 100644 internal/runner.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2777cfe --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Logs directory (daily rotating logs + diagnostics) +internal/data/logs/ + +# Runtime state per-feature +internal/data/state/ + +# Result JSON files (auto-created) +internal/data/*.json + +# IDEs +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f60c4c --- /dev/null +++ b/README.md @@ -0,0 +1,288 @@ +# PowerShell Automation Center + +A unified framework for managing Windows automation tasks with a modular, extensible feature system. All configuration and execution flows through a single entry point: `configure.ps1`. + +## Quick Start + +```powershell +# Navigate to the root folder +cd C:\Tools\ArnePowershellAutomation + +# Run the main menu +.\configure.ps1 +``` + +## Architecture + +### Entry Point: `configure.ps1` +The only file you interact with directly. Provides a menu-driven interface for: +- **Check**: View registration status of the background runner task +- **Register**: Create a scheduled task that runs automation features at logon and every 2 minutes +- **Remove**: Unregister the scheduled task +- **Configure**: Adjust per-feature settings (toggles, custom values) + +### Core Structure +``` +ArnePowershellAutomation/ +├── configure.ps1 # Single user entry point +└── internal/ + ├── runner.ps1 # Scheduled task entry point (runs every 2 min + at logon) + ├── lib/ # Shared utilities + │ ├── Logging.ps1 # Centralized logging with rotation + │ ├── Config.ps1 # Configuration and state I/O + │ ├── Elevation.ps1 # UAC elevation helper + │ ├── NetworkUtils.ps1 # DNS suffix detection + │ └── ToastHelper.ps1 # Windows 11 toast notifications + ├── features/ # Automation feature modules + │ ├── DynamicLock.ps1 # Toggle Dynamic Lock on network presence + │ ├── DefaultBrowser.ps1 # Monitor default browser setting + │ └── SandwichReminder.ps1 # Time-based reminder notifications + └── data/ + ├── config.json # Feature toggles & settings (auto-seeded) + ├── state/ + │ └── state.json # Per-feature runtime state + └── logs/ + ├── automation-*.log # Daily rotating logs (7-day retention) + ├── elevated-process.log # Elevation diagnostics + ├── last-register-result.json # Last registration result + └── last-remove-result.json # Last removal result +``` + +### Execution Flow + +**At Logon / Every 2 Minutes:** +1. Scheduled task launches `internal/runner.ps1` +2. Runner loads all lib utilities and feature modules +3. For each enabled feature: + - Reads configuration from `internal/data/config.json` + - Reads previous state from `internal/data/state/state.json` + - Calls `Invoke-Feature` function + - Captures errors; one feature failure doesn't break others + - Saves updated state back to file +4. Logs all activity to `internal/data/logs/automation-YYYY-MM-DD.log` + +**For Configuration Changes:** +1. User selects menu option 4 (Configure) +2. Navigate feature menu (numbers to toggle, C for sub-menu) +3. For each setting, enter value or accept default +4. Config is saved to `internal/data/config.json` immediately +5. Changes take effect on next scheduled task run + +## Feature Contract + +Each feature in `internal/features/` must export: + +### `$FeatureMeta` Variable +```powershell +$FeatureMeta = @{ + Name = "My Feature" + Description = "What this feature does" + Settings = @( + @{ Key = "settingKey1"; Label = "Display Label"; Type = "string"; Default = "value"; Description = "Help text" } + @{ Key = "settingKey2"; Label = "Port"; Type = "int"; Default = 8080; Description = "Port number" } + @{ Key = "settingKey3"; Label = "Enabled"; Type = "bool"; Default = $true; Description = "Enable this?" } + ) +} +``` + +### `Invoke-Feature` Function +```powershell +function Invoke-Feature { + param( + [Parameter(Mandatory)][hashtable]$Config, # Feature settings from config.json + [Parameter(Mandatory)][hashtable]$State # Feature state from state.json + ) + + # Perform work here + # Use Write-Log -Feature "MyFeature" for logging + + # Return updated $State hashtable + return $State +} +``` + +### Built-in Utilities Available to Features +- **Logging.ps1**: `Write-Log -Level (Info|Warn|Error) -Message -Feature` +- **Config.ps1**: `Get-Config`, `Save-Config`, `Get-State`, `Save-State` +- **NetworkUtils.ps1**: `Test-DnsSuffixConnected -Suffix` +- **ToastHelper.ps1**: `Show-ToastNotification -Title -Body -Buttons` +- **Elevation.ps1**: `Test-Administrator`, `Request-Elevation` + +## Included Features + +### DynamicLock +Enables/disables Windows Dynamic Lock based on network connectivity. +- **Settings**: Network domain suffix (e.g., "example.com"), revert timeout in minutes +- **Logic**: When connected to configured network, enables Dynamic Lock (`EnableGoodbye`=1); when disconnected, reverts after timeout +- **State Tracked**: Current connection status, last connected timestamp + +### DefaultBrowser +Monitors if your default browser matches expected ProgId (app identifier). +- **Settings**: Target ProgId (e.g., "FirefoxURL", "OperaGXStable") +- **Logic**: On each run, reads current ProgId; if mismatch, shows toast with link to settings; max once per day +- **State Tracked**: Last notification date to avoid spam + +### SandwichReminder +Shows a toast reminder at a specific time on a specific network. +- **Settings**: Reminder time (HH:MM), network domain suffix, reminder URL, notification window (±5 minutes) +- **Logic**: If on configured network AND current time is within ±5 min of reminder time AND not shown today, display toast with link +- **State Tracked**: Last notification date + +## Usage Guide + +### Main Menu +``` +1) Check Registration Status + → Displays current task registration, last run time, next run time + +2) Register Runner + → Creates scheduled task (AtLogon trigger + 2-min repetition) + → If not admin, prompts for UAC elevation + → Updates internal/data/logs/last-register-result.json + +3) Remove Runner + → Unregisters the scheduled task + → If not admin, prompts for UAC elevation + → Updates internal/data/logs/last-remove-result.json + +4) Configure Features + → Submenu with feature toggles and per-feature settings + → Enter number to toggle (on/off) + → Enter C to configure settings + → Q to return to main menu + → Saves config.json immediately + +Q) Quit + → Exits configure.ps1 +``` + +### Configuring a Feature +When you select a feature for configuration, you'll be prompted for each setting: +- **Type: string** → Enter any text +- **Type: int** → Enter a number +- **Type: bool** → Enter Y/N (yes/no) +- **Type: time** → Enter HH:MM format +- Press Enter to accept the displayed default + +## Testing Plan + +### T1 — Basic Logging & Config Load +- [ ] Run `.\configure.ps1` → Main menu displays +- [ ] Verify `internal/data/config.json` was created (if first run) +- [ ] Verify `internal/data/logs/automation-.log` has initialization entries + +### T2 — Feature Discovery & Configuration Menu +- [ ] Select menu option 4 (Configure) +- [ ] Verify all three features appear (DynamicLock, DefaultBrowser, SandwichReminder) +- [ ] Toggle DynamicLock (1) → Verify enabled flag flips in config.json +- [ ] Select C1 (configure DynamicLock) → Modify network suffix, save, verify in config.json + +### T3 — Registration & Task Scheduler +- [ ] Select menu option 2 (Register) +- [ ] If prompted for UAC, approve elevation +- [ ] Verify success message and `last-register-result.json` shows success=true +- [ ] Open Task Scheduler → Navigate to Library root → Verify "PSAutomation-Runner" task exists +- [ ] Right-click task → Properties → Verify "Run as current user" (not elevated), AtLogOn trigger, 2-min repetition + +### T4 — Feature Execution (DynamicLock) +- [ ] Register the runner (option 2) +- [ ] Enable DynamicLock in configuration menu (option 4) +- [ ] Configure DynamicLock network to current network suffix (use `Get-DnsClient` or `ipconfig /all` to find) +- [ ] Wait for runner to execute (at next 2-min interval or logon event) +- [ ] Check `internal/data/logs/automation-.log` for "DynamicLock" entries +- [ ] Verify state.json has updated `isConnected` flag + +### T5 — Feature Execution (DefaultBrowser) +- [ ] Enable DefaultBrowser in configuration menu +- [ ] Verify toast notification appears (if configured ProgId differs from current default) +- [ ] Click "Yes" button on toast → Should open Settings → Default apps +- [ ] Check logs for "DefaultBrowser" entries and state.json for `lastShownDate` + +### T6 — Feature Execution (SandwichReminder) +- [ ] Enable SandwichReminder in configuration menu +- [ ] Configure reminderTime to a time 1–2 minutes in the future (e.g., if current time is 14:35, set to 14:37) +- [ ] Configure to current network suffix +- [ ] Wait for runner to execute +- [ ] Verify toast appears with configured URL +- [ ] Check logs for "SandwichReminder" entries and state.json for `lastShownDate` + +### T7 — Error Isolation +- [ ] Intentionally break one feature (e.g., edit `internal/features/DynamicLock.ps1`, add invalid syntax) +- [ ] Wait for runner to execute +- [ ] Verify broken feature logged an error in automation-.log +- [ ] Verify other features still executed successfully (logged their own entries) + +## Known Limitations + +- **Default Browser Registry Protection**: On Windows 11, Microsoft protects the default browser registry key with a hash. The DefaultBrowser feature reads the ProgId but cannot *set* the default browser programmatically. The toast provides a link to Settings for manual override. +- **Windows 11 Only for Toasts**: Toast notifications require WinRT (Windows 10+), but full Windows 11 support is assumed. Win10 may work but is untested. +- **Scheduled Task Security**: The runner task executes as the current user with Limited RunLevel (no elevation). Features cannot perform system-wide administrative tasks; they're limited to user-level operations. +- **DNS Suffix Matching**: Network detection relies on `Get-DnsClient` adapter DNS suffix. Make sure your network adapter is configured with the correct suffix for accurate detection. + +## Extending with New Features + +To add a new automation feature: + +1. Create `internal/features/MyFeature.ps1`: + ```powershell + #Requires -Version 5.1 + param() # Empty; loaded via dot-source + + $FeatureMeta = @{ + Name = "My Feature" + Description = "Does something cool" + Settings = @( + @{ Key = "enabled"; Label = "Enable"; Type = "bool"; Default = $true; Description = "Turn on/off" } + ) + } + + function Invoke-Feature { + param([hashtable]$Config, [hashtable]$State) + + if (-not $Config.enabled) { return $State } + + try { + Write-Log -Level Info -Message "Running My Feature" -Feature "MyFeature" + # Do work here + } + catch { + Write-Log -Level Error -Message "Error: $_" -Feature "MyFeature" + } + + return $State + } + ``` + +2. Drop it in `internal/features/` — it will auto-discover on next configure.ps1 run + +3. Run `.\configure.ps1` → menu option 4 → Your feature appears with toggles and config sub-menu + +## Logging + +All activity is logged to `internal/data/logs/automation-YYYY-MM-DD.log` (UTC date). +- **Retention**: 7 days (older logs auto-deleted) +- **Format**: `[YYYY-MM-DD HH:mm:ss] [LEVEL] [Feature] Message` +- **Levels**: Info, Warn, Error + +Elevation diagnostics go to `internal/data/logs/elevated-process.log` for troubleshooting registration issues. + +## Troubleshooting + +**Task not running?** +- Check Task Scheduler (root library) for "PSAutomation-Runner" task +- Verify "Run as current user" and Limited RunLevel +- Check `automation-.log` for errors + +**Features not executing?** +- Verify feature is enabled in menu option 4 +- Check `internal/data/config.json` for `"enabled": true` on that feature +- Look for errors in `automation-.log` with feature name + +**Elevation prompts failing?** +- Check `elevated-process.log` for bootstrap diagnostics +- Ensure PowerShell isn't blocked by execution policy: `Get-ExecutionPolicy` +- If needed, set: `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` + +## License + +None. Use freely. diff --git a/configure.ps1 b/configure.ps1 new file mode 100644 index 0000000..97140fe --- /dev/null +++ b/configure.ps1 @@ -0,0 +1,683 @@ +#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 = '\' +$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-Features { + <# + .SYNOPSIS + Dot-sources every .ps1 in internal\features\ and collects their $FeatureMeta. + Returns an ordered list of [PSCustomObject]@{ File; Meta }. + #> + $featuresDir = Join-Path $script:InternalRoot 'features' + $featureFiles = Get-ChildItem -Path $featuresDir -Filter '*.ps1' -ErrorAction SilentlyContinue | + Sort-Object Name + + $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 +} + +# ═════════════════════════════════════════════════════════════════════════════ +# 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' + 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' + + # 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 + } + + $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 'powershell.exe' ` + -Argument "-WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$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)'." + } + + Write-Host " Task '$($script:TaskName)' registered 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-.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 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 — 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' + } + } + } +} + +# ═════════════════════════════════════════════════════════════════════════════ +# Main menu +# ═════════════════════════════════════════════════════════════════════════════ + +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 '' + 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 } + { $_ -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 + } +} + +Show-MainMenu + diff --git a/internal/features/DefaultBrowser.ps1 b/internal/features/DefaultBrowser.ps1 new file mode 100644 index 0000000..c6fa929 --- /dev/null +++ b/internal/features/DefaultBrowser.ps1 @@ -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 +} + diff --git a/internal/features/DynamicLock.ps1 b/internal/features/DynamicLock.ps1 new file mode 100644 index 0000000..305092b --- /dev/null +++ b/internal/features/DynamicLock.ps1 @@ -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 +} + diff --git a/internal/features/SandwichReminder.ps1 b/internal/features/SandwichReminder.ps1 new file mode 100644 index 0000000..37c0432 --- /dev/null +++ b/internal/features/SandwichReminder.ps1 @@ -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 +} + diff --git a/internal/lib/Config.ps1 b/internal/lib/Config.ps1 new file mode 100644 index 0000000..6084483 --- /dev/null +++ b/internal/lib/Config.ps1 @@ -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 +} + diff --git a/internal/lib/Elevation.ps1 b/internal/lib/Elevation.ps1 new file mode 100644 index 0000000..54279fe --- /dev/null +++ b/internal/lib/Elevation.ps1 @@ -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 = $_ + } + } +} + diff --git a/internal/lib/Logging.ps1 b/internal/lib/Logging.ps1 new file mode 100644 index 0000000..0013396 --- /dev/null +++ b/internal/lib/Logging.ps1 @@ -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 +} + diff --git a/internal/lib/NetworkUtils.ps1 b/internal/lib/NetworkUtils.ps1 new file mode 100644 index 0000000..1b6d95e --- /dev/null +++ b/internal/lib/NetworkUtils.ps1 @@ -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 + } +} + diff --git a/internal/lib/ToastHelper.ps1 b/internal/lib/ToastHelper.ps1 new file mode 100644 index 0000000..6f491ff --- /dev/null +++ b/internal/lib/ToastHelper.ps1 @@ -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 = '' + 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 += "" + } else { + $action = [System.Security.SecurityElement]::Escape($btn.Action) + $activationType = 'protocol' # covers http://, https://, ms-settings:, etc. + $actionsXml += "" + } + } + $actionsXml += '' + } + + $escapedTitle = [System.Security.SecurityElement]::Escape($Title) + $escapedBody = [System.Security.SecurityElement]::Escape($Body) + + $toastXml = @" + + + + $escapedTitle + $escapedBody + + + $actionsXml + +"@ + $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' + } +} + diff --git a/internal/runner.ps1 b/internal/runner.ps1 new file mode 100644 index 0000000..1f41211 --- /dev/null +++ b/internal/runner.ps1 @@ -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' +