Lumos.psm1

Function Invoke-Lumos {
    <#
        .SYNOPSIS
            Sets the Windows or Mac Theme to light or dark mode dependent on time of day.
 
        .DESCRIPTION
            Use this cmdlet to change the theme on Windows 10 or MacOS Mojave to the light of dark themes,
            either as specified by parameters or (for Windows only), automatically based on the local time
            of day and whether it is before or after sunrise/sunset.
 
        .PARAMETER Dark
            Switch to the Dark OS theme.
 
        .PARAMETER Light
            Switch to the Light OS theme.
 
        .PARAMETER ExcludeSystem
            Exclude changing the System theme when switching to Dark/Light (Windows only).
 
        .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
 
            On Windows: Switches to either Dark or Light theme dependent on your current location/time of day.
            On MacOS: Switches current theme from either Light to Dark or Dark to Light.
    #>

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

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

        [switch]
        $ExcludeSystem,

        [switch]
        $IncludeOfficeProPlus,

        [switch]
        $ExcludeApps,

        [string]
        $DarkWallpaper,

        [string]
        $LightWallpaper
    )

    if ($Dark) {
        $Lumos = 0
    }
    elseif ($Light) {
        $Lumos = 1
    }
    elseif ($IsMacOS) {
        ### MacOS ###

        # Leaving Lumos as undefined on MacOS will make it just alternate to whatever mode it currently is not
        $Lumos = 'Undefined'
    }
    else {
        ### Windows ###
        $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
        }
    }

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

    if ($IsMacOS) {
        ### MacOS ###
        $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'

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

            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
                # Explorer's taskbar - including on secondary monitors - only repaints correctly after being
                # restarted. Only doing this when the theme actually changed avoids restarting Explorer on every
                # run of a frequently scheduled task (e.g. Register-LumosScheduledTask's 15 minute trigger).
                Write-Verbose 'Restarting Explorer to apply the theme change to the taskbar..'
                Stop-Process -ProcessName explorer
            }
        }
        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 a Scheduled Task to run Lumos automatically on Windows.
 
         .DESCRIPTION
            Use this cmdlet to register a scheduled task on Windows so that Invoke-Lumos is executed using
            your specified parameters repeatedly every 15 minutes. Invoke-Lumos looks up the current
            sunrise/sunset for your location on every run and only changes anything when the theme needs to
            change, so this keeps the Dark/Light switch closely aligned with sunrise and sunset without the
            scheduled task itself ever needing its trigger times updated.
 
            The task runs as the current user at standard (non-elevated) privilege - Lumos only ever changes
            current-user settings, so no administrator rights are required. Its trigger is fixed at registration
            time rather than being refreshed later, since repeatedly re-registering the task to update trigger
            times proved unreliable in practice. The task deliberately has no "at logon" trigger, since some
            endpoint security software blocks non-admin users from registering one (likely because it's a common
            persistence technique) - the repeating trigger fires immediately on registration and again within 15
            minutes of any logon, so this has little practical effect.
 
        .PARAMETER ExcludeSystem
            Exclude changing the System theme when switching to Dark/Light (Windows only) when the task runs.
 
        .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 a scheduled task that runs every 15 minutes, switching just the OS theme to either dark or light
            based on the current local sunrise/sunset, along with the specified light or dark wallpaper.
    #>

    [cmdletbinding()]
    Param(
        [switch]
        $ExcludeSystem,

        [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
    }

    $ArgumentDefaults = '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden'

    $LumosArgument = "$ArgumentDefaults -Command Invoke-Lumos"

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

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

    # Repeats indefinitely every 15 minutes so Invoke-Lumos re-checks sunrise/sunset regularly, without ever
    # needing the task's own triggers to be updated later. Deliberately not an "at logon" trigger - some
    # endpoint security software denies non-admin users permission to register one.
    $IntervalTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 15)

    New-ScheduledTask -Action $LumosAction -Principal $Principal -Settings $TaskSettings -Trigger $IntervalTrigger |
        Register-ScheduledTask -TaskName 'Lumos' -Force | 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-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 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)
    }
}