Cackledaemon.psm1

$CackledaemonWD = Join-Path $env:APPDATA 'cackledaemon'

function New-CackledaemonWD {
    New-Item -Path $CackledaemonWD -ItemType directory
}

function Enable-CackledaemonJob {
    [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-CackledaemonJob -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-CackledaemonJob' `
          -CategoryReason 'UnstoppableJobException'
    } else {
        Start-Job `
          -Name $Name `
          -InitializationScript {
              Import-Module Cackledaemon
          } `
          -ScriptBlock $ScriptBlock
    }
}

function Disable-CackledaemonJob {
    [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)
    }

    try {
        Stop-Job -Name $Name -ErrorAction Stop
        Remove-Job -Name $Name
    } 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
    }
}

$CackledaemonLogFile = Join-Path $CackledaemonWD 'Cackledaemon.log'
$EmacsStdOutLogFile = Join-Path $CackledaemonWD 'EmacsStdout.log'
$EmacsStdErrLogFile = Join-Path $CackledaemonWD 'EmacsStderr.log'
$LogSize = 1mb
$LogRotate = 4
$LogCheckTime = 2  # Seconds

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

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

    if (
        (-not @(
            'NotSpecified',
            'OpenError',
            'CloseError',
            'DeviceError',
            'DeadlockDetected',
            'InvalidArgument',
            'InvalidData',
            'InvalidOperation',
            'OperationTimeout',
            'SyntaxError',
            'ParserError',
            'PermissionDenied',
            'ResourceBusy',
            'ResourceExists',
            'ResourceUnavailable',
            'ReadError',
            'WriteError',
            'FromStdErr',
            'SecurityError',
            'ProtocolError',
            'ConnectionError',
            'AuthenticationError',
            'LimitsExceeded',
            'QuotaExceeded',
            'NotEnabled'
        ).Contains($Category)) -and ($Level -eq 'Error')
    ) {
        Write-Warning ('Write-Log called with unrecognized error category "{0}"' -f $Category)
        $Category = 'NotSpecified'
    }

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

    Add-Content $CackledaemonLogFile -value $Line

    if ($Level -eq 'Debug') {
        Write-Debug $Message
    } elseif ($Level -eq 'Verbose') {
        Write-Verbose $Message
    } elseif ($Level -eq 'Warning') {
        Write-Warning $Message
    } elseif ($Level -eq 'Error') {
        if ($Exception) {
            $Message = ('{0} (Exception: {1})' -f $Message, $Exception)
        }
       
        Write-Error -Message $Message `
          -Exception $Exception `
          -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-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 {
    [Parameter(Position=0)]
    param([string]$Message)
    [string]$Exception,
    [string]$Category = 'NotSpecified',
    [string]$CategoryActivity,
    [string]$CategoryReason,
    [string]$CategoryTargetName,
    [string]$CategoryTargetType

    Write-Log -Level Error `
      -Message $Message `
      -Exception $Exception `
      -Category $Category `
      -CategoryActivity $CategoryActivity `
      -CategoryReason $CategoryReason `
      -CategoryTargetName $CategoryTargetName `
      -CategoryTargetType $CategoryTargetType
}

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

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

            if ((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-Log ('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 ($LogRotate + 1)

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

                    Remove-Item $StaleLogFile
                }

                Write-LogVerbose 'Done.'
            }
        }
        Write-LogDebug ('No need to rotate logs. Sleeping for {0} seconds.' -f $LogCheckTime)
        Start-Sleep -Seconds $LogCheckTime
    }
}

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

    Enable-CackledaemonJob 'LogRotateJob' { Invoke-LogRotate }
}

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

    Disable-CackledaemonJob 'LogRotateJob'
}

$PidFile = Join-Path $CackledaemonWD 'DaemonPidFile.json'

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

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

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

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

    if (-not $Id) {
        Remove-Item $PidFile
        return $null
    }

    return Get-Process -Id $Id -ErrorAction SilentlyContinue
}

function Get-UnmanagedEmacsDaemons () {
    $ManagedProcess = $(Get-EmacsProcessFromPidFile)
    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)

    $Process = $(Get-EmacsProcessFromPidFile)

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

    } elseif ($(Get-UnmanagedEmacsDaemons)) {
        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-EmacsProcessToPidFile $Process

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

        Write-Verbose 'Done.'

        return $Process
    }
}

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

    Get-EmacsProcessFromPidFile
}

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

    $Process = Get-EmacsProcessFromPidFile

    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-EmacsProcessToPidFile $null

        Write-LogVerbose 'Done.'
    }
}

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

    $NotifyTimeout = 5000

    # 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 {
            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
        } else {
            $DaemonStatusItem.Text = '[STOPPED] Emacs Daemon'
            $StartDaemonItem.Enabled = $True
            $StopDaemonItem.Enabled = $False
        }

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

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

            if ($State -eq 'RUNNING') {
                $State = 'ENABLED'
                $EnableLogRotateJobItem.Enabled = $False
                $DisableLogRotateJobItem.Enabled = $True
            } else {
                $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

    $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 initialize Cackledaemon' {
            Start-EmacsDaemon -ErrorAction Stop
            Enable-LogRotateJob -ErrorAction Stop
        }
    })

    $ExitItem.add_Click({
        Start-InstrumentedBlock 'Failed to gracefully shut down Cackledaemon' {
            Stop-EmacsDaemon -ErrorAction Stop
            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 @(
      'Invoke-LogRotate',
      'Enable-LogRotateJob',
      'Disable-LogRotateJob',
      'Start-EmacsDaemon',
      'Get-EmacsDaemon',
      'Stop-EmacsDaemon',
      'Restart-EmacsDaemon',
      'Get-UnmanagedEmacsDaemons',
      'Invoke-Applet'
  ) `
  -Variable @(
      'CackledaemonWD'
  )

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