| src | ||
| .gitattributes | ||
| .gitignore | ||
| CHANGELOG.md | ||
| LICENSE.md | ||
| README.md | ||
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
- MaksIT Unified Scheduler Service
- Appendix
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
Recommended (using bundled scripts)
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.ps1file pathIsSigned—trueenforces AllSigned,falseruns unrestricted
Processes
Path— executableArgs— command-line argumentsRestartOnFailure— 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:
- Copy template
- Modify
$Config - Implement
Start-BusinessLogic - Add script to
appsettings.json
That’s 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 *