Lumos.psm1

Function Invoke-Lumos {
    <#
        .SYNOPSIS
            Sets the Windows or Mac Theme to light or dark mode.
 
        .DESCRIPTION
            Use this cmdlet to change the theme on Windows 10/11 or macOS to the light or dark theme,
            either as specified by parameters, automatically based on your location and whether it is
            currently before or after sunrise/sunset (-Auto), or (if none of those are specified) by
            toggling to whichever theme isn't currently active.
 
        .PARAMETER Dark
            Switch to the Dark OS theme.
 
        .PARAMETER Light
            Switch to the Light OS theme.
 
        .PARAMETER Auto
            Switch to the Dark or Light OS theme automatically, based on your current location (determined
            via your public IP address) and whether it is currently before or after sunrise/sunset there.
 
        .PARAMETER ExcludeSystem
            Exclude changing the System theme when switching to Dark/Light (Windows only).
 
        .PARAMETER RestartExplorer
            Restart Explorer to apply the System theme change (Windows only), instead of the default of
            broadcasting a WM_SETTINGCHANGE/WM_THEMECHANGED message. Use this if the taskbar still doesn't
            update without it on your system, or if you want any File Explorer windows you already had open
            to pick up the change too - the broadcast alone reliably updates the taskbar, but not existing
            Explorer windows' own chrome.
 
        .PARAMETER IncludeOfficeProPlus
            Include changing the theme of Microsoft Office to Dark/Light (Windows only).
 
        .PARAMETER ExcludeApps
            Exclude changing the Applications (where supported) theme when switching to Dark/Light (Windows only).
 
        .PARAMETER DarkWallpaper
            Specify a path to use to modify the Desktop Wallpaper to when switching to the Dark theme.
 
        .PARAMETER LightWallpaper
            Specify a path to use to modify the Desktop Wallpaper to when switching to the Light theme.
 
        .EXAMPLE
            Invoke-Lumos -Dark -DarkWallpaper ./dark-wallpaper.png
 
            Switches the OS theme to the Dark theme and specified Wallpaper.
 
        .EXAMPLE
            Invoke-Lumos -Light -LightWallpaper ./light-wallpaper.png
 
            Swithches the OS theme to the Light theme and specified Wallpaper.
 
        .EXAMPLE
            Invoke-Lumos -Dark -ExcludeApps
 
            Switches the OS theme to Dark, but (on Windows only) does not change the theme of apps that support
            Dark/Light theme.
 
        .EXAMPLE
            Invoke-Lumos -Dark -RestartExplorer
 
            Switches the OS theme to Dark and restarts Explorer (Windows only) to apply the change to the
            taskbar, instead of the default of broadcasting a WM_SETTINGCHANGE message.
 
        .Example
            Invoke-Lumos -Auto
 
            Switches to either the Dark or Light theme, dependent on your current location and time of day.
 
        .Example
            Invoke-Lumos
 
            Switches the current theme to its alternate, i.e. if it's Light it will switch to Dark and if
            Dark switch to Light.
    #>

    [cmdletbinding(DefaultParameterSetName = 'Dark')]
    Param(
        [Parameter(ParameterSetName = 'Dark')]
        [switch]
        $Dark,

        [Parameter(ParameterSetName = 'Light')]
        [switch]
        $Light,

        [Parameter(ParameterSetName = 'Auto')]
        [switch]
        $Auto,

        [switch]
        $ExcludeSystem,

        [switch]
        $RestartExplorer,

        [switch]
        $IncludeOfficeProPlus,

        [switch]
        $ExcludeApps,

        [string]
        $DarkWallpaper,

        [string]
        $LightWallpaper
    )

    if ($Dark) {
        $Lumos = 0
    }
    elseif ($Light) {
        $Lumos = 1
    }
    elseif ($Auto) {
        $CurrentTime = Get-Date
        $UserLocation = Get-UserLocation

        if ($UserLocation) {
            $DayLight = Get-LocalDaylight -Latitude $UserLocation.Latitude -Longitude $UserLocation.Longitude
        }
        else {
            Throw 'Could not get sunrise/sunset data for the current user.'
        }

        if ($CurrentTime -ge $DayLight.Sunrise -and $CurrentTime -lt $DayLight.Sunset) {
            $Lumos = 1
        }
        else {
            $Lumos = 0
        }
    }
    else {
        # Leaving Lumos as undefined will make it just alternate to whatever mode it currently is not
        $Lumos = 'Undefined'
    }

    Switch ($Lumos) {
        0 {
            $Status = 'Dark'
            if ($DarkWallpaper) { $Wallpaper = $DarkWallpaper }
        }
        1 {
            $Status = 'Light'
            if ($LightWallpaper) { $Wallpaper = $LightWallpaper }
        }
        default {
            $Status = 'Undefined'
        }
    }

    if ($IsMacOS) {
        ### MacOS ###
        if ($PSVersionTable.PSVersion -lt [Version]'7.3') {
            Throw 'Lumos requires PowerShell 7.3 or later on MacOS, since older versions pass the AppleScript commands used to change the theme/wallpaper through incorrectly.'
        }

        $MacCommand = if ($Lumos -eq 0) {
            'tell application "System Events" to tell appearance preferences to set dark mode to true'
        }
        elseif ($Lumos -eq 1) {
            'tell application "System Events" to tell appearance preferences to set dark mode to false'
        }
        else {
            'tell application "System Events" to tell appearance preferences to set dark mode to not dark mode'
        }

        Invoke-AppleScript -Command $MacCommand

        if ($ExcludeSystem) {
            Write-Error '-ExcludeSystem is not currently supported on MacOS.'
        }

        if ($ExcludeApps) {
            Write-Error '-ExcludeApps is not currently supported on MacOS.'
        }

        if ($IncludeOfficeProPlus) {
            Write-Error '-OfficeProPlus is not currently supported on MacOS.'
        }

        if ($Wallpaper) {
            $MacCommand = "tell application `"System Events`" to tell current desktop to set picture to `"$Wallpaper`""
            Invoke-AppleScript -Command $MacCommand
        }
    }
    elseif ($IsLinux) {
        ### Linux ###
        Throw 'Linux is not currently supported by this module.'
    }
    else {
        ### Windows ###
        $ThemeRegKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize'
        $OfficeThemeRegKey = 'HKCU:\Software\Microsoft\Office\16.0\Common'

        $CurrentSystemTheme = (Get-ItemProperty -Path $ThemeRegKey -Name 'SystemUsesLightTheme' -ErrorAction SilentlyContinue).SystemUsesLightTheme

        if ($Lumos -eq 'Undefined') {
            # No -Dark/-Light/-Auto specified: toggle to whichever theme isn't currently active, mirroring
            # the behaviour of the MacOS "not dark mode" AppleScript command further down.
            $Lumos = [int](-not [bool]$CurrentSystemTheme)
            $Status = if ($Lumos -eq 1) { 'Light' } else { 'Dark' }
        }

        if (-not $ExcludeSystem) {
            if ($CurrentSystemTheme -ne $Lumos) {
                Write-Verbose "Setting System to $Status Theme.."
                Set-ItemProperty -Path $ThemeRegKey -Name 'SystemUsesLightTheme' -Value $Lumos

                # The taskbar (and Start, Action Center) follow SystemUsesLightTheme, not AppsUseLightTheme, and
                # need to be told the setting changed before they'll repaint. Only doing this when the theme
                # actually changed avoids the disruption of restarting Explorer (or even the lighter-weight
                # broadcast below) on every run of a frequently scheduled task.
                if ($RestartExplorer) {
                    Write-Verbose 'Restarting Explorer to apply the theme change to the taskbar..'
                    Stop-Process -ProcessName explorer
                }
                else {
                    Write-Verbose 'Broadcasting a WM_SETTINGCHANGE message to apply the theme change to the taskbar..'
                    Send-SettingChangeMessage
                }
            }
        }
        if (-not $ExcludeApps) {
            $CurrentAppsTheme = (Get-ItemProperty -Path $ThemeRegKey -Name 'AppsUseLightTheme' -ErrorAction SilentlyContinue).AppsUseLightTheme

            if ($CurrentAppsTheme -ne $Lumos) {
                Write-Verbose "Setting Apps to $Status Theme.."
                Set-ItemProperty -Path $ThemeRegKey -Name 'AppsUseLightTheme' -Value $Lumos
            }
        }

        if ($IncludeOfficeProPlus) {
            $proPlusThemeValue = if ($Lumos -eq 0) {
                4
            } else {
                if (Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\O365ProPlusRetail*") {
                    5
                } else {
                    0
                }
            }

            Write-Verbose "Setting OfficeProPlus to $Status with value: $proPlusThemeValue .."

            Set-ItemProperty -Path $OfficeThemeRegKey -Name 'UI Theme' -Value $proPlusThemeValue -Type DWORD

            $IdentitiesRegKey = $OfficeThemeRegKey + "\Roaming\Identities\"

            if (Test-Path $IdentitiesRegKey) {
                Get-ChildItem -Path $IdentitiesRegKey | ForEach-Object {
                    $identityPath = ($_.Name.Replace('HKEY_CURRENT_USER', 'HKCU:') + "\Settings\1186\{00000000-0000-0000-0000-000000000000}");

                    if (Get-ItemProperty -Path $identityPath -Name 'Data' -ErrorAction Ignore) {
                        Write-Verbose "Active identity path for ProPlus installation: $identityPath"

                        Set-ItemProperty -Path $identityPath -Name 'Data' -Value ([byte[]]($proPlusThemeValue, 0, 0, 0)) -Type Binary
                    }
                    Break
                }
            }
        }

        if ($Wallpaper) {
            Set-Wallpaper $Wallpaper
        }
    }
}
Function Register-LumosScheduledTask {
    <#
        .SYNOPSIS
            Registers Scheduled Tasks to run Lumos automatically on Windows.
 
         .DESCRIPTION
            Use this cmdlet to register a "Lumos" scheduled task on Windows that runs Invoke-Lumos (with your
            specified parameters) twice daily, at the current sunrise and sunset for your location.
 
            Since sunrise/sunset drift through the year, this also registers a second "Lumos-Maintenance" task
            that runs once weekly, at solar noon (the point furthest from both sunrise and sunset) on whichever
            day of the week this cmdlet was run, and whose only job is to recompute the current sunrise/sunset
            and update the "Lumos" task's trigger times to match via Update-LumosScheduledTask - weekly is
            frequent enough to keep the triggers close to sunrise/sunset without needing daily API calls to
            determine location and daylight times. Keeping this in a separate task - rather than as a second
            action of the "Lumos" task itself, as an earlier version of this module did - matters: Windows
            Task Scheduler won't let a task modify its own definition while it's the active running instance,
            which is what made that earlier approach throw "Access is denied" and lose its triggers entirely.
            Running the update from a genuinely different task, at a time unlikely to overlap with "Lumos"
            actually running, avoids that.
 
            If you'd rather not rely on the automatic sunrise/sunset lookup (or don't want Lumos calling out to
            it at all), specify -Sunrise and -Sunset yourself to use fixed daily trigger times instead, or use
            -FromNightLight to reuse whichever schedule Windows' own Night Light feature is currently
            configured with. Since neither of those need to be kept current with the season the way an
            automatic lookup does, the "Lumos-Maintenance" task isn't registered in either case - re-run this
            cmdlet if the Night Light schedule you're reusing later changes. If a "Lumos-Maintenance" task was
            already registered from a previous run (e.g. you're switching from the automatic lookup to a fixed
            schedule), it's removed, since it would otherwise keep overwriting your fixed times weekly.
 
            Both tasks run as the current user at standard (non-elevated) privilege - Lumos only ever changes
            current-user settings, so no administrator rights are required. Neither task has an "at logon"
            trigger, since some endpoint security software blocks non-admin users from registering one (likely
            because it's a common persistence technique).
 
            Returns the registered scheduled task(s), displayed with their schedule source and a summary of
            the times that were registered (e.g. to verify -FromNightLight or the automatic sunrise/sunset
            lookup picked up what you expected) - the full underlying task, including its Triggers, is still
            there to inspect if you need more detail.
 
            If PowerShell 7 is installed from the Microsoft Store, its exact path changes with every update
            (it lives in a version-specific folder under WindowsApps), which would otherwise leave the
            registered task pointing at a pwsh.exe that no longer exists after the next update. In that case
            this instead points the task at Windows' own stable app-execution-alias for pwsh.exe, which
            Windows keeps up to date across Store updates - so re-running this cmdlet after updating
            PowerShell shouldn't be necessary. This doesn't apply to Windows PowerShell or a traditionally
            installed PowerShell 7, both of which already have a stable path.
 
        .PARAMETER Sunrise
            Specify a fixed daily time to switch to the Light theme, instead of automatically looking up the
            current sunrise for your location. Must be specified together with -Sunset. Since this time won't
            need to stay current with the season, the "Lumos-Maintenance" task is not registered.
 
        .PARAMETER Sunset
            Specify a fixed daily time to switch to the Dark theme, instead of automatically looking up the
            current sunset for your location. Must be specified together with -Sunrise. Since this time won't
            need to stay current with the season, the "Lumos-Maintenance" task is not registered.
 
        .PARAMETER FromNightLight
            Use whichever schedule Windows' own Night Light feature (Settings > System > Display > Night light)
            is currently configured with, instead of automatically looking up sunrise/sunset for your location.
            Cannot be combined with -Sunrise/-Sunset. Since this is read once at registration time (not kept in
            sync with Night Light afterwards), the "Lumos-Maintenance" task is not registered - re-run this
            cmdlet if you later change your Night Light schedule.
 
        .PARAMETER ExcludeSystem
            Exclude changing the System theme when switching to Dark/Light (Windows only) when the task runs.
 
        .PARAMETER RestartExplorer
            Restart Explorer to apply the System theme change to the taskbar when the task runs, instead of the
            default of broadcasting a WM_SETTINGCHANGE message.
 
        .PARAMETER IncludeOfficeProPlus
            Include changing the theme of Microsoft Office to Dark/Light (Windows only) when the task runs.
 
        .PARAMETER ExcludeApps
            Exclude changing the Applications (where supported) theme when switching to Dark/Light (Windows only) when the task runs.
 
        .PARAMETER DarkWallpaper
            Specify a path to use to modify the Desktop Wallpaper to when the task runs and switches to the Dark theme.
 
        .PARAMETER LightWallpaper
            Specify a path to use to modify the Desktop Wallpaper to when the task runs and switches to the Light theme.
 
        .EXAMPLE
            Register-LumosScheduledTask -ExcludeApps -DarkWallpaper C:\Temp\dark.png -LightWallpaper C:\Temp\light.png
 
            Creates scheduled tasks that switch just the OS theme to dark or light at the current local sunrise
            and sunset, along with the specified light or dark wallpaper, keeping the trigger times themselves
            up to date with sunrise/sunset as they change through the year.
 
        .EXAMPLE
            Register-LumosScheduledTask -Sunrise '07:00' -Sunset '19:00'
 
            Creates a "Lumos" scheduled task that switches to the Light theme at 07:00 and the Dark theme at
            19:00 every day, without looking up your location or registering a "Lumos-Maintenance" task.
 
        .EXAMPLE
            Register-LumosScheduledTask -FromNightLight
 
            Creates a "Lumos" scheduled task using whichever schedule Windows' own Night Light feature is
            currently configured with, without looking up your location or registering a "Lumos-Maintenance"
            task.
    #>

    [cmdletbinding()]
    [OutputType([Microsoft.Management.Infrastructure.CimInstance])]
    Param(
        [datetime]
        $Sunrise,

        [datetime]
        $Sunset,

        [switch]
        $FromNightLight,

        [switch]
        $ExcludeSystem,

        [switch]
        $RestartExplorer,

        [switch]
        $ExcludeApps,

        [switch]
        $IncludeOfficeProPlus,

        [string]
        $DarkWallpaper,

        [string]
        $LightWallpaper
    )

    if (-not ($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows)) {
        Write-Warning 'Register-LumosScheduledTask is only supported on Windows.'
        return
    }

    if (($Sunrise -and -not $Sunset) -or ($Sunset -and -not $Sunrise)) {
        throw '-Sunrise and -Sunset must both be specified together.'
    }

    if ($FromNightLight -and ($Sunrise -or $Sunset)) {
        throw '-FromNightLight cannot be combined with -Sunrise/-Sunset.'
    }

    # Runs the task using whichever PowerShell edition is currently running this cmdlet, since that's the
    # edition Lumos is guaranteed to be installed under - PS Core and Windows PowerShell have separate
    # module paths, so hardcoding the other edition's executable would fail to find Invoke-Lumos. $PSHOME
    # is the home directory of the CURRENT session, so this resolves to an exact, unambiguous full path
    # rather than relying on whatever "powershell.exe"/"pwsh.exe" happens to resolve to on PATH - Task
    # Scheduler doesn't search PATH for a bare executable name the way an interactively typed command does.
    $PowerShellExe = if ($PSVersionTable.PSEdition -eq 'Core') {
        $PSHomeExe = Join-Path -Path $PSHOME -ChildPath 'pwsh.exe'

        # A Microsoft Store (MSIX) install of PowerShell lives in a folder that bakes in its exact version
        # (...\WindowsApps\Microsoft.PowerShell_7.6.6.0_...) and gets removed on update, unlike a
        # traditional installer's stable "Program Files\PowerShell\7" - so a task registered against that
        # exact $PSHOME path would stop working after the next Store update, since Task Scheduler just
        # launches whatever literal path it was given rather than re-resolving it on each run. Windows
        # keeps a stable app-execution-alias stub for it at this fixed, version-independent path instead -
        # prefer that one, but only in this specific case, so every other install method (including the
        # MSI installer's own stable path) is unaffected.
        $StableAliasExe = Join-Path -Path $env:LOCALAPPDATA -ChildPath 'Microsoft\WindowsApps\pwsh.exe'

        if ($PSHomeExe -like '*\WindowsApps\*' -and (Test-Path -Path $StableAliasExe)) {
            $StableAliasExe
        }
        else {
            $PSHomeExe
        }
    }
    else {
        Join-Path -Path $PSHOME -ChildPath 'powershell.exe'
    }

    # -WindowStyle Hidden keeps the task running silently in the background with no visible console window.
    $ArgumentDefaults = '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden'

    # -NoProfile means the task's fresh PowerShell process has to discover Lumos via module auto-loading,
    # which depends on $env:PSModulePath being set up the same way in that process as it is interactively -
    # not guaranteed for a process spawned by Task Scheduler rather than typed at a prompt. Importing from
    # this exact, already-loaded module's own path sidesteps auto-loading entirely.
    $ModulePath = (Get-Module -Name 'Lumos').Path
    $ImportModuleCommand = "Import-Module '$ModulePath' -Force; "

    # -Auto makes Invoke-Lumos independently decide Dark vs Light based on live location/time each time the
    # task fires, rather than toggling - needed since both the sunrise and sunset triggers below run this
    # exact same action, and toggling would desync from reality if a run is ever missed or run out of order.
    $LumosArgument = "$ArgumentDefaults -Command ${ImportModuleCommand}Invoke-Lumos -Auto"

    If ($ExcludeSystem) {
        $LumosArgument = $LumosArgument + " -ExcludeSystem"
    }
    If ($RestartExplorer) {
        $LumosArgument = $LumosArgument + " -RestartExplorer"
    }
    If ($ExcludeApps) {
        $LumosArgument = $LumosArgument + " -ExcludeApps"
    }
    If ($IncludeOfficeProPlus) {
        $LumosArgument = $LumosArgument + " -IncludeOfficeProPlus"
    }
    If ($LightWallpaper) {
        $LumosArgument = $LumosArgument + " -LightWallpaper '$LightWallpaper'"
    }
    If ($DarkWallpaper) {
        $LumosArgument = $LumosArgument + " -DarkWallpaper '$DarkWallpaper'"
    }

    # A user-specified -Sunrise/-Sunset pair, or -FromNightLight, is used as-is, skipping the location/daylight
    # lookup entirely - since neither of those drift with the season the way an automatic lookup's result
    # does, the "Lumos-Maintenance" task isn't needed either.
    $UseCustomDaylight = ($Sunrise -and $Sunset) -or $FromNightLight

    if ($FromNightLight) {
        $DayLight = Get-NightLightSchedule
        $ScheduleSource = 'Night Light'
    }
    elseif ($UseCustomDaylight) {
        $DayLight = [pscustomobject]@{ Sunrise = $Sunrise; Sunset = $Sunset }
        $ScheduleSource = 'Custom'
    }
    else {
        $UserLocation = Get-UserLocation

        if (-not $UserLocation) {
            throw 'Could not get sunrise/sunset data for the current user.'
        }

        $DayLight = Get-LocalDaylight -Latitude $UserLocation.Latitude -Longitude $UserLocation.Longitude
        $ScheduleSource = 'Location'
    }

    $LumosAction = New-ScheduledTaskAction -Execute $PowerShellExe -Argument $LumosArgument
    $Principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
    $TaskSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable

    $SunriseTrigger = New-ScheduledTaskTrigger -Daily -At $DayLight.Sunrise
    $SunsetTrigger = New-ScheduledTaskTrigger -Daily -At $DayLight.Sunset

    $LumosTask = New-ScheduledTask -Action $LumosAction -Principal $Principal -Settings $TaskSettings -Trigger @($SunriseTrigger, $SunsetTrigger) |
        Register-ScheduledTask -TaskName 'Lumos' -Force

    # Inserting a custom type name (rather than replacing it) lets the Lumos.Format.ps1xml view below add a
    # Source/Schedule summary to how this displays, without losing anything Register-ScheduledTask's own
    # MSFT_ScheduledTask type gives it (e.g. so Unregister-ScheduledTask -InputObject $task still works).
    $LumosTask.PSObject.TypeNames.Insert(0, 'Lumos.ScheduledTask')
    Add-Member -InputObject $LumosTask -NotePropertyName 'ScheduleSource' -NotePropertyValue $ScheduleSource
    Add-Member -InputObject $LumosTask -NotePropertyName 'Schedule' -NotePropertyValue (
        "Light $($DayLight.Sunrise.ToString('HH:mm')), Dark $($DayLight.Sunset.ToString('HH:mm'))"
    )

    if ($UseCustomDaylight) {
        # A fixed schedule doesn't need weekly upkeep - remove any "Lumos-Maintenance" task left over from a
        # previous registration with the automatic lookup, since it would otherwise keep overwriting these
        # fixed trigger times with a freshly looked-up sunrise/sunset every week.
        if (Get-ScheduledTask -TaskName 'Lumos-Maintenance' -ErrorAction SilentlyContinue) {
            Write-Verbose 'Removing existing Lumos-Maintenance task, since it is not needed for a fixed schedule..'
            Unregister-ScheduledTask -TaskName 'Lumos-Maintenance' -Confirm:$false
        }

        return $LumosTask
    }

    $MaintenanceArgument = "$ArgumentDefaults -Command ${ImportModuleCommand}Update-LumosScheduledTask"
    $MaintenanceAction = New-ScheduledTaskAction -Execute $PowerShellExe -Argument $MaintenanceArgument
    $MaintenancePrincipal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
    $MaintenanceSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable

    # Runs weekly (on whichever day this cmdlet happens to be run) at solar noon: the point in the day
    # furthest from both the sunrise and sunset triggers above, to minimize any chance of
    # Update-LumosScheduledTask trying to modify the "Lumos" task while it's running. Weekly is frequent
    # enough to keep the sunrise/sunset triggers reasonably current without a daily API call.
    $SolarNoon = $DayLight.Sunrise.AddSeconds(($DayLight.Sunset - $DayLight.Sunrise).TotalSeconds / 2)
    $MaintenanceTrigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek (Get-Date).DayOfWeek -At $SolarNoon

    $MaintenanceTask = New-ScheduledTask -Action $MaintenanceAction -Principal $MaintenancePrincipal -Settings $MaintenanceSettings -Trigger $MaintenanceTrigger |
        Register-ScheduledTask -TaskName 'Lumos-Maintenance' -Force

    $MaintenanceTask.PSObject.TypeNames.Insert(0, 'Lumos.ScheduledTask')
    Add-Member -InputObject $MaintenanceTask -NotePropertyName 'ScheduleSource' -NotePropertyValue $ScheduleSource
    Add-Member -InputObject $MaintenanceTask -NotePropertyName 'Schedule' -NotePropertyValue (
        "Weekly $((Get-Date).DayOfWeek) $($SolarNoon.ToString('HH:mm'))"
    )

    $LumosTask, $MaintenanceTask
}
Function Update-LumosScheduledTask {
    <#
        .SYNOPSIS
            Updates the "Lumos" scheduled task's sunrise/sunset trigger times to match today.
 
        .DESCRIPTION
            Recomputes the current sunrise/sunset for the local user and updates the "Lumos" scheduled task's
            two daily triggers to match, so they stay aligned with sunrise/sunset as they drift through the
            year. The task's existing action, principal and settings are read back and reused as-is, so only
            the triggers actually change - but rather than modifying the task in place, it's unregistered and
            re-registered with those same values plus the new triggers.
 
            This is intended to be run automatically, once weekly, by the "Lumos-Maintenance" scheduled task
            that Register-LumosScheduledTask creates alongside the "Lumos" task itself - not normally called
            directly. It's registered as a separate task, run at a time unlikely to overlap with "Lumos"
            actually running, because Windows Task Scheduler won't let a task modify its own definition while
            it's the active running instance.
 
        .EXAMPLE
            Update-LumosScheduledTask
 
            Updates the "Lumos" scheduled task's triggers to today's sunrise/sunset for the current location.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    Param()

    if (-not ($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows)) {
        Write-Warning 'Update-LumosScheduledTask is only supported on Windows.'
        return
    }

    $LumosTask = Get-ScheduledTask -TaskName 'Lumos' -ErrorAction SilentlyContinue

    if (-not $LumosTask) {
        Write-Warning "No 'Lumos' scheduled task was found. Run Register-LumosScheduledTask first."
        return
    }

    $UserLocation = Get-UserLocation

    if (-not $UserLocation) {
        Write-Error 'Could not get sunrise/sunset data for the current user.'
        return
    }

    $DayLight = Get-LocalDaylight -Latitude $UserLocation.Latitude -Longitude $UserLocation.Longitude

    $SunriseTrigger = New-ScheduledTaskTrigger -Daily -At $DayLight.Sunrise
    $SunsetTrigger = New-ScheduledTaskTrigger -Daily -At $DayLight.Sunset

    # Rebuilds the task from its own existing Action/Principal/Settings (reused as-is) plus the new
    # triggers, then unregisters and re-registers it, rather than modifying it in place with
    # Set-ScheduledTask - delete-and-recreate uses the same Register-ScheduledTask call already proven to
    # work in Register-LumosScheduledTask, in case Set-ScheduledTask specifically is what's unreliable here.
    $NewTaskDefinition = New-ScheduledTask -Action $LumosTask.Actions -Principal $LumosTask.Principal `
        -Settings $LumosTask.Settings -Trigger @($SunriseTrigger, $SunsetTrigger)

    if ($PSCmdlet.ShouldProcess('Lumos scheduled task', 'Recreate with updated sunrise/sunset triggers')) {
        Unregister-ScheduledTask -TaskName 'Lumos' -Confirm:$false
        Register-ScheduledTask -TaskName 'Lumos' -InputObject $NewTaskDefinition | Out-Null
    }
}
if (-not (Test-Path alias:lumos)) {
    New-Alias -Name 'lumos' -Value 'Invoke-Lumos'
    Export-ModuleMember -Alias 'lumos'
}
Function Get-LocalDaylight {
    <#
        .SYNOPSIS
            Returns the current sunrise and sunset times for the local user in localtime.
 
        .EXAMPLE
            Get-LocalDaylight
 
            Result
            -----------
            Sunrise : 06/08/2019 06:04:57
            Sunset : 06/08/2019 20:22:17
             
    #>
      
    [cmdletbinding()]
    Param(
        [Parameter(Mandatory)]
        [double]
        $Latitude,

        [Parameter(Mandatory)]
        [double]
        $Longitude
    )

    # Return sunrise/sunset
    $Daylight = (Invoke-RestMethod "https://api.sunrise-sunset.org/json?lat=$Latitude&lng=$Longitude").results

    # Convert to local time datetime objects
    [pscustomobject]@{
        Sunrise = ($Daylight.Sunrise | Get-Date).ToLocalTime()
        Sunset  = ($Daylight.Sunset | Get-Date).ToLocalTime()
    }
}
Function Get-NightLightSchedule {
    <#
        .SYNOPSIS
            Returns today's Sunrise/Sunset as configured in Windows' own Night Light schedule.
 
        .DESCRIPTION
            Reads the schedule Windows Night Light is currently configured with (Settings > System > Display >
            Night light) directly from the registry, so Register-LumosScheduledTask can reuse it instead of
            looking up sunrise/sunset for your location independently.
 
            Night Light stores its settings as a Microsoft Bond CompactBinary v1 document, nested inside an
            outer "CloudStore" envelope of the same format, under:
            HKCU:\Software\Microsoft\Windows\CurrentVersion\CloudStore\Store\DefaultAccount\Current\
            default$windows.data.bluelightreduction.settings\windows.data.bluelightreduction.settings ("Data").
            This isn't a documented or supported format - it's reverse-engineered (field IDs/purposes below are
            cross-referenced against https://github.com/kvnxiao/win-nightlight-cli's docs and verified against
            real registry data) and could change in a future Windows release. Rather than parse the outer
            envelope's own (undocumented) schema, the inner document is located by its own magic header
            (0x43 0x42 0x01 0x00, "CB" + version 1), which empirically always reappears a little way into it.
 
            The inner document's fields that matter here:
              0 (bool) schedule_enabled - a schedule is active
              10 (bool) set_hours_mode - PRESENT (any value) when using a fixed "Set hours" schedule
              20 (TimeBlock) schedule_start_time - fixed schedule's dark/Night-Light-on time
              30 (TimeBlock) schedule_end_time - fixed schedule's light/Night-Light-off time
              50 (TimeBlock) sunset_time - "Sunset to sunrise" mode's computed dark/Night-Light-on time
              60 (TimeBlock) sunrise_time - "Sunset to sunrise" mode's computed light/Night-Light-off time
            A TimeBlock is itself a struct of two optional int8 fields (0 = hour, 1 = minute); Bond omits
            fields left at their default value, so an empty TimeBlock means midnight - indistinguishable from
            that field being genuinely unset. Field 10's presence (not its actual bool value) is what indicates
            "Set hours" mode is selected; without it, fields 50/60 are used instead, if a schedule is enabled.
 
        .EXAMPLE
            Get-NightLightSchedule
 
            Result
            -----------
            Sunrise : 06/08/2019 07:00:00
            Sunset : 06/08/2019 21:00:00
    #>

    [CmdletBinding()]
    Param()

    $RegistryPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\CloudStore\Store\DefaultAccount\Current\' +
    'default$windows.data.bluelightreduction.settings\windows.data.bluelightreduction.settings'

    $Bytes = (Get-ItemProperty -Path $RegistryPath -Name 'Data' -ErrorAction SilentlyContinue).Data

    if (-not $Bytes) {
        throw 'Could not read the Windows Night Light schedule from the registry. Make sure Night Light has ' +
        'been configured at least once under Settings > System > Display > Night light.'
    }

    $InnerStart = -1

    for ($i = 4; $i -le $Bytes.Length - 4; $i++) {
        if ($Bytes[$i] -eq 0x43 -and $Bytes[$i + 1] -eq 0x42 -and $Bytes[$i + 2] -eq 0x01 -and $Bytes[$i + 3] -eq 0x00) {
            $InnerStart = $i + 4
            break
        }
    }

    if ($InnerStart -lt 0) {
        throw 'Could not parse the Windows Night Light schedule - the registry data was not in the expected format.'
    }

    $Position = $InnerStart
    $ScheduleEnabled = $false
    $SetHoursMode = $false
    $Times = @{}

    while ($Position -lt $Bytes.Length) {
        $Header = $Bytes[$Position]
        $Position++

        # Low 5 bits are the Bond wire type; high 3 bits are either the field ID directly (0-5), or a
        # sentinel saying the ID follows as its own byte (6) or 16-bit value (7).
        $Type = $Header -band 0x1F
        $IdBits = ($Header -shr 5) -band 0x07

        if ($Type -eq 0) { break } # BT_STOP: end of this struct
        if ($Type -eq 1) { continue } # BT_STOP_BASE: end of base class fields, more fields follow

        # Cast to [int] in every branch - $Times.ContainsKey(20) below relies on the key's boxed type matching
        # an int literal, which a bare [byte] (from $IdBits or $Bytes[$Position]) would silently fail to match.
        $FieldId = if ($IdBits -le 5) {
            [int]$IdBits
        }
        elseif ($IdBits -eq 6) {
            $Id = [int]$Bytes[$Position]; $Position++; $Id
        }
        else {
            $Id = [int][BitConverter]::ToUInt16($Bytes, $Position); $Position += 2; $Id
        }

        switch ($Type) {
            2 {
                # BT_BOOL
                $Value = $Bytes[$Position] -ne 0
                $Position++

                if ($FieldId -eq 0) { $ScheduleEnabled = $Value }
                elseif ($FieldId -eq 10) { $SetHoursMode = $true }
            }
            { $_ -in 3, 14 } {
                # BT_UINT8 / BT_INT8 - 1 byte
                $Position++
            }
            { $_ -in 4, 5, 6, 15, 16, 17 } {
                # BT_UINT16/32/64, BT_INT16/32/64 - varint, e.g. the color_temperature field
                do { $VarIntByte = $Bytes[$Position]; $Position++ } while ($VarIntByte -band 0x80)
            }
            10 {
                # BT_STRUCT
                if ($FieldId -in 20, 30, 50, 60) {
                    # A TimeBlock: hour (field 0) and minute (field 1), each an optional BT_INT8.
                    $Hour = 0
                    $Minute = 0

                    while ($true) {
                        $SubHeader = $Bytes[$Position]; $Position++
                        $SubType = $SubHeader -band 0x1F
                        $SubIdBits = ($SubHeader -shr 5) -band 0x07

                        if ($SubType -eq 0) { break }
                        if ($SubType -eq 1) { continue }
                        if ($SubType -ne 14) {
                            throw "Could not parse the Windows Night Light schedule - unexpected field type ($SubType) in a TimeBlock."
                        }

                        $SubId = if ($SubIdBits -le 5) { $SubIdBits } else { $Id = $Bytes[$Position]; $Position++; $Id }
                        $Value = $Bytes[$Position]; $Position++

                        if ($SubId -eq 0) { $Hour = $Value }
                        elseif ($SubId -eq 1) { $Minute = $Value }
                    }

                    $Times[$FieldId] = [pscustomobject]@{ Hour = $Hour; Minute = $Minute }
                }
                else {
                    # An unrecognized struct field - skip it, as long as it's flat (no further nested structs).
                    while ($true) {
                        $SubHeader = $Bytes[$Position]; $Position++
                        $SubType = $SubHeader -band 0x1F

                        if ($SubType -eq 0) { break }
                        if ($SubType -eq 1) { continue }
                        throw "Could not parse the Windows Night Light schedule - encountered an unsupported nested field ($SubType)."
                    }
                }
            }
            default {
                throw "Could not parse the Windows Night Light schedule - encountered an unsupported field type ($Type)."
            }
        }
    }

    if ($SetHoursMode -and $Times.ContainsKey(20) -and $Times.ContainsKey(30)) {
        $SunsetTime = $Times[20]
        $SunriseTime = $Times[30]
    }
    elseif ($ScheduleEnabled -and $Times.ContainsKey(50) -and $Times.ContainsKey(60) -and
        -not ($Times[50].Hour -eq 0 -and $Times[50].Minute -eq 0 -and $Times[60].Hour -eq 0 -and $Times[60].Minute -eq 0)) {
        $SunsetTime = $Times[50]
        $SunriseTime = $Times[60]
    }
    else {
        throw 'Windows Night Light does not currently have a schedule configured. Enable a schedule under ' +
        'Settings > System > Display > Night light, or use -Sunrise/-Sunset instead.'
    }

    $Today = Get-Date

    [pscustomobject]@{
        Sunrise = Get-Date -Year $Today.Year -Month $Today.Month -Day $Today.Day -Hour $SunriseTime.Hour -Minute $SunriseTime.Minute -Second 0
        Sunset  = Get-Date -Year $Today.Year -Month $Today.Month -Day $Today.Day -Hour $SunsetTime.Hour -Minute $SunsetTime.Minute -Second 0
    }
}
Function Get-UserLocation {
    <#
        .SYNOPSIS
            Returns the approximate location of the local user, based on their public IP address.
 
        .DESCRIPTION
            Looks up the city-level location of the current public IP address via the ipinfo.io API. This is used
            instead of the Windows Location Service because that requires location permission to be granted
            interactively and is typically unavailable when this module is run from a Scheduled Task.
 
        .EXAMPLE
            Get-UserLocation
 
            Result
            -----------
            Latitude : 51.5074
            Longitude : -0.1278
    #>

    [cmdletbinding()]
    Param()

    $IPInfo = Invoke-RestMethod -Uri 'https://ipinfo.io/json'

    if ($IPInfo.loc) {
        $Latitude, $Longitude = $IPInfo.loc -split ','

        [pscustomobject]@{
            Latitude  = [double]$Latitude
            Longitude = [double]$Longitude
        }
    }
}
Function Invoke-AppleScript {
    <#
        .SYNOPSIS
            Executes a command string via Apple Script.
    #>

    [cmdletbinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [String]
        $Command
    )
    Begin {
    }
    Process {
        If ($PSCmdlet.ShouldProcess('/usr/bin/osascript -e',$Command)){
            /usr/bin/osascript -e $Command
        }
    }
}
Function Send-SettingChangeMessage {
    <#
        .SYNOPSIS
            Broadcasts the messages Windows itself sends when a theme setting changes, so running apps and
            the shell pick up the change immediately.
 
        .DESCRIPTION
            Sends the same WM_SETTINGCHANGE("ImmersiveColorSet") + WM_THEMECHANGED broadcast pair Windows'
            own Settings app sends when you change theme there - the taskbar, Start and Action Center all
            pick this up live, without restarting Explorer.
 
            This does NOT reliably repaint the chrome of an already-open File Explorer window on Windows 11
            - confirmed by testing every documented/undocumented trick short of reapplying a full .theme
            file through the private IThemeManager2 COM interface (SendMessageTimeout broadcasts,
            Shell.Application's Windows().Refresh(), and a targeted WM_COMMAND to the window). This appears
            to be a genuine Windows 11 limitation rather than something missing here - Microsoft's own
            PowerToys "Light Switch" module does the exact same two-message broadcast and has the same
            unresolved gap (see https://github.com/microsoft/PowerToys/issues/42463). -RestartExplorer on
            Invoke-Lumos/Register-LumosScheduledTask is the only reliable fix for that specific symptom.
 
        .EXAMPLE
            Send-SettingChangeMessage
    #>

    [CmdletBinding()]
    Param()

    # Guarded so a second call within the same session (e.g. toggling the theme more than once interactively)
    # doesn't hit Add-Type's "type already exists" error.
    if (-not ('Win32SettingChange' -as [type])) {
        Add-Type -TypeDefinition @"
        using System;
        using System.Runtime.InteropServices;
 
        public class Win32SettingChange
        {
            [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]
            public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);
        }
"@

    }

    $HWND_BROADCAST = [IntPtr]0xffff
    $WM_SETTINGCHANGE = 0x1A
    $WM_THEMECHANGED = 0x031A
    $SMTO_ABORTIFHUNG = 0x0002
    $Result = [UIntPtr]::Zero

    [void][Win32SettingChange]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, 'ImmersiveColorSet', $SMTO_ABORTIFHUNG, 5000, [ref]$Result)

    # WM_THEMECHANGED takes no meaningful wParam/lParam - $null marshals to a null pointer here, matching
    # the message's documented NULL/NULL contract.
    [void][Win32SettingChange]::SendMessageTimeout($HWND_BROADCAST, $WM_THEMECHANGED, [UIntPtr]::Zero, $null, $SMTO_ABORTIFHUNG, 5000, [ref]$Result)
}
Function Set-Wallpaper {
    <#
        .SYNOPSIS
            Applies a specified wallpaper to the current user's desktop
         
        .PARAMETER Image
            Provide the full path to the image
         
        .EXAMPLE
            Set-WallPaper -Image "C:\Wallpaper\Default.jpg"
    #>

    [cmdletbinding(SupportsShouldProcess)]
    Param(
        [string]
        $Image
    )
     
    Add-Type -TypeDefinition @"
    using System;
    using System.Runtime.InteropServices;
      
    public class Params
    {
        [DllImport("User32.dll",CharSet=CharSet.Unicode)]
        public static extern int SystemParametersInfo (Int32 uAction,
                                                       Int32 uParam,
                                                       String lpvParam,
                                                       Int32 fuWinIni);
    }
"@
 
     
    $SPI_SETDESKWALLPAPER = 0x0014
    $UpdateIniFile = 0x01
    $SendChangeEvent = 0x02
     
    $fWinIni = $UpdateIniFile -bor $SendChangeEvent
    
    if ($PSCmdlet.ShouldProcess($Image)) {
        [void][Params]::SystemParametersInfo($SPI_SETDESKWALLPAPER, 0, $Image, $fWinIni)
    }
}