This project has an aim to allow System Administrators and also to who Thinks to be System Administrator to launch Power Shell scripts and console applications as Windows Service.
Go to file
2025-12-06 13:06:47 +01:00
src (feature): migrate to .net10 2025-12-06 13:06:47 +01:00
.gitattributes Add .gitattributes, .gitignore, and LICENSE.txt. 2023-12-27 00:46:37 +01:00
.gitignore Add .gitattributes, .gitignore, and LICENSE.txt. 2023-12-27 00:46:37 +01:00
CHANGELOG.md (feature): migrate to .net10 2025-12-06 13:06:47 +01:00
LICENSE.md (feature): migrate to .net10 2025-12-06 13:06:47 +01:00
README.md (feature): migrate to .net10 2025-12-06 13:06:47 +01:00

MaksIT Unified Scheduler Service

A modern, fully rewritten Windows service built on .NET 10 for scheduling and running PowerShell scripts and console applications. Designed for system administrators — and also for those who feel like system administrators — who need a predictable, resilient, and secure background execution environment.


Table of Contents


Features at a Glance

  • .NET 10 Worker Service clean, robust, stable.
  • Strongly typed configuration via appsettings.json.
  • Run PowerShell scripts & executables concurrently (each in its own thread).
  • Signature enforcement (AllSigned by default).
  • Automatic restart-on-failure for supervised processes.
  • Extensible logging (file + console).
  • Simple Install.cmd / Uninstall.cmd.
  • Reusable scheduling module: SchedulerTemplate.psm1.
  • Thread-isolated architecture — individual failures do not affect others.

Installation

cd /d path\to\src\MaksIT.UScheduler
Install.cmd

To uninstall:

Uninstall.cmd

Manual Installation

sc.exe create "MaksIT.UScheduler Service" binpath="C:\Path\To\MaksIT.UScheduler.exe"
sc.exe start "MaksIT.UScheduler Service"

Manual uninstall:

sc.exe delete "MaksIT.UScheduler Service"

Configuration (appsettings.json)

{
  "Configuration": {
    "ServiceName": "MaksIT.UScheduler",
    "LogDir": "C:\\Logs",

    "Powershell": [
      { "Path": "C:\\Scripts\\MyScript.ps1", "IsSigned": true }
    ],

    "Processes": [
      { "Path": "C:\\Programs\\MyApp.exe", "Args": ["--option"], "RestartOnFailure": true }
    ]
  }
}

PowerShell Scripts

  • Path — full .ps1 file path
  • IsSignedtrue enforces AllSigned, false runs unrestricted

Processes

  • Path — executable
  • Args — command-line arguments
  • RestartOnFailure — restart logic handled by service

How It Works

Each script or process is executed in its own managed thread.

PowerShell Execution Parameters

myCommand.Parameters.Add(new CommandParameter("Automated", true));
myCommand.Parameters.Add(new CommandParameter("CurrentDateTimeUtc", DateTime.UtcNow.ToString("o")));

Inside the script:

param (
    [switch]$Automated,
    [string]$CurrentDateTimeUtc
)

Thread Layout

Unified Scheduler Service
├── PowerShell
│   ├── ScriptA.ps1     Thread
│   ├── ScriptB.ps1     Thread
│   └── ...
└── Processes
    ├── ProgramA.exe     Thread
    ├── ProgramB.exe     Thread
    └── ...

A crash in one thread never stops the service or other components.


Reusable Scheduler Module (SchedulerTemplate.psm1)

This module provides:

  • Scheduling by:

    • Month
    • Weekday
    • Exact time(s)
    • Minimum interval
  • Automatic lock file (no concurrent execution)

  • Last-run file tracking

  • Unified callback execution pattern

  • Logging helpers (Write-Log)

Example usage

param (
    [switch]$Automated,
    [string]$CurrentDateTimeUtc
)

Import-Module "$PSScriptRoot\..\SchedulerTemplate.psm1" -Force

$Config = @{
    RunMonth = @()
    RunWeekday = @()
    RunTime = @("22:52")
    MinIntervalMinutes = 10
}

function Start-BusinessLogic {
     Write-Log "Executing business logic..." -Automated:$Automated
}

Invoke-ScheduledExecution -Config $Config -Automated:$Automated -CurrentDateTimeUtc $CurrentDateTimeUtc -ScriptBlock {
    Start-BusinessLogic
}

Workflow for new scheduled scripts:

  1. Copy template
  2. Modify $Config
  3. Implement Start-BusinessLogic
  4. Add script to appsettings.json

Thats it — the full scheduling engine is reused automatically.


Security

  • Signed scripts required by default.
  • Scripts are auto-unblocked before execution.
  • Unrestricted execution can be enabled if needed (not recommended on production systems).

Logging

  • Console logging
  • File logging under the directory specified by LogDir
  • All events (start, stop, crash, restart, error, skip) are logged

Contact

Maksym Sadovnychyy MAKS-IT, 2025 Email: maksym.sadovnychyy@gmail.com


License

MIT License Copyright (c) 2025 Maksym Sadovnychyy MAKS-IT maksym.sadovnychyy@gmail.com


Appendix

SchedulerTemplate.psm1 (Full Source)

# ======================================================================
# SchedulerTemplate.psm1 - Scheduling + Lock + Interval + Callback Runner
# ======================================================================

function Write-Log {
    param(
        [string]$Message,
        [switch]$Automated,
        [string]$Color = 'White'
    )

    if ($Automated) {
        Write-Output $Message
    }
    else {
        Write-Host $Message -ForegroundColor $Color
    }
}

function Get-CurrentUtcDateTime {
    param([string]$ExternalDateTime, [switch]$Automated)

    if ($ExternalDateTime) {
        try {
            return [datetime]::Parse($ExternalDateTime).ToUniversalTime()
        }
        catch {
            try {
                return [datetime]::ParseExact($ExternalDateTime, 'dd/MM/yyyy HH:mm:ss', $null).ToUniversalTime()
            }
            catch {
                Write-Log "Failed to parse CurrentDateTimeUtc ('$ExternalDateTime'). Using system time (UTC)." -Automated:$Automated -Color 'Red'
                return (Get-Date).ToUniversalTime()
            }
        }
    }
    return (Get-Date).ToUniversalTime()
}

function Test-ScheduleMonth { param([datetime]$DateTime, [array]$Months)
    $name = $DateTime.ToString('MMMM')
    return ($Months.Count -eq 0) -or ($Months -contains $name)
}
function Test-ScheduleWeekday { param([datetime]$DateTime, [array]$Weekdays)
    $name = $DateTime.DayOfWeek.ToString()
    return ($Weekdays.Count -eq 0) -or ($Weekdays -contains $name)
}
function Test-ScheduleTime { param([datetime]$DateTime, [array]$Times)
    $t = $DateTime.ToString('HH:mm')
    return ($Times.Count -eq 0) -or ($Times -contains $t)
}

function Test-Schedule {
    param(
        [datetime]$DateTime,
        [array]$RunMonth,
        [array]$RunWeekday,
        [array]$RunTime
    )

    return (Test-ScheduleMonth -DateTime $DateTime -Months $RunMonth) -and
           (Test-ScheduleWeekday -DateTime $DateTime -Weekdays $RunWeekday) -and
           (Test-ScheduleTime -DateTime $DateTime -Times $RunTime)
}

function Test-Interval {
    param([datetime]$LastRun,[datetime]$Now,[int]$MinIntervalMinutes)
    return $Now -ge $LastRun.AddMinutes($MinIntervalMinutes)
}

function Test-ScheduledExecution {
    param(
        [switch]$Automated,
        [string]$CurrentDateTimeUtc,
        [hashtable]$Config,
        [string]$LastRunFilePath
    )

    $now = Get-CurrentUtcDateTime -ExternalDateTime $CurrentDateTimeUtc -Automated:$Automated
    $shouldRun = $true

    if ($Automated) {
        Write-Log "Automated: $Automated" -Automated:$Automated -Color 'Green'
        Write-Log "Current UTC Time: $now" -Automated:$Automated -Color 'Green'

        if (-not (Test-Schedule -DateTime $now -RunMonth $Config.RunMonth -RunWeekday $Config.RunWeekday -RunTime $Config.RunTime)) {
            Write-Log "Execution skipped due to schedule." -Automated:$Automated -Color 'Yellow'
            $shouldRun = $false
        }
    }

    if ($shouldRun -and $LastRunFilePath -and (Test-Path $LastRunFilePath)) {
        $lastRun = Get-Content $LastRunFilePath | Select-Object -First 1
        if ($lastRun) {
            [datetime]$lr = $lastRun
            if (-not (Test-Interval -LastRun $lr -Now $now -MinIntervalMinutes $Config.MinIntervalMinutes)) {
                Write-Log "Last run at $lr. Interval not reached." -Automated:$Automated -Color 'Yellow'
                $shouldRun = $false
            }
        }
    }

    return @{
        ShouldExecute = $shouldRun
        Now = $now
    }
}

function New-LockGuard {
    param([string]$LockFile,[switch]$Automated)

    if (Test-Path $LockFile) {
        Write-Log "Guard: Lock file exists ($LockFile). Skipping." -Automated:$Automated -Color 'Red'
        return $false
    }
    try {
        New-Item -Path $LockFile -ItemType File -Force | Out-Null
        return $true
    }
    catch {
        Write-Log "Guard: Cannot create lock file ($LockFile)." -Automated:$Automated -Color 'Red'
        return $false
    }
}

function Remove-LockGuard {
    param([string]$LockFile,[switch]$Automated)
    if (Test-Path $LockFile) {
        Remove-Item $LockFile -Force
        Write-Log "Lock removed: $LockFile" -Automated:$Automated -Color 'Cyan'
    }
}

# ======================================================================
# Main unified executor (callback-based)
# ======================================================================
function Invoke-ScheduledExecution {
    param(
        [scriptblock]$ScriptBlock,
        [hashtable]$Config,
        [switch]$Automated,
        [string]$CurrentDateTimeUtc
    )

    $scriptPath  = $MyInvocation.ScriptName
    $lastRunFile = [IO.Path]::ChangeExtension($scriptPath, ".lastRun")
    $lockFile    = [IO.Path]::ChangeExtension($scriptPath, ".lock")

    # Check schedule
    $schedule = Test-ScheduledExecution -Automated:$Automated -CurrentDateTimeUtc $CurrentDateTimeUtc -Config $Config -LastRunFilePath $lastRunFile
    if (-not $schedule.ShouldExecute) {
        Write-Log "Execution skipped." -Automated:$Automated -Color 'Yellow'
        return
    }

    # Lock
    if (-not (New-LockGuard -LockFile $lockFile -Automated:$Automated)) {
        return
    }

    try {
        $schedule.Now.ToString("o") | Set-Content $lastRunFile
        & $ScriptBlock
    }
    finally {
        Remove-LockGuard -LockFile $lockFile -Automated:$Automated
    }
}

Export-ModuleMember -Function * -Alias *