Cackledaemon.psm1

$CackledaemonWD = Join-Path $Env:AppData 'Cackledaemon'
$CackledaemonConfigLocation = Join-Path $CackledaemonWD 'Configuration.ps1'

function New-CackledaemonWD {
    param(
        [switch]$NoShortcuts,
        [switch]$NoFileTypeAssociations
    )
    New-Item -Path $CackledaemonWD -ItemType directory

    $ModuleDirectory = Split-Path -Path (Get-Module Cackledaemon).Path -Parent

    Copy-Item (Join-Path $ModuleDirectory 'Configuration.ps1') (Join-Path $CackledaemonWD 'Configuration.ps1')

    if (-not $NoShortcuts) {
        Copy-Item (Join-Path $ModuleDirectory 'Shortcuts.csv') (Join-Path $CackledaemonWD 'Shortcuts.csv')
    }

    if (-not $NoFileTypeAssociations) {
        Copy-Item (Join-Path $ModuleDirectory 'FileTypeAssociations.csv') (Join-Path $CackledaemonWD 'FileTypeAssociations.csv')
    }
}

class ShortcutCsvRecord {
    [string]$ShortcutName
    [string]$EmacsBinaryName
    [string]$ArgumentList
    [string]$Description

    ShortcutCsvRecord(
        [string]$ShortcutName,
        [string]$EmacsBinaryName,
        [string]$ArgumentList,
        [string]$Description
    ) {
        $this.ShortcutName = $ShortcutName
        $this.EmacsBinaryName = $EmacsBinaryName
        $this.ArgumentList = $ArgumentList
        $this.Description = $Description
    }
}

class ShortcutRecord {
    [string]$ShortcutName
    [string]$EmacsBinaryName
    [string[]]$ArgumentList
    [string]$Description

    ShortcutRecord(
        [string]$ShortcutName,
        [string]$EmacsBinaryName,
        [string[]]$ArgumentList,
        [string]$Description
    ) {
        $this.ShortcutName = $ShortcutName
        $this.EmacsBinaryName = $EmacsBinaryName
        $this.ArgumentList = $ArgumentList
        $this.Description = $Description
    }
}

function Get-ShortcutsConfig {
    Import-Csv -Path (Join-Path $CackledaemonWD './Shortcuts.csv') | ForEach-Object {
        New-Object ShortcutRecord $_.ShortcutName, $_.EmacsBinaryName, ($_.ArgumentList | ConvertFrom-Json), $_.Description
    }
}

class FileTypeAssociationRecord {
    [string]$FileType
    [string]$Extension
    [string]$Command

    FileTypeAssociationRecord(
        [string]$FileType,
        [string]$Extension,
        [string]$Command
    ) {
        $this.FileType = $FileType
        $this.Extension = $Extension
        $this.Command = $Command
    }
}

class FileTypeAssociationCsvRecord : FileTypeAssociationRecord {
    FileTypeAssociationCsvRecord([string]$FileType, [string]$Extension, [string]$Command): base($FileType, $Extension, $Command) {}
}

function Get-FileTypeAssociationsConfig {
    Import-Csv -Path (Join-Path $CackledaemonWD './FileTypeAssociations.csv') | ForEach-Object {
        New-Object FileTypeAssociationRecord $_.FileType, $_.Extension, $_.Command
    }
}

function Enable-Job {
    [CmdletBinding()]
    param(
        [Parameter(Position=0)]
        [string]$Name,
        [Parameter(Position=1)]
        [ScriptBlock]$ScriptBlock
    )

    $Job = Get-Job -Name $Name -ErrorAction SilentlyContinue

    if ($Job) {
        Write-LogWarning ('{0} job already exists. Trying to stop and remove...' -f $Name)
            Disable-Job -Name $Job.Name -ErrorAction Stop

    }

    $Job = Get-Job -Name $Name -ErrorAction SilentlyContinue

    if ($Job) {
        Write-LogError -Message ('{0} job somehow still exists - not attempting to start a new one.' -f $Name) `
          -Category 'ResourceExists' `
          -CategoryActivity 'Enable-Job' `
          -CategoryReason 'UnstoppableJobException'
    } else {
        Start-Job `
          -Name $Name `
          -InitializationScript {
              Import-Module Cackledaemon
          } `
          -ScriptBlock $ScriptBlock
    }
}

function Disable-Job {
    [CmdletBinding()]
    param(
        [Parameter(Position=0)]
        [string]$Name
    )

    $Job = Get-Job -Name $Name -ErrorAction SilentlyContinue

    if (-not $Job) {
        Write-LogWarning ("{0} job doesn't exist. Doing nothing." -f $Name)
        return
    }

    try {
        Stop-Job -Name $Name -ErrorAction Stop
        Remove-Job -Name $Name -ErrorAction Stop
    } catch {
        Write-LogError -Message ('Failed to stop and remove {0} job.' -f $Name) `
            -Exception $_.Exception `
            -Category $_.CategoryInfo.Category `
            -CategoryActivity $_.CategoryInfo.Activity `
            -CategoryReason $_.CategoryInfo.Reason `
            -CategoryTargetName $_.CategoryInfo.TargetName `
            -CategoryTargetType $_.CategoryInfo.TargetType
    }
}

function Write-Log {
    param(
        [Parameter(Position=0)]
        [string]$Message,
        [string]$Level = 'Verbose',
        [Exception]$Exception,
        [System.Management.Automation.ErrorCategory]$Category = 'NotSpecified',
        [string]$CategoryActivity,
        [string]$CategoryReason,
        [string]$CategoryTargetName,
        [string]$CategoryTargetType
    )

    Try {
        . $CackledaemonConfigLocation
    } Catch {
        Write-Warning 'Unable to load configuration! Unable to write to log file.'
    }

    if (-not @('Debug', 'Verbose', 'Information', 'Host', 'Warning', 'Error').Contains($Level)) {
        Write-LogWarning ('Write-Log called with unrecognized level {0}' -f $Level)
        $Level = 'Warning'
    }

    if ($Level -eq 'Error' -and $Exception) {
        $Message = ('{0} (Exception: {1})' -f $Message, $Exception)
    }

    $Line = ('[{0}] {1}: {2}' -f (Get-Date -Format o), $Level, $Message)

    if ($CackledaemonLogFile) {
        Add-Content $CackledaemonLogFile -Value $Line
    }

    if ($Level -eq 'Debug') {
        Write-Debug $Message
    } elseif ($Level -eq 'Verbose') {
        Write-Verbose $Message
    } elseif ($Level -eq 'Information') {
        Write-Information $Message
    } elseif ($Level -eq 'Host') {
        Write-Host $Message
    } elseif ($Level -eq 'Warning') {
        Write-Warning $Message
    } elseif ($Level -eq 'Error') {
        if ($Exception) {
            Write-Error -Message $Message `
              -Exception $Exception `
              -Category $Category `
              -CategoryActivity $CategoryActivity `
              -CategoryReason $CategoryReason `
              -CategoryTargetName $CategoryTargetName `
              -CategoryTargetType $CategoryTargetType
        } else {
            Write-Error -Message $Message `
              -Category $Category `
              -CategoryActivity $CategoryActivity `
              -CategoryReason $CategoryReason `
              -CategoryTargetName $CategoryTargetName `
              -CategoryTargetType $CategoryTargetType
        }
    }
}

function Write-LogDebug {
    [Parameter(Position=0)]
    param([string]$Message)

    Write-Log $Message -Level Debug
}

function Write-LogInformation {
    [Parameter(Position=0)]
    param([string]$Message)

    Write-Log $Message -Level Information
}

function Write-LogHost {
    [Parameter(Position=0)]
    param([string]$Message)

    Write-Log $Message -Level Host
}

function Write-LogVerbose {
    [Parameter(Position=0)]
    param([string]$Message)

    Write-Log $Message -Level Verbose
}

function Write-LogWarning {
    [Parameter(Position=0)]
    param([string]$Message)

    Write-Log $Message -Level Warning
}

function Write-LogError {
    param(
        [Parameter(Position=0)]
        [string]$Message,
        [Exception]$Exception,
        [System.Management.Automation.ErrorCategory]$Category = 'NotSpecified',
        [string]$CategoryActivity,
        [string]$CategoryReason,
        [string]$CategoryTargetName,
        [string]$CategoryTargetType
    )

    if ($Exception) {
        Write-Log -Level Error `
          -Message $Message `
          -Exception $Exception `
          -Category $Category `
          -CategoryActivity $CategoryActivity `
          -CategoryReason $CategoryReason `
          -CategoryTargetName $CategoryTargetName `
          -CategoryTargetType $CategoryTargetType
    } else {
        Write-Log -Level Error `
          -Message $Message `
          -Category $Category `
          -CategoryActivity $CategoryActivity `
          -CategoryReason $CategoryReason `
          -CategoryTargetName $CategoryTargetName `
          -CategoryTargetType $CategoryTargetType
    }
}

function Invoke-LogRotate {
    [CmdletBinding()]
    param()

    . $CackledaemonConfigLocation

    @($CackledaemonLogFile, $EmacsStdoutLogFile, $EmacsStdErrLogFile) | ForEach-Object {
        $LogFile = $_

        if ((Test-Path $LogFile) -and (Get-Item $LogFile).Length -ge $LogSize) {
            Write-LogVerbose ('Rotating {0}...' -f $LogFile)

            ($LogRotate..0) | ForEach-Object {
                $Current = $(if ($_) {
                    '{0}.{1}' -f $LogFile, $_
                } else { $LogFile })

                $Next = '{0}.{1}' -f $LogFile, ($_ + 1)

                if (Test-Path $Current) {
                    Write-LogVerbose ('Copying {0} to {1}...' -f $Current, $Next)

                    Copy-Item -Path $Current -Destination $Next
                }
            }

            Write-LogVerbose ('Truncating {0}...' -f $LogFile)

            Clear-Content $LogFile

            $StaleLogFile = '{0}.{1}' -f $LogFile, ($LogRotate + 1)

            if (Test-Path $StaleLogFile) {
                Write-LogVerbose ('Removing {0}...' -f $StaleLogFile)

                Remove-Item $StaleLogFile
            }

            Write-LogVerbose 'Done.'
        }
    }
}

function Enable-LogRotateJob {
    [CmdletBinding()]
    param()

    Enable-Job 'LogRotateJob' {
        . $CackledaemonConfigLocation

        while ($True) {
            Invoke-LogRotate
            Write-LogDebug ('LogRotateJob sleeping for {0} seconds.' -f $LogCheckTime)
            Start-Sleep -Seconds $LogCheckTime
        }
    }
}

function Disable-LogRotateJob {
    [CmdletBinding()]
    param()

    Disable-Job 'LogRotateJob'
}

function Test-EmacsExe {
    . $CackledaemonConfigLocation

    Test-Path (Join-Path $EmacsInstallLocation 'bin\emacs.exe')
}

class Version : IComparable {
    [int]$Major
    [int]$Minor

    Version([int64]$Major, [int64]$Minor) {
        $this.Major = $Major
        $this.Minor = $Minor
    }

    [int]CompareTo([object]$Other) {
        if ($Other -eq $null) {
            return 1
        }

        $Other = [Version]$Other

        if ($this.Major -gt $Other.Major) {
            return 1
        } elseif ($this.Major -lt $Other.Major) {
            return -1
        } elseif ($this.Minor -gt $Other.Minor) {
            return 1
        } elseif ($this.Minor -lt $Other.Minor) {
            return -1
        } else {
            return 0
        }
    }

    [string]ToString() {
        return 'v{0}.{1}' -f $this.Major, $this.Minor
    }
}

function New-Version {
    param(
        [Parameter(Position=0)]
        [int]$Major,
        [Parameter(Position=1)]
        [int]$Minor
    )

    return New-Object Version $Major, $Minor
}

function Get-EmacsExeVersion {
    if (Test-EmacsExe) {
        . $CackledaemonConfigLocation

        $EmacsExe = Join-Path $EmacsInstallLocation 'bin\emacs.exe'
        if ((& $EmacsExe --version)[0] -match '^GNU Emacs (\d+)\.(\d+)$') {
            New-Version $Matches[1] $Matches[2]
        }
    }
}

class Download : IComparable {
    [Version]$Version
    [string]$Href

    Download([int64]$Major, [int64]$Minor, [string]$Href) {
        $this.Version = New-Object Version $Major, $Minor
        $this.Href = $Href
    }

    [int]CompareTo([object]$Other) {
        if ($Other -eq $null) {
            return 1
        }

        $Other = [Download]$Other

        return $this.Version.CompareTo($Other.Version)
    }

    [string]ToString() {
        return 'Download($Version={0}; $Href={1})' -f $this.Version, $this.Href
    }
}

function New-Download {
    param(
        [int]$Major,
        [int]$Minor,
        [string]$Href
    )

    New-Object Download $Major, $Minor, $Href
}

function Get-EmacsDownload {
    . $CackledaemonConfigLocation

    return (Invoke-WebRequest $EmacsDownloadsEndpoint).Links | ForEach-Object {
        if ($_.href -match '^emacs-(\d+)/$') {
            $MajorPathPart = $_.href

            if ([int]$Matches[1] -lt 25) {
                return
            }

            (Invoke-WebRequest ($EmacsDownloadsEndpoint + $MajorPathPart)).Links | ForEach-Object {
                 if ($_.href -match '^emacs-(\d+)\.(\d+)-x86_64\.zip$') {
                     $Href = $EmacsDownloadsEndpoint + $MajorPathPart + $_.href
                     return New-Download $Matches[1] $Matches[2] $Href
                 }
            }
        }
    } | Where-Object {$_}
}

function Get-LatestEmacsDownload {
    (Get-EmacsDownload | Measure-Object -Maximum).Maximum
}

class Workspace {
    [System.IO.DirectoryInfo]$Root
    [System.IO.DirectoryInfo]$Archives
    [System.IO.DirectoryInfo]$Installs
    [System.IO.DirectoryInfo]$Backups

    Workspace([string]$Path) {
        $ArchivesPath = Join-Path $Path 'Archives'
        $InstallsPath = Join-Path $Path 'Installs'
        $BackupsPath = Join-Path $Path 'Backups'

        $this.Root = Get-Item $Path
        $this.Archives = Get-Item $ArchivesPath
        $this.Installs = Get-Item $InstallsPath
        $this.Backups = Get-Item $BackupsPath
    }

    [string]GetKey([Version]$Version) {
        return 'emacs-{0}.{1}-x86_64' -f $Version.Major, $Version.Minor
    }

    [string]GetArchivePath([Version]$Version) {
        return Join-Path $this.Archives ('{0}.zip' -f $this.GetKey($Version))
    }

    [boolean]TestArchive([Version]$Version) {
        return Test-Path $this.GetArchivePath($Version)
    }

    [System.IO.FileInfo]GetArchive([Version]$Version) {
        return Get-Item $this.GetArchivePath($Version)
    }

    [string]GetInstallPath([Version]$Version) {
        return Join-Path $this.Installs $this.GetKey($Version)
    }

    [boolean]TestInstall([Version]$Version) {
        return Test-Path $this.GetInstallPath($Version)
    }

    [System.IO.DirectoryInfo]GetInstall([Version]$Version) {
        return Get-Item $this.GetInstallPath($Version)
    }

    Clear() {
        $this.Root = $null
        $this.Archives = $null
        $this.Installs = $null
        $this.Backups = $null
    }
}

function Test-Workspace {
    . $CackledaemonConfigLocation

    Test-Path $WorkspaceDirectory
}

function Get-Workspace {
    . $CackledaemonConfigLocation

    return New-Object Workspace $WorkspaceDirectory
}

function New-Workspace {
    . $CackledaemonConfigLocation

    $ArchivesPath = Join-Path $WorkspaceDirectory 'Archives'
    $InstallsPath = Join-Path $WorkspaceDirectory 'Installs'
    $BackupsPath = Join-Path $WorkspaceDirectory 'Backups'

    New-Item -Type Directory $WorkspaceDirectory | Out-Null

    New-Item -Type Directory $ArchivesPath | Out-Null
    New-Item -Type Directory $InstallsPath | Out-Null
    New-Item -Type Directory $BackupsPath | Out-Null

    return New-Object Workspace $WorkspaceDirectory
}

function Remove-Workspace {
    $Workspace = Get-Workspace

    Remove-Item $Workspace.Root -Recurse

    $Workspace.Clear()
}

function New-EmacsArchive {
    param(
        [Parameter(Position=0)]
        [Download]$Download
    )

    $Workspace = Get-Workspace

    $Archive = $Workspace.GetArchivePath($Download.Version)

    Invoke-WebRequest `
      -Uri $Download.Href `
      -OutFile $Archive | Out-Null

    return Get-Item $Archive
}

function Export-EmacsArchive {
    param(
        [Parameter(Position=0)]
        [string]$Path
    )

    $Workspace = Get-Workspace

    $Key = [IO.Path]::GetFileNameWithoutExtension($Path)

    $Destination = Join-Path $Workspace.Installs.FullName $Key

    Expand-Archive -Path $Path -DestinationPath $Destination

    return Get-Item $Destination
}

function Update-EmacsInstall {
    param(
        [string]$Path
    )


    $Source = Get-Item -ErrorAction Stop $Path

    . $CackledaemonConfigLocation

    $Workspace = Get-Workspace

    $Backup = Join-Path $Workspace.Backups ('emacs-{0}' -f (Get-Date -Format 'yyyyMMddHHmmss'))

    if (Test-Path $EmacsInstallLocation -ErrorAction Stop) {
        Copy-Item $EmacsInstallLocation $Backup -ErrorAction Stop
        Remove-Item -Recurse $EmacsInstallLocation -ErrorAction Stop
    }

    Move-Item $Source $EmacsInstallLocation -ErrorAction Stop
    Remove-Item -Recurse $Backup -ErrorAction SilentlyContinue

    return Get-Item $EmacsInstallLocation
}

function Set-EmacsPathEnvVariable {
    [CmdletBinding()]
    param()

    . $CackledaemonConfigLocation

    $Path = Join-Path $EmacsInstallLocation 'bin'

    $ExistingEmacs = Get-Command 'emacs.exe' -ErrorAction SilentlyContinue

    if ($ExistingEmacs) {
        $ExistingEmacsBinDir = Split-Path $ExistingEmacs.Source -Parent
    }

    if ($ExistingEmacs -and -not ($ExistingEmacsBinDir -eq $Path)) {
        Write-Warning ('An unmanaged Emacs is already installed at {0} - this may cause unexpected behavior.' -f $ExistingEmacsBinDir)
    }

    $PathProperty = (Get-ItemProperty -Path 'HKCU:\Environment' -Name 'Path')
    $PathParts = $PathProperty.Path.Split(';') | Where-Object { $_ }

    $ExistingEmacsPathPart = $PathParts | Where-Object { $_ -eq $Path }

    if ($ExistingEmacsPathPart) {
        Write-Verbose 'Emacs is already in the PATH - no changes necessary.'
    } else {
        $PathProperty.Path += ($Path + ';')

        Set-ItemProperty -Path 'HKCU:\Environment' -Name 'Path' -Value $PathProperty
    }
}

function Set-HomeEnvVariable {
    . $CackledaemonConfigLocation

    Set-ItemProperty -Path 'HKCU:\Environment' -Name 'HOME' -Value $HomeDirectory
}

function Set-EmacsAppPathRegistryKeys {
    . $CackledaemonConfigLocation


    @('emacs.exe', 'runemacs.exe', 'emacsclient.exe', 'emacsclientw.exe') | ForEach-Object {
        $RegistryPath = Join-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths' $_
        $BinPath = Join-Path $EmacsInstallLocation "bin\$_"

        if (Test-Path $BinPath) {
            if (Test-Path -Path $RegistryPath) {
                Set-Item -Path $RegistryPath -Value $BinPath
            } else {
                New-Item -Path $RegistryPath -Value $BinPath
            }
            Set-ItemProperty -Path $RegistryPath -Name Path -Value $Path
        } else {
            Write-Error -Message ("{0} doesn't exist - refusing to write this to the registry." -f $BinPath) `
              -Category ObjectNotFound `
              -CategoryActivity 'Set-EmacsAppPathRegistryKeys' `
              -CategoryReason 'ItemNotFoundException'`
              -CategoryTargetName $BinPath `
              -CategoryTargetType 'string'
        }

    }
}

function Get-StartMenuItems {
    . $CackledaemonConfigLocation

    Get-ChildItem -Path $StartMenuPath -ErrorAction SilentlyContinue | ForEach-Object {
        Get-Item $_.FullName
    }
}

function Get-WShell {
    if (-not $WShell) {
        $Global:WShell = New-Object -comObject WScript.Shell
    }

    return $WShell
}

function Set-Shortcut {
    param(
        [string]$ShortcutPath,
        [string]$TargetPath,
        [string[]]$ArgumentList = @(),
        [string]$WorkingDirectory = $Env:UserProfile,
        [string]$Description
    )

    $Shell = Get-WShell

    $Arguments = ($ArgumentList | ForEach-Object {
        if ($_ -match '[" ]') {
            return ('"{0}"' -f ($_ -replace '"', '\"'))
        } else {
            return ($_ -replace '([,;=\W])', '^$1')
        }
    }) -join ' '

    $Shortcut = $Shell.CreateShortcut($ShortcutPath)
    $Shortcut.TargetPath = $TargetPath
    $Shortcut.Arguments = $Arguments
    $Shortcut.WorkingDirectory = $WorkingDirectory

    if ($Description) {
        $Shortcut.Description = $Description
    }

    $Shortcut.Save()
}

function Install-Shortcuts {
    . $CackledaemonConfigLocation

    $Config = Get-ShortcutsConfig
    $CurrentItems = Get-StartMenuItems
    $DesiredShortcutPaths = $Config | ForEach-Object {
        Join-Path $StartMenuPath ($_.ShortcutName + ".lnk")
    }

    $CurrentItems | Where-Object {
        -not $DesiredShortcutPaths.Contains($_.FullName)
    } | ForEach-Object {
        Remove-Item $_
    }

    $Config | ForEach-Object {
        Set-Shortcut `
          -ShortcutPath (Join-Path $StartMenuPath ($_.ShortcutName + ".lnk")) `
          -TargetPath (Join-Path "$EmacsInstallLocation\bin" $_.EmacsBinaryName) `
          -ArgumentList $_.ArgumentList `
          -Description $_.Description
    }
}

function Install-FileTypeAssociations {
    Get-FileTypeAssociationsConfig | ForEach-Object {
        cmd /c assoc ("{0}={1}" -f $_.Extension, $_.FileType)
        cmd /c ftype ("{0}={1}" -f $_.FileType, $_.Command)
    }
}

function Install-EmacsUserEnvironment {
    $ErrorActionPreference = 'Stop'

    Write-Progress -Id 3 -Activity 'Setting up the Emacs user environment' -CurrentOperation "Updating the user's `$Path variable..." -PercentComplete 0
    Write-LogHost "Updating the user's `$Path variable..."

    Set-EmacsPathEnvVariable

    Write-Progress -Id 3 -Activity 'Setting up the Emacs user environment' -CurrentOperation "Setting the user's `$HOME variable..." -PercentComplete 33
    Write-LogHost "Setting the user's `$HOME variable..."

    Set-HomeEnvVariable

    Write-Progress -Id 3 -Activity 'Setting up the Emacs user environment' -CurrentOperation "Installing shortcuts..." -PercentComplete 67
    Write-LogHost "Installing shortcuts..."

    Install-Shortcuts

    Write-Progress -Id 3 -Activity 'Setting up the Emacs user environment' -Completed
}

function Install-Emacs {
    $ErrorActionPreference = 'Stop'

    Write-Progress -Id 1 -Activity 'Installing Emacs' -CurrentOperation 'Loading Cackledaemon configuration...' -PercentComplete 0
    Write-LogVerbose 'Loading Cackledaemon configuration...'

    . $CackledaemonConfigLocation

    if (Test-Workspace) {
        $Workspace = Get-Workspace
    } else {
        Write-Progress -Id 1 -Activity 'Installing Emacs' -CurrentOperation 'Creating workspace...' -PercentComplete 14
        Write-LogVerbose 'Creating new workspace...'

        $Workspace = New-Workspace
    }

    Write-Progress -Id 1 -Activity 'Installing Emacs' -CurrentOperation 'Checking the Emacs website for the latest available download...' -PercentComplete 29
    Write-LogHost 'Checking the Emacs website for the latest available download...'

    $LatestDownload = Get-LatestEmacsDownload

    Write-LogVerbose ('Version {0} is the latest version of Emacs available for install' -f $LatestDownload.Version)

    $ShouldInstall = $False

    Write-Progress -Id 1 -Activity 'Installing Emacs' -CurrentOperation 'Checking if Emacs needs to be installed or updated...' -PercentComplete 43
    Write-Progress -ParentId 1 -Id 2 -Activity 'Checking the currently installed Emacs version' -CurrentOperation "Looking for an Emacs install in $EmacsInstallLocation..." -PercentComplete 0
    if (Test-EmacsExe) {
        Write-Progress -ParentId 1 -Id 2 -Activity 'Checking the currently installed Emacs version' -CurrentOperation 'Running "Emacs --version"...' -PercentComplete 33

        $InstalledVersion = Get-EmacsExeVersion

        Write-LogVerbose ('Version {0} of Emacs is installed' -f $InstalledVersion)

        Write-Progress -ParentId 1 -Id 2 -Activity 'Checking the currently installed Emacs version' -CurrentOperation 'Comparing versions...' -PercentComplete 57
        if ($LatestDownload.Version -gt $InstalledVersion) {
            Write-LogVerbose ('Upstream Emacs version {0} is newer than installed Emacs version {1}' -f $LatestDownload.Version, $InstalledVersion)
            $ShouldInstall = $True
        } else {
            Write-LogVerbose ('Upstream Emacs version {0} is no newer than installed Emacs version {1}' -f $LatestDownload.Version, $InstalledVersion)
        }
    } else {
        Write-LogVerbose 'No version of Emacs is installed'
        $ShouldInstall = $True
    }

    Write-Progress -ParentId 1 -Id 2 -Activity 'Checking the currently installed Emacs version' -Completed

    if (-not $ShouldInstall) {
        Write-Progress -Id 1 -Activity 'Installing Emacs' -Completed
        Write-LogHost 'Emacs is currently installed and at the latest available version.'
    } else {
        $TargetVersion = $LatestDownload.Version

        if ($Workspace.TestInstall($TargetVersion)) {
            Write-LogVerbose "Emacs has already been downloaded and unpacked for version $TargetVersion"
            $Install = $Workspace.GetInstall($TargetVersion)
        } else {
            if ($Workspace.TestArchive($TargetVersion)) {
                Write-LogVerbose "Eamcs has already been downloaded (but not unpacked) for version $TargetVersion"
                $Archive = $Workspace.GetArchive($TargetVersion);
            } else {
                Write-Progress -Id 1 -Activity 'Installing Emacs' -CurrentOperation "Downloading Emacs version $TargetVersion..." -PercentComplete 71
                Write-LogHost "Downloading Emacs version $TargetVersion..."

                $Archive = New-EmacsArchive $LatestDownload
            }
            Write-Progress -Id 1 -Activity 'Installing Emacs' -CurrentOperation "Unpacking Emacs version $TargetVersion..." -PercentComplete 86
            Write-LogHost "Unpacking Emacs version $TargetVersion..."

            $Install = Export-EmacsArchive $Archive
        }


        Write-Progress -Id 1 -Activity 'Installing Emacs' -CurrentOperation 'Executing Administrator commands...' -PercentComplete 60
        Write-LogHost "Executing Administrator commands..."

        $AdminProcess = Start-Process -Wait powershell.exe -Verb RunAs -ArgumentList '-Command', "& {
            `$ErrorActionPreference = 'Stop'
            Import-Module Cackledaemon
 
            Write-Progress -Activity 'Installing Emacs' -CurrentOperation 'Moving files to $EmacsInstallLocation...' -PercentComplete 0
 
            Write-LogHost 'Moving files to $EmacsInstallLocation...'
            Update-EmacsInstall -Path $Install
 
            Write-Progress -Activity 'Installing Emacs' -CurrentOperation 'Setting App Path registry keys...' -PercentComplete 1
 
            Write-LogHost 'Setting App Path registry keys...'
            Set-EmacsAppPathRegistryKeys
 
            Write-Progress -Activity 'Installing Emacs' -CurrentOperation 'Setting file type associations...' -PercentComplete 2
 
            Write-LogHost 'Setting file type associations...'
            Install-FileTypeAssociations
 
            Write-Progress -Activity 'Installing Emacs' -Completed
        }"


        if ($AdminProcess.ExitCode) {
            Write-Progress -Id 1 -Activity 'Installing Emacs' -Completed
            $ExitCode = $AdminProcess.ExitCode
            Write-LogHost "Administrator commands failed with exit code $ExitCode. Check $CackledaemonLogFile for details."
        } else {
            Write-Progress -Id 1 -Activity 'Installing Emacs' -Completed
            Write-LogHost "Emacs $TargetVersion is installed and ready to rock!"
        }
    }
}

function Test-ServerFileDirectory {
    . $CackledaemonConfigLocation

    Test-Path $ServerFileDirectory
}

function New-ServerFileDirectory {
    . $CackledaemonConfigLocation

    New-Item -Type Directory $ServerFileDirectory
}

function Clear-ServerFileDirectory {
    . $CackledaemonConfigLocation

    Get-ChildItem $ServerFileDirectory | ForEach-Object {
        Remove-Item $_.FullName
    }
}

function Write-ProcessToPidFile {
    param([System.Diagnostics.Process]$Process)

    . $CackledaemonConfigLocation

    ($Process).Id | ConvertTo-Json | Out-File $PidFile
}

function Get-ProcessFromPidFile {
    . $CackledaemonConfigLocation

    if (-not (Test-Path $PidFile)) {
        return $null
    }

    $Id = (Get-Content $PidFile | ConvertFrom-Json)

    $Process = Get-Process -Id $Id -ErrorAction SilentlyContinue

    if (-not $Process) {
        Remove-Item $PidFile
    }

    return $Process
}

function Get-UnmanagedEmacsDaemon () {
    $ManagedProcess = Get-ProcessFromPidFile
    return Get-CimInstance -Query "
        SELECT
          *
        FROM Win32_Process
        WHERE
          Name = 'emacs.exe' OR Name = 'runemacs.exe'
    "
 | Where-Object {
        $_.CommandLine.Contains("--daemon")
    } | ForEach-Object {
        Get-Process -Id ($_.ProcessId)
    } | Where-Object { -not ($_.Id -eq $ManagedProcess.Id) }
}

function Start-EmacsDaemon {
    [CmdletBinding()]
    param ([switch]$Wait)

    . $CackledaemonConfigLocation

    $Process = Get-ProcessFromPidFile

    if ($Process) {
        Write-LogError `
          -Message 'The Emacs daemon is already running and being managed.' `
          -Category ResourceExists `
          -CategoryActivity 'Start-EmacsDaemon' `
          -CategoryReason ManagedResourceExistsException

    } elseif (Get-UnmanagedEmacsDaemon) {
        Write-LogError `
          -Message 'An unmanaged Emacs daemon is running.' `
          -Category ResourceExists `
          -CategoryActivity 'Start-EmacsDaemon' `
          -CategoryReason UnmanagedResourceExistsException
    } else {
        Write-LogVerbose 'Starting the Emacs daemon...'

        $Process = Start-Process `
          -FilePath 'emacs.exe' `
          -ArgumentList '--daemon' `
          -NoNewWindow `
          -RedirectStandardOut $EmacsStdOutLogFile `
          -RedirectStandardError $EmacsStdErrLogFile `
          -PassThru

        Write-ProcessToPidFile $Process

        if ($Wait) {
            Write-Verbose 'Waiting for Emacs daemon to exit...'
            $Process = Wait-Process -Id $Process.Id
        }

        Write-Verbose 'Done.'

        return $Process
    }
}

function Get-EmacsDaemon {
    [CmdletBinding()]
    param()

    Get-EmacsProcessFromPidFile
}

function Stop-EmacsDaemon {
    [CmdletBinding()]
    param()

    $Process = Get-ProcessFromPidFile

    if (-not $Process) {
        Write-LogError `
          -Message "A managed Emacs daemon isn't running and can not be stopped!" `
          -Category ResourceUnavailable `
          -CategoryActivity 'Stop-EmacsDaemon' `
          -CategoryReason ManagedResourceUnavailableException
    } else {
        Write-LogVerbose 'Stopping the Emacs daemon...'

        Stop-Process -InputObject $Process

        Write-ProcessToPidFile $null

        Write-LogVerbose 'Done.'
    }
}

function Restart-EmacsDaemon {
    [CmdletBinding()]
    param()

    try {
        Stop-EmacsDaemon -ErrorAction Stop
    } catch {
        Write-LogWarning 'Attempting to start the Emacs daemon even though stopping it failed'
    }

    Start-EmacsDaemon
}

Add-Type -AssemblyName System.Windows.Forms

function Invoke-Applet {
    [CmdletBinding()]
    param()

    # The parent Form

    $Global:AppletForm = New-Object System.Windows.Forms.Form
    $AppletForm.Visible = $False
    $AppletForm.WindowState = "minimized"
    $AppletForm.ShowInTaskbar = $False

    # The NotifyIcon

    $Global:AppletIcon = New-Object System.Windows.Forms.NotifyIcon
    $AppletIcon.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon(
        (Get-Command 'emacs.exe').Path
    )
    $AppletIcon.Visible = $True

    # Notify the user if something fails

    function Start-InstrumentedBlock {
        param(
            [Parameter(Position=0)]
            [string]$Message,

            [Parameter(Position=1)]
            [ScriptBlock]$ScriptBlock,

            [System.Windows.Forms.ToolTipIcon]$Icon = [System.Windows.Forms.ToolTipIcon]::Warning
        )

        try {
            Invoke-Command -ScriptBlock $ScriptBlock
        } catch {
            Try {
                . $CackledaemonConfigLocation
            } Catch {
                Write-Warning 'Unable to load configuration! Using default notify timeout.'
                $NotifyTimeout = 5000
            }

            Write-LogError -Message $_.Exception.Message `
              -Exception $_.Exception `
              -Category $_.CategoryInfo.Category `
              -CategoryActivity $_.CategoryInfo.Activity `
              -CategoryReason $_.CategoryInfo.Reason `
              -CategoryTargetName $_.CategoryInfo.TargetName `
              -CategoryTargetType $_.CategoryInfo.TargetType

            $AppletIcon.BalloonTipIcon = $Icon
            $AppletIcon.BalloonTipTitle = $Message
            $AppletIcon.BalloonTipText = $_.Exception
            $AppletIcon.ShowBalloonTip($NotifyTimeout)
        }

    }

    # The right-click menu

    $ContextMenu = New-Object System.Windows.Forms.ContextMenu
    $AppletIcon.ContextMenu = $ContextMenu

    # Status items

    $DaemonStatusItem = New-Object System.Windows.Forms.MenuItem
    $DaemonStatusItem.Index = 0
    $DaemonStatusItem.Text = '[???] Emacs Daemon'
    $ContextMenu.MenuItems.Add($DaemonStatusItem) | Out-Null

    $LogRotateStatusItem = New-Object System.Windows.Forms.MenuItem
    $LogRotateStatusItem.Text = '[???] Emacs Logs Rotation'
    $ContextMenu.MenuItems.Add($LogRotateStatusItem) | Out-Null

    $AppletIcon.add_MouseDown({
        $Process = Get-EmacsProcessFromPidFile
        if ($Process) {
            $DaemonStatusItem.Text = '[RUNNING] Emacs Daemon'
            $StartDaemonItem.Enabled = $False
            $StopDaemonItem.Enabled = $True
            $RestartDaemonItem.Enabled = $True
        } else {
            $DaemonStatusItem.Text = '[STOPPED] Emacs Daemon'
            $StartDaemonItem.Enabled = $True
            $StopDaemonItem.Enabled = $False
            $RestartDaemonItem.Enabled = $True
        }

        $Job = Get-Job -Name 'LogRotateJob' -ErrorAction SilentlyContinue

        if ($Job) {
            $State = $Job.State.ToUpper()

            if ($State -eq 'RUNNING') {
                $State = 'ENABLED'
            }

            $LogRotateStatusItem.Text = ('[{0}] Logs Rotation' -f $State)
            $EnableLogRotateJobItem.Enabled = $False
            $DisableLogRotateJobItem.Enabled = $True
        } else {
            $LogRotateStatusItem.Text = '[DISABLED] Logs Rotation'
            $EnableLogRotateJobItem.Enabled = $True
            $DisableLogRotateJobItem.Enabled = $False
        }
    })

    $ContextMenu.MenuItems.Add('-') | Out-Null

    # Daemon lifecycle items

    $StartDaemonItem = New-Object System.Windows.Forms.MenuItem
    $StartDaemonItem.Text = 'Start Emacs Daemon...'
    $StartDaemonItem.add_Click({
        Start-InstrumentedBlock 'Failed to start the Emacs daemon' {
            Start-EmacsDaemon -ErrorAction Stop
        }
    })
    $ContextMenu.MenuItems.Add($StartDaemonItem) | Out-Null

    $StopDaemonItem = New-Object System.Windows.Forms.MenuItem
    $StopDaemonItem.Text = 'Stop Emacs Daemon...'
    $StopDaemonItem.add_Click({
        Start-InstrumentedBlock 'Failed to stop the Emacs daemon' {
            Stop-EmacsDaemon -ErrorAction Stop
        }
    })
    $ContextMenu.MenuItems.Add($StopDaemonItem) | Out-Null

    $RestartDaemonItem = New-Object System.Windows.Forms.MenuItem
    $RestartDaemonItem.Text = 'Restart Emacs Daemon...'
    $RestartDaemonItem.add_Click({
        Start-InstrumentedBlock 'Failed to restart the Emacs daemon' {
            Restart-EmacsDaemon -ErrorAction Stop
        }
    })
    $ContextMenu.MenuItems.Add($RestartDaemonItem) | Out-Null

    $ContextMenu.MenuItems.Add('-') | Out-Null

    # Log rotate items

    $EnableLogRotateJobItem = New-Object System.Windows.Forms.MenuItem
    $EnableLogRotateJobItem.Text = 'Enable Log Rotation...'
    $EnableLogRotateJobItem.add_Click({
        Start-InstrumentedBlock 'Failed to enable log rotation' {
            Enable-LogRotateJob -ErrorAction Stop
        }
    })
    $ContextMenu.MenuItems.Add($EnableLogRotateJobItem) | Out-Null

    $DisableLogRotateJobItem = New-Object System.Windows.Forms.MenuItem
    $DisableLogRotateJobItem.Text = 'Disable Log Rotation...'
    $DisableLogRotateJobItem.add_Click({
        Start-InstrumentedBlock 'Failed to disable log rotation' {
            Disable-LogRotateJob -ErrorAction Stop
        }
    })
    $ContextMenu.MenuItems.Add($DisableLogRotateJobItem) | Out-Null

    $ContextMenu.MenuItems.Add('-') | Out-Null

    $EditConfigItem = New-Object System.Windows.Forms.MenuItem
    $EditConfigItem.Text = 'Edit Configuration...'
    $EditConfigItem.add_Click({
        Start-InstrumentedBlock 'Failed to edit configuration' {
            Start-Process $CackledaemonConfigLocation
        }
    })
    $ContextMenu.MenuItems.Add($EditConfigItem) | Out-Null

    $OpenWDItem = New-Object System.Windows.Forms.MenuItem
    $OpenWDItem.Text = 'Open Working Directory...'
    $OpenWDItem.add_Click({
        Start-InstrumentedBlock 'Failed to open working directory' {
            Start-Process $CackledaemonWD -ErrorAction Stop
        }
    })
    $ContextMenu.MenuItems.Add($OpenWDItem) | Out-Null

    $ContextMenu.MenuItems.Add('-') | Out-Null

    $ExitItem = New-Object System.Windows.Forms.MenuItem
    $ExitItem.Text = 'Exit'
    $ContextMenu.MenuItems.Add($ExitItem) | Out-Null

    # Lifecycle events

    $AppletForm.add_Load({
        Start-InstrumentedBlock 'Failed to start the Emacs daemon' {
            Start-EmacsDaemon -ErrorAction Stop
        }
        Start-InstrumentedBlock 'Failed to enable log rotation' {
            Enable-LogRotateJob -ErrorAction Stop
        }
    })

    $ExitItem.add_Click({
        if (Get-EmacsDaemon) {
            Start-InstrumentedBlock 'Failed to gracefully shut down Emacs' {
                Stop-EmacsDaemon -ErrorAction Stop
            }
        }

        if (Get-Job -Name 'LogRotateJob' -ErrorAction SilentlyContinue) {
            Start-InstrumentedBlock 'Failed to gracefully shut down log rotation' {
                Disable-LogRotateJob -ErrorAction Stop
            }
        }
        $AppletIcon.Visible = $False
        $AppletIcon.Dispose()
        $AppletForm.Close()
        Remove-Variable -Name AppletForm -Scope Global
        Remove-Variable -Name AppletIcon -Scope Global
    })


    $AppletForm.ShowDialog() | Out-Null
}

Export-ModuleMember `
  -Function @(
      'Clear-ServerFileDirectory',
      'Disable-Job',
      'Disable-LogRotateJob',
      'Enable-Job',
      'Enable-LogRotateJob',
      'Export-EmacsArchive'
      'Get-EmacsDaemon',
      'Get-EmacsDownload',
      'Get-EmacsExeVersion',
      'Get-FileTypeAssociationsConfig',
      'Get-LatestEmacsDownload',
      'Get-ProcessFromPidFile',
      'Get-ShortcutsConfig',
      'Get-StartMenuItems',
      'Get-StartMenuPath',
      'Get-UnmanagedEmacsDaemon',
      'Get-WShell',
      'Get-Workspace',
      'Install-Emacs',
      'Install-EmacsUserEnvironment',
      'Install-FileTypeAssociations',
      'Install-Shortcuts',
      'Invoke-Applet',
      'Invoke-LogRotate',
      'New-CackledaemonWD',
      'New-Download',
      'New-EmacsArchive',
      'New-ServerFileDirectory',
      'New-Shortcut',
      'New-Version',
      'New-Workspace',
      'Remove-Workspace',
      'Restart-EmacsDaemon',
      'Set-EmacsAppPathRegistryKeys',
      'Set-EmacsPathEnvVariable',
      'Set-HomeEnvVariable',
      'Set-Shortcut',
      'Start-EmacsDaemon',
      'Stop-EmacsDaemon',
      'Test-EmacsExe',
      'Test-ServerFileDirectory',
      'Update-EmacsInstall',
      'Write-Log',
      'Write-LogDebug',
      'Write-LogError',
      'Write-LogHost',
      'Write-LogInformation',
      'Write-LogVerbose',
      'Write-LogWarning',
      'Write-ProcessToPidFile'
  )`
  -Variable @(
      'CackledaemonConfigLocation',
      'CackledaemonWD'
  )

# Copyright 2020 Josh Holbrook
#
# This file is part of Cackledaemon and not a part of Emacs.
#
# Cackledaemon is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Cackledaemon is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Cackledaemon. if not, see <https://www.gnu.org/licenses/>.