Cackledaemon.psm1

# Copyright 2020 Josh Holbrook
#
# This file is part of Cackledaemon and 100% definitely 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/>.

Set-Alias Invoke-CDInstallWizard (Join-Path $PSScriptRoot 'InstallWizard.ps1')

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

function New-CackledaemonWD {
    param(
        [switch]$NoShortcuts
    )
    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')
    }
}

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 {
  $ShortcutsPath = Join-Path $CackledaemonWD 'Shortcuts.csv'

  if (Test-Path $ShortcutsPath) {
    Import-Csv -Path $ShortcutsPath | ForEach-Object {
      New-Object ShortcutRecord $_.ShortcutName, $_.EmacsBinaryName, ($_.ArgumentList | ConvertFrom-Json), $_.Description
    }
  }
}

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-CDWarning ('{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-CDWarning ("{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-CDError $_
  }
}

function New-CDLogRecord {
  [CmdletBinding()]
  param(
    [string]$Level = 'Info',
    [object]$MessageData,
    [string[]]$Tags = @()
  )

  if (-not @('Debug','Info','Warning','Error','Fatal').Contains($Level)) {
    Write-Warning "New-CDLogRecord called with unrecognized level $Level"
    $Level = 'Warning'
  }

  $WriteInformation = Get-Command 'Write-Information' -CommandType Cmdlet

  & $WriteInformation $MessageData (@($Level) + $Tags) 6>&1
}

function Write-CDLog {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
    [System.Management.Automation.InformationRecord]$InformationRecord
  )

  try {
    . $CackledaemonConfigLocation
  } catch {
    Write-Warning 'Unable to load Cackledaemon configuration! Unable to write to log file.'
    return
  }

  if ($InformationRecord) {
    $Timestamp = (Get-Date -Date $InformationRecord.TimeGenerated -Format o)

    $InformationRecord.MessageData | Out-String | ForEach-Object {
      if ($_) {
        $Line = '{0} [{1}] {2}' -f $Timestamp,($InformationRecord.Tags -join ':'),$_
        Add-Content $CackledaemonLogFile -Value $Line
      }
    }
  }
}

function Write-CDDebug {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
    [string]$Message,
    [string[]]$Tags = @()
  )

  New-CDLogRecord 'Debug' $Message $Tags | Write-CDLog
  Write-Debug $Message
}

function Write-CDInfo {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
    [object]$MessageData,
    [string[]]$Tags = @()
  )

  New-CDLogRecord 'Info' $MessageData $Tags | Write-CDLog
  Write-Information $MessageData $Tags
}

function Write-CDWarning {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
    [string]$Message,
    [string[]]$Tags = @()
  )
  New-CDLogRecord 'Warning' $Message $Tags | Write-CDLog
  Write-Warning $Message
}

function Write-CDError {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
    [System.Management.Automation.ErrorRecord]$ErrorRecord,
    [string[]]$Tags = @()
  )

  New-CDLogRecord 'Error' $ErrorRecord $Tags | Write-CDLog
  $PSCmdlet.WriteError($ErrorRecord)
}

function Write-CDFatal {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
    [System.Management.Automation.ErrorRecord]$ErrorRecord,
    [string[]]$Tags = @()
  )
  New-CDLogRecord 'Fatal' $ErrorRecord $Tags | Write-CDLog
  $PSCmdlet.ThrowTerminatingError($ErrorRecord)
}

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

  . $CackledaemonConfigLocation

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

    if ((Test-Path $LogFile) -and (Get-Item $LogFile).Length -ge $LogSize) {
      Write-CDInfo ('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-CDInfo ('Copying {0} to {1}...' -f $Current, $Next)

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

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

      Clear-Content $LogFile

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

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

        Remove-Item $StaleLogFile
      }

      Write-CDInfo 'Done.'
    }
  }
}

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

  Enable-Job 'CDLogRotateJob' {
    . $CackledaemonConfigLocation

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

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

  Disable-Job 'CDLogRotateJob'
}

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(
    [int]$Major,
    [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)
  }
}

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 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-CDWarning ('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-CDInfo '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 {
      $Exception = New-Object Exception ("{0} doesn't exist - refusing to write this to the registry." -f $BinPath)
      $ErrorRecord = New-Object System.Management.Automation.ErrorRecord $Exception,'ItemNotFoundException','ObjectNotFound',$BinPath
      Write-CDError $ErrorRecord
    }
  }
}

function Get-StartMenuItems {
  . $CackledaemonConfigLocation

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

function Get-WShell {
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars','',Justification='Using a global singleton for a WShell instance is appropriate')]
  param()

  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-CDShortcuts {
  . $CackledaemonConfigLocation

  $Config = Get-ShortcutsConfig

  if ($Config) {
    if (-not (Test-Path $StartMenuPath)) {
      New-Item -Type Directory $StartMenuPath
    }

    $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 Get-OpenEmacsProcesses {
    . $CackledaemonConfigLocation

    # This escape isn't thoroughly researched and may be brittle
    $EmacsSearchQuery = "${EmacsInstallLocation}%" -replace '\\', '\\'

    return Get-CimInstance -Query "
      SELECT
        *
      FROM Win32_Process WHERE ExecutablePath LIKE '${EmacsSearchQuery}'
    "
 | ForEach-Object { Get-Process -Id $_.ProcessId }
}

function Wait-ForEmacsProcessExit {
  [CmdletBinding()]
  param(
    [int]$PollingInterval = 5,  # 1 Second
    [int]$Timeout = 60  # 1 minute
  )

  $StartTime = Get-Date

  $OpenProcesses = Get-OpenEmacsProcesses

  while ($OpenProcesses) {
    if (((Get-Date) - $StartTime).TotalSeconds -gt $Timeout) {
      $Exception = New-Object Exception "Emacs processes are still open after $Timeout seconds!"
      $ErrorRecord = New-Object System.Management.Automation.ErrorRecord $Exception,'OpenEmacsProcessesException','LimitsExceeded',$null
      Write-CDFatal $ErrorRecord
    }

    Write-CDInfo "The following Emacs processes are open:"

    $OpenProcesses | Out-String | Write-CDInfo

    Write-CDInfo "Please close these processes to continue."
    Write-CDInfo ""

    Start-Sleep -Seconds $PollingInterval

    $OpenProcesses = Get-OpenEmacsProcesses
  }
}

function Invoke-PostInstallHook {
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression','',Justification = 'Who am I to stop the user from using eval?')]
  param()

  . $CackledaemonConfigLocation

  if ($PostInstallHook -is [string]) {
    Invoke-Expression $PostInstallHook
  }
  if ($PostInstallHook -is [scriptblock]) {
    & $PostInstallHook
  }
}

function Install-Emacs {
  param(
    [switch]$Force
  )

  $ErrorActionPreference = 'Stop'

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

  . $CackledaemonConfigLocation

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

    $Workspace = New-Workspace
  }

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

  $LatestDownload = Get-LatestEmacsDownload

  Write-CDInfo ('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 27

  if ($Force) {
    Write-CDInfo "`-Force switch was enabled so installing Emacs regardless of what's installed"

    $ShouldInstall = $True
  } else {
    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-CDInfo ('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 67

      if ($LatestDownload.Version -gt $InstalledVersion) {
        Write-CDInfo ('Upstream Emacs version {0} is newer than installed Emacs version {1}' -f $LatestDownload.Version, $InstalledVersion)
        $ShouldInstall = $True
      } else {
        Write-CDInfo ('Upstream Emacs version {0} is no newer than installed Emacs version {1}' -f $LatestDownload.Version, $InstalledVersion)
      }
    } else {
      Write-CDInfo '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-CDInfo 'Emacs is currently installed and at the latest available version.'
  } else {
    $TargetVersion = $LatestDownload.Version

    if ($Workspace.TestInstall($TargetVersion)) {
      Write-CDInfo "Emacs has already been downloaded and unpacked for version $TargetVersion"

      $Install = $Workspace.GetInstall($TargetVersion)
    } else {
      if ($Workspace.TestArchive($TargetVersion)) {
        Write-CDInfo "Emacs 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 36
        Write-CDInfo "Downloading Emacs version $TargetVersion..."

        $Archive = New-EmacsArchive $LatestDownload
      }

      Write-Progress -Id 1 -Activity 'Installing Emacs' -CurrentOperation "Unpacking Emacs version $TargetVersion..." -PercentComplete 45
      Write-CDInfo "Unpacking Emacs version $TargetVersion..."

      $Install = Export-EmacsArchive $Archive
    }

    Write-Progress -Id 1 -Activity 'Installing Emacs' -CurrentOperation "Waiting for Emacs to exit..."
    Write-CDInfo 'In order to install Emacs, all Emacs processes must be closed.'

    Wait-ForEmacsProcessExit

    Write-Progress -Id 1 -Activity 'Installing Emacs' -CurrentOperation 'Elevating privileges...' -PercentComplete 55
    Write-CDInfo "Elevating privileges..."

    $ModulePath = Join-Path $PSScriptRoot 'Cackledaemon.psd1'

    Invoke-AsAdministrator "
      `$ErrorActionPreference = 'Stop'
 
      Import-Module `"$ModulePath`"
 
      Write-Progress -Id 1 -Activity 'Installing Emacs' -CurrentOperation 'Updating Emacs...' -PercentComplete 63
      Write-CDInfo 'Updating Emacs...'
      Update-EmacsInstall -Path $Install | Out-Null
 
      Write-Progress -Id 1 -Activity 'Installing Emacs' -CurrentOperation 'Setting app path registry keys...' -PercentComplete 73
      Write-CDInfo 'Setting app path registry keys...'
      Set-EmacsAppPathRegistryKeys | Out-Null
 
      Write-CDInfo 'Emacs $TargetVersion is installed and ready to rock!'
    "


    Write-Progress -Id 1 -Activity 'Installing Emacs' -CurrentOperation 'Running post-install hook...' -PercentComplete 91
    Write-CDInfo "Running post-install hook..."
    Invoke-PostInstallHook

    Write-Progress -Id 1 -Activity 'Installing Emacs' -Completed
  }
}

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-CDInfo "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-CDInfo "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-CDInfo "Installing shortcuts..."

  Install-CDShortcuts

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

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)

  if ($Id) {
    $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) {
    $Exception = New-Object Exception 'The Emacs daemon is already running and being managed.'
    $ErrorRecord = New-Object System.Management.Automation.ErrorRecord $Exception,'ManagedResourceExistsException','ResourceExists',$null
    Write-CDError $ErrorRecord
  } elseif (Get-UnmanagedEmacsDaemon) {
    $Exception = New-Object Exception 'An unmanaged Emacs daemon is running.'
    $ErrorRecord = New-Object System.Management.Automation.ErrorRecord $Exception,'UnmanagedResourceExistsException','ResourceExists',$null
    Write-CDError $ErrorRecord
  } else {
    Write-CDInfo 'Starting the Emacs daemon...'

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

    Write-ProcessToPidFile $Process

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

    Write-CDInfo 'Done.'

    return $Process
  }
}

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

  Get-ProcessFromPidFile
}

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

  $Process = Get-ProcessFromPidFile

  if (-not $Process) {
    $Exception = New-Object Exception "A managed Emacs daemon isn't running and can not be stopped."
    $ErrorRecord = New-Object System.Management.Automation.ErrorRecord $Exception,'UnmanagedResourceUnavailableException','ResourceUnavailable',$null
    Write-CDError $ErrorRecord
  } else {
    Write-CDInfo 'Stopping the Emacs daemon...'

    Stop-Process -InputObject $Process

    Write-ProcessToPidFile $null

    Write-CDInfo 'Done.'
  }
}

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

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

  Start-EmacsDaemon
}

Add-Type -AssemblyName System.Windows.Forms

function Invoke-CDApplet {
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars','',Justification='Using Windows Forms necessitates global variables')]
  [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

function Start-InstrumentedBlock {
  param(
    [string]$Message,
    [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-CDError $_

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

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

$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

$OnIconClick = {
  $Process = Get-ProcessFromPidFile
  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 'CDLogRotateJob' -ErrorAction SilentlyContinue

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

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

    $LogRotateStatusItem.Text = ('[{0}] Logs Rotation' -f $State)
    $EnableLogRotateItem.Enabled = $False
    $DisableLogRotateItem.Enabled = $True
  } else {
    $LogRotateStatusItem.Text = '[DISABLED] Logs Rotation'
    $EnableLogRotateItem.Enabled = $True
    $DisableLogRotateItem.Enabled = $False
  }
}
$AppletIcon.add_MouseDown($OnIconClick)

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

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

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

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

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

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

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

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

$InstallWizardItem = New-Object System.Windows.Forms.MenuItem
$InstallWizardItem.Text = 'Check for updates...'
$OnInstallWizardClick = {
  Start-InstrumentedBlock 'Failed to launch install wizard' {
    Start-Process powershell.exe -ArgumentList @(
      '-NoExit',
      '-Command', (Join-Path $PSScriptRoot 'InstallWizard.ps1')
    )
  }
}
$InstallWizardItem.add_Click($OnInstallWizardClick)
$ContextMenu.MenuItems.Add($InstallWizardItem) | Out-Null

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

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

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

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

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

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

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

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

$AppletForm.ShowDialog() | Out-Null
}

function Install-CDApplet {
  . $CackledaemonConfigLocation

  if (-not (Test-Path $StartMenuPath)) {
    New-Item -Type Directory $StartMenuPath
  }

  $StartupPath = Join-Path $Env:AppData 'Microsoft\Windows\Start Menu\Programs\Startup'

  @($StartMenuPath, $StartupPath) | ForEach-Object {
    Set-Shortcut `
      -ShortcutPath (Join-Path $_ ("Cackledaemon.lnk")) `
      -TargetPath (Join-Path $PSScriptRoot "Applet.vbs") `
      -Description "Launch the Cackledaemon applet"
  }
}

$AliasesToExport = @('Invoke-CDInstallWizard')

$FunctionsToExport = @(
  'Clear-ServerFileDirectory',
  'Disable-Job',
  'Disable-CDLogRotateJob',
  'Enable-Job',
  'Enable-CDLogRotateJob',
  'Export-EmacsArchive'
  'Get-EmacsDaemon',
  'Get-EmacsDownload',
  'Get-EmacsExeVersion',
  'Get-FileTypeAssociationsConfig',
  'Get-LatestEmacsDownload',
  'Get-OpenEmacsProcesses',
  'Get-ProcessFromPidFile',
  'Get-ShortcutsConfig',
  'Get-StartMenuItems',
  'Get-StartMenuPath',
  'Get-UnmanagedEmacsDaemon',
  'Get-WShell',
  'Get-Workspace',
  'Install-CDApplet',
  'Install-Emacs',
  'Install-EmacsUserEnvironment',
  'Install-FileTypeAssociations',
  'Install-CDShortcuts',
  'Invoke-CDApplet',
  'Invoke-LogRotate',
  'Invoke-PostInstallHook',
  'New-CackledaemonWD',
  'New-CDLogRecord',
  'New-Download',
  'New-EmacsArchive',
  'New-ServerFileDirectory',
  'New-Shortcut',
  'New-Version',
  'New-Workspace',
  'Restart-EmacsDaemon',
  'Set-EmacsAppPathRegistryKeys',
  'Set-EmacsPathEnvVariable',
  'Set-HomeEnvVariable',
  'Set-Shortcut',
  'Start-EmacsDaemon',
  'Stop-EmacsDaemon',
  'Test-EmacsExe',
  'Test-ServerFileDirectory',
  'Update-EmacsInstall',
  'Write-CDDebug',
  'Write-CDError',
  'Write-CDFatal',
  'Write-CDInfo',
  'Write-CDLog',
  'Write-CDWarning',
  'Write-ProcessToPidFile'
)

$VariablesToExport = @(
  'CackledaemonConfigLocation',
  'CackledaemonWD'
)

Export-ModuleMember `
  -Alias $AliasesToExport `
  -Function $FunctionsToExport `
  -Variable $VariablesToExport