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:
+16
@@ -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
|
||||
@@ -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<number> 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<number> 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-<date>.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-<date>.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-<date>.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-<date>.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-<date>.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.
|
||||
+683
@@ -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-<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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = $_
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user