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:
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user