(feature): 1.0.1 init

This commit is contained in:
Maksym Sadovnychyy 2026-02-08 12:06:13 +01:00
parent 986eea321e
commit 5805664c3e
77 changed files with 6202 additions and 457 deletions

View File

@ -1,5 +1,44 @@
# MaksIT.UScheduler Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.0.1 - 2026-02-02
### Added
- **CLI service management**: Added command-line arguments for service installation and management
- `--install`, `-i`: Install the Windows service
- `--uninstall`, `-u`: Uninstall the Windows service
- `--start`: Start the service
- `--stop`: Stop the service
- `--status`: Query service status
- `--help`, `-h`: Show help message
- **Relative path support**: Script and process paths can now be relative to the application directory
- **OS guard**: Application now checks for Windows at startup and exits with error on unsupported platforms
- **Release script enhancement**: Example scripts are now automatically added to `appsettings.json` in disabled state during release build
- **Unit tests**: Added comprehensive test project `MaksIT.UScheduler.Tests` with tests for background services and configuration
### Changed
- Default value for `IsSigned` in PowerShell script configuration changed from `false` to `true` for improved security
- PSScriptService now implements `IDisposable` for proper RunspacePool cleanup
- Method signatures updated: `RunScript``RunScriptAsync`, `RunProcess``RunProcessAsync`
- Service is now installed with `start=auto` for automatic startup on boot
- Updated package dependencies:
- MaksIT.Core: 1.6.0 → 1.6.1
- Microsoft.Extensions.Hosting: 10.0.0 → 10.0.2
- Microsoft.Extensions.Hosting.WindowsServices: 10.0.0 → 10.0.2
- System.Diagnostics.PerformanceCounter: 10.0.0 → 10.0.2
### Removed
- `Install.cmd` and `Uninstall.cmd` files (replaced by CLI arguments)
### Fixed
- **Parallel execution**: Restored parallel execution of PowerShell scripts and processes (broken during .NET Framework to .NET migration)
- PSScriptService now uses RunspacePool (up to CPU core count) for concurrent script execution
- Background services use `Task.WhenAll` to launch all tasks simultaneously
## v1.0.0 - 2025-12-06
### Major Changes
@ -13,7 +52,7 @@
- `ProcessBackgroundService` for process management.
- Enhanced PowerShell script execution with signature validation and script unblocking.
- Improved process management with restart-on-failure logic.
- Updated install/uninstall scripts (`Install.cmd`, `Uninstall.cmd`) for service management.
- Added install/uninstall scripts (`Install.cmd`, `Uninstall.cmd`) for service management (removed in v1.0.1).
- Added comprehensive README with usage, configuration, and scheduling examples.
- MIT License included.
@ -21,3 +60,27 @@
- Old solution, project, and service files removed.
- Configuration format and service naming conventions updated.
- Scheduling logic for console applications is not yet implemented (runs every 10 seconds).
<!--
Template for new releases:
## v1.x.x - YYYY-MM-DD
### Added
- New features
### Changed
- Changes in existing functionality
### Deprecated
- Soon-to-be removed features
### Removed
- Removed features
### Fixed
- Bug fixes
### Security
- Security improvements
-->

236
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,236 @@
# Contributing to MaksIT.UScheduler
Thank you for your interest in contributing to MaksIT.UScheduler!
## Table of Contents
- [Contributing to MaksIT.UScheduler](#contributing-to-maksituscheduler)
- [Table of Contents](#table-of-contents)
- [Development Setup](#development-setup)
- [Branch Strategy](#branch-strategy)
- [Making Changes](#making-changes)
- [Versioning](#versioning)
- [Release Process](#release-process)
- [Prerequisites](#prerequisites)
- [Version Files](#version-files)
- [Branch-Based Release Behavior](#branch-based-release-behavior)
- [Development Build Workflow](#development-build-workflow)
- [Production Release Workflow](#production-release-workflow)
- [Release Script Details](#release-script-details)
- [Changelog Guidelines](#changelog-guidelines)
- [AI-Powered Changelog Generation (Optional)](#ai-powered-changelog-generation-optional)
---
## Development Setup
1. Clone the repository:
```bash
git clone https://github.com/MaksIT/uscheduler.git
cd uscheduler
```
2. Open the solution in Visual Studio or your preferred IDE:
```
src/MaksIT.UScheduler/MaksIT.UScheduler.sln
```
3. Build the project:
```bash
dotnet build src/MaksIT.UScheduler/MaksIT.UScheduler.csproj
```
---
## Branch Strategy
- `main` - Production-ready code
- `dev` - Active development branch
- Feature branches - Created from `dev` for specific features
---
## Making Changes
1. Create a feature branch from `dev`
2. Make your changes
3. Update `CHANGELOG.md` with your changes
4. Update version in `.csproj` if needed
5. Test your changes locally using dev tags
6. Submit a pull request to `dev`
---
## Versioning
This project follows [Semantic Versioning](https://semver.org/):
- **MAJOR** - Incompatible API changes
- **MINOR** - New functionality (backwards compatible)
- **PATCH** - Bug fixes (backwards compatible)
Version format: `X.Y.Z` (e.g., `1.0.1`)
---
## Release Process
### Prerequisites
- .NET SDK installed
- Git CLI
- GitHub CLI (`gh`) - required only for production releases
- GitHub token set in environment variable (configured in `scriptsettings.json`)
### Version Files
Before creating a release, ensure version consistency across:
1. **`.csproj`** - Update `<Version>` element:
```xml
<Version>1.0.1</Version>
```
2. **`CHANGELOG.md`** - Add version entry at the top:
```markdown
## v1.0.1
### Added
- New feature description
### Fixed
- Bug fix description
```
### Branch-Based Release Behavior
The release script behavior is controlled by the current branch (configurable in `scriptsettings.json`):
| Branch | Tag Required | Uncommitted Changes | Behavior |
|--------|--------------|---------------------|----------|
| Dev (`dev`) | No | Allowed | Local build only (version from .csproj) |
| Release (`main`) | Yes | Not allowed | Full release to GitHub |
| Other | - | - | Blocked |
Branch names can be customized in `scriptsettings.json`:
```json
"branches": {
"release": "main",
"dev": "dev"
}
```
### Development Build Workflow
Test builds on the `dev` branch - no tag needed:
```bash
# 1. On dev branch: Update version in .csproj and CHANGELOG.md
git checkout dev
# 2. Commit your changes
git add .
git commit -m "Prepare v1.0.1 release"
# 3. Run the release script (no tag needed!)
cd src/scripts/Release-ToGitHub
.\Release-ToGitHub.ps1
# Output: DEV BUILD COMPLETE
# Creates: release/maksit.uscheduler-1.0.1.zip (local only)
```
### Production Release Workflow
When ready to publish, merge to `main`, create tag, and run:
```bash
# 1. Merge to main
git checkout main
git merge dev
# 2. Create tag (required on main)
git tag v1.0.1
# 3. Run the release script
cd src/scripts/Release-ToGitHub
.\Release-ToGitHub.ps1
# Output: RELEASE COMPLETE
# Creates: release/maksit.uscheduler-1.0.1.zip
# Pushes tag to GitHub
# Creates GitHub release with assets
```
### Release Script Details
The `Release-ToGitHub.ps1` script performs these steps:
**Pre-flight checks:**
- Detects current branch (`main` or `dev`)
- On `main`: requires clean working directory; on `dev`: uncommitted changes allowed
- Reads version from `.csproj` (source of truth)
- On `main`: requires tag matching the version
- Ensures `CHANGELOG.md` has matching version entry
- Checks GitHub CLI authentication (main branch only)
**Build process:**
- Publishes .NET project in Release configuration
- Copies `Scripts` folder into the release
- Creates versioned ZIP archive
- Extracts release notes from `CHANGELOG.md`
**GitHub release (main branch only):**
- Pushes tag to remote if not present
- Creates (or recreates) GitHub release with assets
**Configuration:**
The script reads settings from `scriptsettings.json`:
```json
{
"github": {
"tokenEnvVar": "GITHUB_MAKS_IT_COM"
},
"paths": {
"csprojPath": "..\\..\\MaksIT.UScheduler\\MaksIT.UScheduler.csproj",
"changelogPath": "..\\..\\..\\CHANGELOG.md",
"releaseDir": "..\\..\\release"
},
"release": {
"zipNamePattern": "maksit.uscheduler-{version}.zip",
"releaseTitlePattern": "Release {version}"
}
}
```
---
## Changelog Guidelines
Follow [Keep a Changelog](https://keepachangelog.com/) format:
```markdown
## v1.0.1
### Added
- New features
### Changed
- Changes to existing functionality
### Deprecated
- Features to be removed in future
### Removed
- Removed features
### Fixed
- Bug fixes
### Security
- Security-related changes
```
---

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 Maksym Sadovnychyy (Maks-IT)
Copyright (c) 2017 - 2026 Maksym Sadovnychyy (Maks-IT)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

258
README.md
View File

@ -1,7 +1,7 @@
# MaksIT Unified Scheduler Service
![Line Coverage](badges/coverage-lines.svg) ![Branch Coverage](badges/coverage-branches.svg) ![Method Coverage](badges/coverage-methods.svg)
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.
@ -14,38 +14,52 @@ Designed for system administrators — and also for those who *feel like* system
- [Scripts Examples](#scripts-examples)
- [Features at a Glance](#features-at-a-glance)
- [Installation](#installation)
- [Recommended (using bundled scripts)](#recommended-using-bundled-scripts)
- [Manual Installation](#manual-installation)
- [Using CLI Commands](#using-cli-commands)
- [Using sc.exe](#using-scexe)
- [Configuration (`appsettings.json`)](#configuration-appsettingsjson)
- [Path Resolution](#path-resolution)
- [Log Levels](#log-levels)
- [PowerShell Scripts](#powershell-scripts)
- [Processes](#processes)
- [How It Works](#how-it-works)
- [PowerShell Execution Parameters](#powershell-execution-parameters)
- [Thread Layout](#thread-layout)
- [Execution Model](#execution-model)
- [Reusable Scheduler Module (`SchedulerTemplate.psm1`)](#reusable-scheduler-module-schedulertemplatepsm1)
- [Exported Functions](#exported-functions)
- [Module Version](#module-version)
- [Example usage](#example-usage)
- [Security](#security)
- [Logging](#logging)
- [Testing](#testing)
- [Running Tests](#running-tests)
- [Code Coverage](#code-coverage)
- [Test Structure](#test-structure)
- [Contact](#contact)
- [License](#license)
## Scripts Examples
- [Hyper-V Backup](./examples/HyperV-Backup/README.md) - Production-ready Hyper-V VM backup solution with scheduling and retention management
- [Native-Sync](./examples/Native-Sync/README.md) - Production-ready file synchronization solution using pure PowerShell with no external dependencies
- [File-Sync](./examples/File-Sync/README.md) - [FreeFileSync](https://freefilesync.org/) batch job execution
- [Scheduler Template Module](./examples/SchedulerTemplate.psm1)
> **Note:** These examples are **bundled with the release** and included in the default `appsettings.json`, but are **disabled by default**. To enable an example, set `"Disabled": false` in the configuration.
- [Hyper-V Backup](./src/Scripts/HyperV-Backup/README.md) - Production-ready Hyper-V VM backup solution with scheduling and retention management
- [Native-Sync](./src/Scripts/Native-Sync/README.md) - Production-ready file synchronization solution using pure PowerShell with no external dependencies
- [File-Sync](./src/Scripts/File-Sync/README.md) - [FreeFileSync](https://freefilesync.org/) batch job execution
- [Windows-Update](./src/Scripts/Windows-Update/README.md) - Production-ready Windows Update automation solution using pure PowerShell
- [Scheduler Template Module](./src/Scripts/SchedulerTemplate.psm1)
---
## Features at a Glance
* **.NET 10 Worker Service** clean, robust, stable.
* **Windows only** designed specifically for Windows services.
* **Strongly typed configuration** via `appsettings.json`.
* **Run PowerShell scripts & executables concurrently** (each in its own thread).
* **Parallel execution** PowerShell scripts & executables run concurrently using RunspacePool and Task.WhenAll.
* **Relative path support** script and process paths can be relative to the application directory.
* **Signature enforcement** (AllSigned by default).
* **Automatic restart-on-failure** for supervised processes.
* **Extensible logging** (file + console).
* **Simple Install.cmd / Uninstall.cmd**.
* **Extensible logging** (file + console + Windows EventLog).
* **Built-in CLI** for service management (`--install`, `--uninstall`, `--start`, `--stop`, `--status`).
* **Reusable scheduling module**: `SchedulerTemplate.psm1`.
* **Thread-isolated architecture** — individual failures do not affect others.
@ -53,30 +67,55 @@ Designed for system administrators — and also for those who *feel like* system
## Installation
### Recommended (using bundled scripts)
### Using CLI Commands
```bat
cd /d path\to\src\MaksIT.UScheduler
Install.cmd
The executable includes built-in service management commands. Run as Administrator:
```powershell
# Install the service (auto-start enabled)
MaksIT.UScheduler.exe --install
# Start the service
MaksIT.UScheduler.exe --start
# Check service status
MaksIT.UScheduler.exe --status
# Stop the service
MaksIT.UScheduler.exe --stop
# Uninstall the service
MaksIT.UScheduler.exe --uninstall
# Show help
MaksIT.UScheduler.exe --help
```
| Command | Short | Description |
|---------|-------|-------------|
| `--install` | `-i` | Install the Windows service (auto-start) |
| `--uninstall` | `-u` | Stop and remove the Windows service |
| `--start` | | Start the service |
| `--stop` | | Stop the service |
| `--status` | | Query service status |
| `--help` | `-h` | Show help message |
> **Note:** Service management commands require administrator privileges.
### Using sc.exe
Alternatively, use Windows Service Control Manager directly:
```powershell
sc.exe create "MaksIT.UScheduler" binpath="C:\Path\To\MaksIT.UScheduler.exe" start=auto
sc.exe start "MaksIT.UScheduler"
```
To uninstall:
```bat
Uninstall.cmd
```
### Manual Installation
```powershell
sc.exe create "MaksIT.UScheduler Service" binpath="C:\Path\To\MaksIT.UScheduler.exe"
sc.exe start "MaksIT.UScheduler Service"
```
Manual uninstall:
```powershell
sc.exe delete "MaksIT.UScheduler Service"
sc.exe stop "MaksIT.UScheduler"
sc.exe delete "MaksIT.UScheduler"
```
---
@ -85,31 +124,79 @@ sc.exe delete "MaksIT.UScheduler Service"
```json
{
"Logging": {
"LogLevel": {
"Default": "Information"
},
"EventLog": {
"SourceName": "MaksIT.UScheduler",
"LogName": "Application",
"LogLevel": {
"Microsoft": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
},
"Configuration": {
"ServiceName": "MaksIT.UScheduler",
"LogDir": "C:\\Logs",
"Powershell": [
{ "Path": "C:\\Scripts\\MyScript.ps1", "IsSigned": true }
{ "Path": "../Scripts/MyScript.ps1", "IsSigned": true, "Disabled": false },
{ "Path": "C:\\Scripts\\AnotherScript.ps1", "IsSigned": false, "Disabled": true }
],
"Processes": [
{ "Path": "C:\\Programs\\MyApp.exe", "Args": ["--option"], "RestartOnFailure": true }
{ "Path": "../Tools/MyApp.exe", "Args": ["--option"], "RestartOnFailure": true, "Disabled": false }
]
}
}
```
> **Note:** `ServiceName` and `LogDir` are optional. Defaults: `"MaksIT.UScheduler"` and `Logs` folder in app directory.
### Path Resolution
Paths can be either absolute or relative:
| Path Type | Example | Resolved To |
|-----------|---------|-------------|
| Absolute | `C:\Scripts\backup.ps1` | `C:\Scripts\backup.ps1` |
| Relative | `../Scripts/backup.ps1` | `{AppDirectory}\..\Scripts\backup.ps1` |
| Relative | `scripts/backup.ps1` | `{AppDirectory}\scripts\backup.ps1` |
Relative paths are resolved against the application's base directory (where `MaksIT.UScheduler.exe` is located).
### Log Levels
The `"Default": "Information"` setting controls the minimum severity of messages that get logged. Available levels (from most to least verbose):
| Level | Description |
|-------|-------------|
| `Trace` | Most detailed, for debugging internals |
| `Debug` | Debugging information |
| `Information` | General operational events (recommended default) |
| `Warning` | Abnormal or unexpected events |
| `Error` | Errors and exceptions |
| `Critical` | Critical failures requiring immediate attention |
| `None` | Disables logging |
### PowerShell Scripts
* `Path` — full `.ps1` file path
* `IsSigned``true` enforces AllSigned, `false` runs unrestricted
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `Path` | string | required | Path to `.ps1` file (absolute or relative) |
| `IsSigned` | bool | `true` | `true` enforces AllSigned, `false` runs unrestricted |
| `Disabled` | bool | `false` | `true` skips this script during execution |
### Processes
* `Path` — executable
* `Args` — command-line arguments
* `RestartOnFailure` — restart logic handled by service
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `Path` | string | required | Path to executable (absolute or relative) |
| `Args` | string[] | `null` | Command-line arguments |
| `RestartOnFailure` | bool | `false` | Restart process if it exits with non-zero code |
| `Disabled` | bool | `false` | `true` skips this process during execution |
---
@ -133,21 +220,28 @@ param (
)
```
### Thread Layout
### Execution Model
Scripts and processes run **in parallel** using:
- **PowerShell**: `RunspacePool` (up to CPU core count concurrent runspaces)
- **Processes**: `Task.WhenAll` for concurrent process execution
```
Unified Scheduler Service
├── PowerShell
│ ├── ScriptA.ps1 Thread
│ ├── ScriptB.ps1 Thread
│ └── ...
└── Processes
├── ProgramA.exe Thread
├── ProgramB.exe Thread
└── ...
├── PSScriptBackgroundService (RunspacePool)
│ ├── ScriptA.ps1 ─┐
│ ├── ScriptB.ps1 ─┼─ Parallel execution
│ └── ScriptC.ps1 ─┘
└── ProcessBackgroundService (Task.WhenAll)
├── ProgramA.exe ─┐
├── ProgramB.exe ─┼─ Parallel execution
└── ProgramC.exe ─┘
```
A crash in one thread **never stops the service** or other components.
- A failure in one script/process **never stops the service** or other components.
- The same script/process won't run twice concurrently (protected by "already running" check).
- Execution cycle repeats every 10 seconds.
---
@ -164,7 +258,20 @@ This module provides:
* Automatic lock file (no concurrent execution)
* Last-run file tracking
* Unified callback execution pattern
* Logging helpers (Write-Log)
### Exported Functions
| Function | Description |
|----------|-------------|
| `Write-Log` | Logging with timestamp, level (Info/Success/Warning/Error), and color support |
| `Invoke-ScheduledExecution` | Main scheduler — checks schedule, manages locks, runs callback |
| `Get-CredentialFromEnvVar` | Retrieves credentials from Base64-encoded machine environment variables |
| `Test-UNCPath` | Validates whether a path is a UNC path |
| `Send-EmailNotification` | Sends SMTP email with optional SSL and credential support |
### Module Version
The module exports `$ModuleVersion` and `$ModuleDate` for version tracking.
### Example usage
@ -205,20 +312,65 @@ 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).
* Scripts run with **AllSigned** execution policy by default.
* Set `IsSigned: false` to use **Unrestricted** policy (not recommended for production).
* Scripts are auto-unblocked before execution (Zone.Identifier removed).
* Signature validation ensures only trusted scripts execute.
---
## Logging
* Console logging
* File logging under the directory specified by `LogDir`
* **Console logging** — standard output
* **File logging** — written to `LogDir` (default: `Logs` folder in app directory)
* **Windows EventLog** — events logged to Application log under `MaksIT.UScheduler` source
* All events (start, stop, crash, restart, error, skip) are logged
---
## Testing
The project includes a comprehensive test suite using **xUnit** and **Moq** for unit testing.
### Running Tests
```powershell
# Run all tests
dotnet test src/MaksIT.UScheduler.Tests
# Run with verbose output
dotnet test src/MaksIT.UScheduler.Tests --verbosity normal
```
### Code Coverage
Coverage badges are generated locally using [ReportGenerator](https://github.com/danielpalme/ReportGenerator).
**Prerequisites:**
```powershell
dotnet tool install --global dotnet-reportgenerator-globaltool
```
**Generate coverage report and badges:**
```powershell
.\src\scripts\Run-Coverage\Run-Coverage.ps1
# With HTML report opened in browser
.\src\scripts\Run-Coverage\Run-Coverage.ps1 -OpenReport
```
### Test Structure
| Test Class | Coverage |
|------------|----------|
| `ConfigurationTests` | Configuration POCOs and default values |
| `ProcessBackgroundServiceTests` | Process execution lifecycle and error handling |
| `PSScriptBackgroundServiceTests` | PowerShell script execution and signature validation |
---
## Contact
Maksym Sadovnychyy MAKS-IT, 2025

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 9.6%">
<title>Branch Coverage: 9.6%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="147.5" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="40" height="20" fill="#e05d44"/>
<rect width="147.5" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">9.6%</text>
<text x="127.5" y="14" fill="#fff">9.6%</text>
</g>
</svg>

21
badges/coverage-lines.svg Normal file
View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 18.6%">
<title>Line Coverage: 18.6%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="137" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="94.5" height="20" fill="#555"/>
<rect x="94.5" width="42.5" height="20" fill="#fe7d37"/>
<rect width="137" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">18.6%</text>
<text x="115.75" y="14" fill="#fff">18.6%</text>
</g>
</svg>

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 43.2%">
<title>Method Coverage: 43.2%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="150" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="42.5" height="20" fill="#a4a61d"/>
<rect width="150" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">43.2%</text>
<text x="128.75" y="14" fill="#fff">43.2%</text>
</g>
</svg>

View File

@ -0,0 +1,8 @@
<Application x:Class="MaksIT.UScheduler.ScheduleManager.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>

View File

@ -0,0 +1,7 @@
using System.Windows;
namespace MaksIT.UScheduler.ScheduleManager;
public partial class App : Application
{
}

View File

@ -0,0 +1,445 @@
<Window x:Class="MaksIT.UScheduler.ScheduleManager.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:MaksIT.UScheduler.ScheduleManager.ViewModels"
xmlns:svc="clr-namespace:MaksIT.UScheduler.ScheduleManager.Services"
xmlns:shared="clr-namespace:MaksIT.UScheduler.Shared;assembly=MaksIT.UScheduler.Shared"
mc:Ignorable="d"
Title="MaksIT.UScheduler Manager"
Height="800" Width="1100"
MinHeight="600" MinWidth="900"
WindowStartupLocation="CenterScreen">
<Window.DataContext>
<vm:MainViewModel />
</Window.DataContext>
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<Style TargetType="GroupBox">
<Setter Property="Margin" Value="5"/>
<Setter Property="Padding" Value="5"/>
</Style>
<Style TargetType="Button">
<Setter Property="Padding" Value="10,5"/>
<Setter Property="Margin" Value="3"/>
<Setter Property="MinWidth" Value="80"/>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Margin" Value="5,2"/>
</Style>
</Window.Resources>
<Grid>
<TabControl>
<!-- Main Tab -->
<TabItem Header="Main">
<TabControl>
<!-- Scripts Management -->
<TabItem Header="Scripts Management">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="280"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Left Panel: Scripts List -->
<GroupBox Grid.Column="0" Header="Scripts">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ListBox Grid.Row="0" ItemsSource="{Binding Scripts}" SelectedItem="{Binding SelectedScript}" DisplayMemberPath="Name"/>
<Button Grid.Row="1" Content="Refresh" Command="{Binding RefreshScriptsCommand}" HorizontalAlignment="Stretch" Margin="0,5,0,0"/>
</Grid>
</GroupBox>
<!-- Right Panel: Schedule Settings & Script Status -->
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Script Status Panel -->
<GroupBox Grid.Row="0" Header="Script Status"
Visibility="{Binding HasSelectedScript, Converter={StaticResource BoolToVis}}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal">
<TextBlock Text="Last Run: " FontWeight="Bold" VerticalAlignment="Center"/>
<TextBlock Text="{Binding CurrentScriptStatus.LastRunDisplay, FallbackValue='Never'}"
VerticalAlignment="Center" Margin="0,0,30,0"/>
<TextBlock Text="Status: " FontWeight="Bold" VerticalAlignment="Center"/>
<Ellipse Width="10" Height="10" Margin="0,0,5,0" VerticalAlignment="Center">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="LimeGreen"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasLockFile}" Value="True">
<Setter Property="Fill" Value="Orange"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<TextBlock Text="{Binding CurrentScriptStatus.StatusDisplay, FallbackValue='Ready'}"
VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="2" Orientation="Horizontal">
<Button Content="Remove Lock" Command="{Binding RemoveLockFileCommand}"
Visibility="{Binding HasLockFile, Converter={StaticResource BoolToVis}}"
Background="#FFFFCCCC"/>
<Button Content="Refresh Status" Command="{Binding RefreshScriptStatusCommand}"/>
<Button Content="Launch" Command="{Binding LaunchScriptCommand}"
ToolTip="Run script via its .bat file in a separate window"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Orientation="Horizontal" Margin="0,8,0,0">
<TextBlock Text="Name:" VerticalAlignment="Center" Margin="0,0,8,0" FontWeight="Bold"/>
<TextBox Text="{Binding ScriptName, UpdateSourceTrigger=PropertyChanged}" MinWidth="180" VerticalAlignment="Center"
ToolTip="Display name for this script in the list; saved to service appsettings"/>
<CheckBox Content="Require signed script" IsChecked="{Binding ScriptIsSigned, UpdateSourceTrigger=PropertyChanged}" Margin="20,0,0,0"
ToolTip="When checked, only scripts with valid Authenticode signatures will be executed"/>
<CheckBox Content="Disabled" IsChecked="{Binding ScriptDisabled, UpdateSourceTrigger=PropertyChanged}" Margin="20,0,0,0"
ToolTip="When checked, this script will not be executed by the scheduler"/>
</StackPanel>
</Grid>
</GroupBox>
<!-- Schedule Settings -->
<GroupBox Grid.Row="1" Header="Schedule Settings">
<Grid Visibility="{Binding HasSelectedScript, Converter={StaticResource BoolToVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Months Section -->
<GroupBox Grid.Row="0" Header="Months (empty = all months)">
<WrapPanel>
<CheckBox Content="Jan" IsChecked="{Binding MonthOptions[0].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Feb" IsChecked="{Binding MonthOptions[1].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Mar" IsChecked="{Binding MonthOptions[2].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Apr" IsChecked="{Binding MonthOptions[3].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="May" IsChecked="{Binding MonthOptions[4].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Jun" IsChecked="{Binding MonthOptions[5].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Jul" IsChecked="{Binding MonthOptions[6].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Aug" IsChecked="{Binding MonthOptions[7].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Sep" IsChecked="{Binding MonthOptions[8].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Oct" IsChecked="{Binding MonthOptions[9].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Nov" IsChecked="{Binding MonthOptions[10].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Dec" IsChecked="{Binding MonthOptions[11].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
</WrapPanel>
</GroupBox>
<!-- Weekdays Section -->
<GroupBox Grid.Row="1" Header="Weekdays (empty = all days)">
<WrapPanel>
<CheckBox Content="Mon" IsChecked="{Binding WeekdayOptions[0].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Tue" IsChecked="{Binding WeekdayOptions[1].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Wed" IsChecked="{Binding WeekdayOptions[2].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Thu" IsChecked="{Binding WeekdayOptions[3].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Fri" IsChecked="{Binding WeekdayOptions[4].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Sat" IsChecked="{Binding WeekdayOptions[5].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
<CheckBox Content="Sun" IsChecked="{Binding WeekdayOptions[6].IsChecked}"
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
</WrapPanel>
</GroupBox>
<!-- Run Times Section -->
<GroupBox Grid.Row="2" Header="Run Times (UTC)">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="80"/>
</Grid.RowDefinitions>
<!-- Add Time Controls -->
<StackPanel Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2"
Orientation="Horizontal" Margin="0,0,0,5">
<TextBox Text="{Binding NewRunTime, UpdateSourceTrigger=PropertyChanged}"
Width="80" VerticalAlignment="Center"/>
<Button Content="Add" Command="{Binding AddRunTimeCommand}" MinWidth="60"/>
<Button Content="Remove" Command="{Binding RemoveRunTimeCommand}" MinWidth="60"/>
</StackPanel>
<!-- Times List -->
<ListBox Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2"
ItemsSource="{Binding RunTimes}"
SelectedItem="{Binding SelectedRunTime}"/>
</Grid>
</GroupBox>
<!-- Min Interval Section -->
<GroupBox Grid.Row="3" Header="Minimum Interval">
<StackPanel Orientation="Horizontal" VerticalAlignment="Top">
<TextBox Text="{Binding MinIntervalMinutes, UpdateSourceTrigger=PropertyChanged}"
Width="60" VerticalAlignment="Center"/>
<TextBlock Text=" minutes between executions" VerticalAlignment="Center"/>
</StackPanel>
</GroupBox>
<!-- Save/Revert Buttons -->
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<TextBlock Text="* Unsaved changes" Foreground="Orange" VerticalAlignment="Center"
Margin="0,0,20,0"
Visibility="{Binding IsDirty, Converter={StaticResource BoolToVis}}"/>
<Button Content="Revert" Command="{Binding RevertScheduleCommand}" MinWidth="80"/>
<Button Content="Save" Command="{Binding SaveScheduleCommand}" MinWidth="80"
FontWeight="Bold"/>
</StackPanel>
</Grid>
</GroupBox>
</Grid>
</Grid>
</TabItem>
<!-- Processes Management (to-do; hidden for now) -->
<TabItem Header="Processes Management" Visibility="Collapsed">
<GroupBox Header="Processes">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Margin="0,0,0,8" TextWrapping="Wrap">
<Run Text="Processes are configured in MaksIT.UScheduler appsettings.json (Configuration.Processes). Listed below are the entries currently loaded."/>
</TextBlock>
<ListBox Grid.Row="1" ItemsSource="{Binding ProcessList}" MinHeight="120">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type shared:ProcessConfiguration}">
<StackPanel>
<TextBlock Text="{Binding Path}" FontWeight="SemiBold"/>
<TextBlock FontSize="11" Foreground="Gray">
<Run Text="Disabled: "/><Run Text="{Binding Disabled, Mode=OneWay}"/>
<Run Text=" Restart on failure: "/><Run Text="{Binding RestartOnFailure, Mode=OneWay}"/>
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</GroupBox>
</TabItem>
</TabControl>
</TabItem>
<!-- Logs Tab -->
<TabItem Header="Logs">
<GroupBox Header="Log Viewer" Margin="5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Log Files List -->
<Grid Grid.Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TabControl Grid.Row="0" Grid.RowSpan="2" SelectedIndex="{Binding SelectedLogTab}">
<TabItem Header="Service Logs">
<ListBox ItemsSource="{Binding ServiceLogs}"
SelectedItem="{Binding SelectedLogFile}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding FileName}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding SizeDisplay}" FontSize="10" Foreground="Gray"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</TabItem>
<TabItem Header="Script Logs">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Script folders" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="Log files" FontWeight="SemiBold" Margin="5,0,0,4"/>
<ListBox Grid.Row="1" Grid.Column="0" ItemsSource="{Binding ScriptLogFolders}"
SelectedItem="{Binding SelectedScriptLogFolder}"/>
<ListBox Grid.Row="1" Grid.Column="1" Margin="5,0,0,0"
ItemsSource="{Binding ScriptLogFiles}"
SelectedItem="{Binding SelectedLogFile}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding FileName}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding SizeDisplay}" FontSize="10" Foreground="Gray"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
</TabControl>
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,5,0,0">
<Button Content="Refresh" Command="{Binding RefreshServiceLogsCommand}" MinWidth="60" Padding="5,2"/>
<Button Content="Open Folder" Command="{Binding OpenLogDirectoryCommand}" MinWidth="70" Padding="5,2"/>
</StackPanel>
</Grid>
<!-- Log Content -->
<Grid Grid.Column="1" Margin="5,0,0,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,5">
<TextBlock Text="Log Content" FontWeight="Bold" VerticalAlignment="Center"/>
<Button Content="Refresh" Command="{Binding RefreshLogContentCommand}"
MinWidth="60" Padding="5,2" Margin="10,0,0,0"/>
<Button Content="Open in Explorer" Command="{Binding OpenLogInExplorerCommand}"
MinWidth="100" Padding="5,2"/>
</StackPanel>
<TextBox Grid.Row="1"
Text="{Binding LogContent, Mode=OneWay}"
IsReadOnly="True"
FontFamily="Consolas"
FontSize="11"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto"
TextWrapping="NoWrap"
AcceptsReturn="True"/>
</Grid>
</Grid>
</GroupBox>
</TabItem>
<!-- Settings Tab -->
<TabItem Header="Settings">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="5">
<GroupBox Header="Service Control">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Margin="0,0,0,8"
Text="{Binding AppSettingsLoadError}"
Visibility="{Binding AppSettingsLoadErrorVisibility}"
Foreground="DarkRed" TextWrapping="Wrap"/>
<Grid Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Service Bin Path:" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold"/>
<TextBox Grid.Column="1" Text="{Binding ServiceBinPath, UpdateSourceTrigger=PropertyChanged}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="2" Orientation="Horizontal" Margin="5,0,0,0">
<Button Content="..." Command="{Binding BrowseServiceBinPathCommand}" MinWidth="30" Padding="5,2"/>
<Button Content="Reload" Command="{Binding ReloadAppSettingsCommand}" MinWidth="50" Padding="5,2"/>
</StackPanel>
</Grid>
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Service: " FontWeight="Bold"/>
<TextBlock Text="{Binding ServiceName}" Margin="0,0,20,0"/>
<TextBlock Text="Status: " FontWeight="Bold"/>
<Ellipse Width="12" Height="12" Margin="0,0,5,0" VerticalAlignment="Center">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Style.Triggers>
<DataTrigger Binding="{Binding ServiceStatusColor}" Value="Green"><Setter Property="Fill" Value="LimeGreen"/></DataTrigger>
<DataTrigger Binding="{Binding ServiceStatusColor}" Value="Red"><Setter Property="Fill" Value="Red"/></DataTrigger>
<DataTrigger Binding="{Binding ServiceStatusColor}" Value="Orange"><Setter Property="Fill" Value="Orange"/></DataTrigger>
<DataTrigger Binding="{Binding ServiceStatusColor}" Value="Gray"><Setter Property="Fill" Value="Gray"/></DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<TextBlock Text="{Binding ServiceStatusText}" FontWeight="SemiBold"/>
</StackPanel>
<StackPanel Grid.Row="2" Grid.Column="2" Orientation="Horizontal">
<Button Content="Register" Command="{Binding RegisterServiceCommand}" IsEnabled="{Binding IsAdmin}"/>
<Button Content="Unregister" Command="{Binding UnregisterServiceCommand}" IsEnabled="{Binding IsAdmin}"/>
<Separator Style="{StaticResource {x:Static ToolBar.SeparatorStyleKey}}" Margin="10,0"/>
<Button Content="Start" Command="{Binding StartServiceCommand}" IsEnabled="{Binding IsAdmin}"/>
<Button Content="Stop" Command="{Binding StopServiceCommand}" IsEnabled="{Binding IsAdmin}"/>
<Separator Style="{StaticResource {x:Static ToolBar.SeparatorStyleKey}}" Margin="10,0"/>
<Button Content="Refresh" Command="{Binding RefreshServiceStatusCommand}"/>
</StackPanel>
</Grid>
</GroupBox>
<GroupBox Header="Log Path" Margin="0,10,0,0">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Log Directory:" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold"/>
<TextBox Text="{Binding LogDirectory, UpdateSourceTrigger=PropertyChanged}" MinWidth="300" VerticalAlignment="Center" IsReadOnly="True" Background="#F0F0F0"/>
<Button Content="Open" Command="{Binding OpenLogDirectoryCommand}" Margin="5,0" MinWidth="50"/>
</StackPanel>
</GroupBox>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
</Grid>
</Window>

View File

@ -0,0 +1,20 @@
using System.Windows;
using MaksIT.UScheduler.ScheduleManager.ViewModels;
namespace MaksIT.UScheduler.ScheduleManager;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void OnScheduleChanged(object sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
{
vm.OnMonthCheckedChanged();
}
}
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Version>1.0.1</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -0,0 +1,59 @@
namespace MaksIT.UScheduler.ScheduleManager.Models;
/// <summary>
/// Represents the schedule configuration from a scriptsettings.json file.
/// </summary>
public class ScriptSchedule
{
/// <summary>
/// Full path to the scriptsettings.json file.
/// </summary>
public string FilePath { get; set; } = string.Empty;
/// <summary>
/// Display name for the script in the UI (from JSON "name" or "title", or derived from path).
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Script title from the JSON "title" field.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Array of month names to run (empty = every month).
/// Valid values: January, February, March, April, May, June, July, August, September, October, November, December
/// </summary>
public List<string> RunMonth { get; set; } = [];
/// <summary>
/// Array of weekday names to run (empty = every day).
/// Valid values: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday
/// </summary>
public List<string> RunWeekday { get; set; } = [];
/// <summary>
/// Array of UTC times in HH:mm format when script should run.
/// </summary>
public List<string> RunTime { get; set; } = [];
/// <summary>
/// Minimum minutes between runs to prevent duplicate executions.
/// </summary>
public int MinIntervalMinutes { get; set; } = 10;
/// <summary>
/// Script path as stored in service appsettings (e.g. ".\file-sync.ps1"). Used to find and update the config entry.
/// </summary>
public string ConfigScriptPath { get; set; } = string.Empty;
/// <summary>
/// Whether the script must be digitally signed (from service Configuration.Powershell).
/// </summary>
public bool IsSigned { get; set; } = true;
/// <summary>
/// Whether the script is disabled and will not be executed (from service Configuration.Powershell).
/// </summary>
public bool Disabled { get; set; }
}

View File

@ -0,0 +1,49 @@
namespace MaksIT.UScheduler.ScheduleManager.Models;
/// <summary>
/// Represents the runtime status of a script (lock file, last run).
/// </summary>
public class ScriptStatus
{
/// <summary>
/// Path to the script's .ps1 file.
/// </summary>
public string ScriptPath { get; set; } = string.Empty;
/// <summary>
/// Script name (derived from folder name).
/// </summary>
public string ScriptName { get; set; } = string.Empty;
/// <summary>
/// Whether a lock file exists (script is currently running or was interrupted).
/// </summary>
public bool HasLockFile { get; set; }
/// <summary>
/// Path to the lock file.
/// </summary>
public string LockFilePath { get; set; } = string.Empty;
/// <summary>
/// Last execution time (from .lastRun file).
/// </summary>
public DateTime? LastRun { get; set; }
/// <summary>
/// Path to the last run file.
/// </summary>
public string LastRunFilePath { get; set; } = string.Empty;
/// <summary>
/// Formatted last run time for display.
/// </summary>
public string LastRunDisplay => LastRun.HasValue
? LastRun.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")
: "Never";
/// <summary>
/// Status indicator for display.
/// </summary>
public string StatusDisplay => HasLockFile ? "Locked" : "Ready";
}

View File

@ -0,0 +1,206 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using MaksIT.UScheduler.Shared;
using MaksIT.UScheduler.Shared.Helpers;
namespace MaksIT.UScheduler.ScheduleManager.Services;
/// <summary>
/// Service for loading and managing appsettings.json from UScheduler.
/// </summary>
public class AppSettingsService
{
private string? _appSettingsPath;
private JsonNode? _rootDocument;
/// <summary>
/// Gets the path to the appsettings.json file.
/// </summary>
public string? AppSettingsPath => _appSettingsPath;
/// <summary>
/// Gets the directory containing appsettings.json (the service directory).
/// </summary>
public string? ServiceDirectory => string.IsNullOrEmpty(_appSettingsPath)
? null
: System.IO.Path.GetDirectoryName(_appSettingsPath);
/// <summary>
/// Loads appsettings.json from the specified path.
/// Returns null if path is null/empty or the file does not exist.
/// </summary>
public Configuration? LoadAppSettings(string? appSettingsPath = null)
{
_appSettingsPath = appSettingsPath;
if (string.IsNullOrEmpty(_appSettingsPath) || !File.Exists(_appSettingsPath))
return null;
try
{
var json = File.ReadAllText(_appSettingsPath);
_rootDocument = JsonNode.Parse(json);
if (_rootDocument == null)
return null;
var configNode = _rootDocument["Configuration"];
if (configNode == null)
return new Configuration { LogDir = ".\\Logs" };
var config = new Configuration
{
ServiceName = configNode["ServiceName"]?.GetValue<string>() ?? "MaksIT.UScheduler",
LogDir = configNode["LogDir"]?.GetValue<string>() ?? ".\\Logs"
};
// Parse PowerShell scripts
if (configNode["Powershell"] is JsonArray psArray)
{
foreach (var item in psArray)
{
if (item == null) continue;
config.Powershell.Add(new PowershellScript
{
Path = item["Path"]?.GetValue<string>() ?? string.Empty,
Name = item["Name"]?.GetValue<string>(),
IsSigned = item["IsSigned"]?.GetValue<bool>() ?? true,
Disabled = item["Disabled"]?.GetValue<bool>() ?? false
});
}
}
// Parse Processes
if (configNode["Processes"] is JsonArray procArray)
{
foreach (var item in procArray)
{
if (item == null) continue;
var proc = new ProcessConfiguration
{
Path = item["Path"]?.GetValue<string>() ?? string.Empty,
RestartOnFailure = item["RestartOnFailure"]?.GetValue<bool>() ?? false,
Disabled = item["Disabled"]?.GetValue<bool>() ?? false
};
if (item["Args"] is JsonArray argsArray)
{
proc.Args = argsArray.Select(a => a?.GetValue<string>() ?? "").ToArray();
}
config.Processes.Add(proc);
}
}
return config;
}
catch
{
return null;
}
}
/// <summary>
/// Gets the resolved log directory path from the service config.
/// LogDir may be relative (e.g. ".\Logs"); it is resolved against the service directory, same as script paths.
/// </summary>
public string GetLogDirectory(Configuration? config)
{
if (config == null || string.IsNullOrEmpty(ServiceDirectory))
return string.Empty;
if (!string.IsNullOrEmpty(config.LogDir))
return ResolvePathRelativeToService(config.LogDir);
// Default: Logs folder in service directory
return System.IO.Path.Combine(ServiceDirectory, "Logs");
}
/// <summary>
/// Gets the service executable path.
/// </summary>
public string GetExecutablePath()
{
if (string.IsNullOrEmpty(ServiceDirectory))
return string.Empty;
var exePath = System.IO.Path.Combine(ServiceDirectory, "MaksIT.UScheduler.exe");
return File.Exists(exePath) ? exePath : string.Empty;
}
/// <summary>
/// Reads the LogDir value from MaksIT.UScheduler's appsettings.json at the given path.
/// The path should be the full path to appsettings.json (e.g. ServiceBinPath + "appsettings.json").
/// Returns null if the file doesn't exist or Configuration.LogDir is missing.
/// </summary>
public static string? GetLogDirFromAppSettingsFile(string appSettingsFilePath)
{
if (string.IsNullOrEmpty(appSettingsFilePath) || !File.Exists(appSettingsFilePath))
return null;
try
{
var json = File.ReadAllText(appSettingsFilePath);
var doc = JsonNode.Parse(json);
var configNode = doc?["Configuration"];
return configNode?["LogDir"]?.GetValue<string>();
}
catch
{
return null;
}
}
/// <summary>
/// Updates Name, IsSigned and Disabled for a PowerShell script entry in appsettings and saves the file.
/// The script is identified by its config path (e.g. ".\file-sync.ps1").
/// Pass null for name to leave the existing Name value unchanged.
/// </summary>
public bool UpdatePowershellScriptEntry(string configScriptPath, string? name, bool isSigned, bool disabled)
{
if (string.IsNullOrEmpty(_appSettingsPath) || _rootDocument == null)
return false;
var configNode = _rootDocument["Configuration"];
if (configNode?["Powershell"] is not JsonArray psArray)
return false;
for (var i = 0; i < psArray.Count; i++)
{
if (psArray[i] is not JsonObject item)
continue;
var path = item["Path"]?.GetValue<string>();
if (path != configScriptPath)
continue;
if (name != null)
item["Name"] = name;
item["IsSigned"] = isSigned;
item["Disabled"] = disabled;
try
{
var options = new JsonWriterOptions { Indented = true };
using var stream = File.Create(_appSettingsPath);
using var writer = new Utf8JsonWriter(stream, options);
_rootDocument.WriteTo(writer);
}
catch
{
return false;
}
return true;
}
return false;
}
/// <summary>
/// Resolves a path relative to the service directory (where appsettings.json is located).
/// If the path is already absolute, returns it as-is.
/// </summary>
public string ResolvePathRelativeToService(string path) =>
PathHelper.ResolvePath(path, ServiceDirectory ?? string.Empty);
}

View File

@ -0,0 +1,259 @@
using System.IO;
namespace MaksIT.UScheduler.ScheduleManager.Services;
/// <summary>
/// Represents a log file with metadata.
/// </summary>
public class LogFileInfo
{
public string FileName { get; set; } = string.Empty;
public string FullPath { get; set; } = string.Empty;
public DateTime LastModified { get; set; }
public long SizeBytes { get; set; }
/// <summary>Script folder name for script logs (e.g. file-sync); empty for service logs.</summary>
public string ScriptFolder { get; set; } = string.Empty;
public string SizeDisplay => SizeBytes switch
{
< 1024 => $"{SizeBytes} B",
< 1024 * 1024 => $"{SizeBytes / 1024.0:F1} KB",
_ => $"{SizeBytes / (1024.0 * 1024.0):F1} MB"
};
/// <summary>Display label for list: script folder + file name for script logs, else file name.</summary>
public string DisplayLabel => string.IsNullOrEmpty(ScriptFolder) ? FileName : $"{ScriptFolder} \\ {FileName}";
}
/// <summary>
/// Service for viewing log files.
/// </summary>
public class LogViewerService
{
/// <summary>
/// Gets all log files from the specified log directory.
/// </summary>
public List<LogFileInfo> GetServiceLogs(string logDir)
{
var logs = new List<LogFileInfo>();
if (string.IsNullOrEmpty(logDir) || !Directory.Exists(logDir))
return logs;
try
{
// Get log files from root directory (service logs)
var rootLogs = Directory.GetFiles(logDir, "*.log", SearchOption.TopDirectoryOnly);
foreach (var file in rootLogs)
{
logs.Add(CreateLogFileInfo(file));
}
// Also check for .txt log files
var txtLogs = Directory.GetFiles(logDir, "*.txt", SearchOption.TopDirectoryOnly);
foreach (var file in txtLogs)
{
logs.Add(CreateLogFileInfo(file));
}
}
catch
{
// Ignore access errors
}
return logs.OrderByDescending(l => l.LastModified).ToList();
}
/// <summary>
/// Gets the names of all script log subfolders under logDir (e.g. file-sync, hyper-v-backup).
/// </summary>
public List<string> GetScriptLogFolderNames(string logDir)
{
var names = new List<string>();
if (string.IsNullOrEmpty(logDir))
return names;
var logDirFull = Path.GetFullPath(logDir);
if (!Directory.Exists(logDirFull))
return names;
try
{
foreach (var subDir in Directory.GetDirectories(logDirFull))
{
var name = Path.GetFileName(subDir);
if (!string.IsNullOrEmpty(name))
names.Add(name);
}
}
catch
{
// Ignore access errors
}
return names.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
}
/// <summary>
/// Gets log files from all script subfolders under logDir (e.g. logs\file-sync, logs\hyper-v-backup).
/// Does not require a script to be selected. Each subfolder of logDir is treated as a script log folder.
/// </summary>
public List<LogFileInfo> GetAllScriptLogs(string logDir)
{
var logs = new List<LogFileInfo>();
if (string.IsNullOrEmpty(logDir))
return logs;
var logDirFull = Path.GetFullPath(logDir);
if (!Directory.Exists(logDirFull))
return logs;
try
{
foreach (var subDir in Directory.GetDirectories(logDirFull))
{
var scriptFolder = Path.GetFileName(subDir) ?? string.Empty;
var logFiles = Directory.GetFiles(subDir, "*.*", SearchOption.TopDirectoryOnly)
.Where(f => f.EndsWith(".log", StringComparison.OrdinalIgnoreCase) ||
f.EndsWith(".txt", StringComparison.OrdinalIgnoreCase));
foreach (var file in logFiles)
{
var info = CreateLogFileInfo(file);
info.ScriptFolder = scriptFolder;
logs.Add(info);
}
}
}
catch
{
// Ignore access errors
}
return logs.OrderByDescending(l => l.LastModified).ToList();
}
/// <summary>
/// Gets log files for a specific script. Script logs live under LogDir in a subfolder
/// named by the script filename without extension (from Configuration.Powershell[].Path).
/// E.g. Path=...\file-sync.ps1, LogDir=.\logs → .\logs\file-sync. Tries candidate names, case-insensitive.
/// </summary>
public List<LogFileInfo> GetScriptLogs(string logDir, string scriptName)
{
var logs = new List<LogFileInfo>();
if (string.IsNullOrEmpty(logDir) || string.IsNullOrEmpty(scriptName))
return logs;
var logDirFull = Path.GetFullPath(logDir);
if (!Directory.Exists(logDirFull))
return logs;
var scriptLogDir = FindScriptLogSubfolder(logDirFull, scriptName);
if (string.IsNullOrEmpty(scriptLogDir))
return logs;
try
{
var logFiles = Directory.GetFiles(scriptLogDir, "*.*", SearchOption.TopDirectoryOnly)
.Where(f => f.EndsWith(".log", StringComparison.OrdinalIgnoreCase) ||
f.EndsWith(".txt", StringComparison.OrdinalIgnoreCase));
foreach (var file in logFiles)
{
logs.Add(CreateLogFileInfo(file));
}
}
catch
{
// Ignore access errors
}
return logs.OrderByDescending(l => l.LastModified).ToList();
}
/// <summary>
/// Finds the script log subfolder under logDir: exact scriptName first, then case-insensitive match.
/// </summary>
private static string? FindScriptLogSubfolder(string logDir, string scriptName)
{
var exact = Path.Combine(logDir, scriptName);
if (Directory.Exists(exact))
return exact;
try
{
foreach (var subDir in Directory.GetDirectories(logDir))
{
var name = Path.GetFileName(subDir);
if (string.Equals(name, scriptName, StringComparison.OrdinalIgnoreCase))
return subDir;
}
}
catch
{
// Ignore access errors
}
return null;
}
/// <summary>
/// Reads the content of a log file.
/// </summary>
public string ReadLogFile(string filePath, int maxLines = 1000)
{
try
{
if (!File.Exists(filePath))
return "Log file not found.";
var lines = File.ReadLines(filePath).TakeLast(maxLines).ToList();
if (lines.Count == maxLines)
{
return $"[Showing last {maxLines} lines...]\n\n" + string.Join("\n", lines);
}
return string.Join("\n", lines);
}
catch (Exception ex)
{
return $"Error reading log file: {ex.Message}";
}
}
/// <summary>
/// Reads the tail of a log file (last N lines).
/// </summary>
public string ReadLogTail(string filePath, int lines = 100)
{
try
{
if (!File.Exists(filePath))
return "Log file not found.";
var allLines = File.ReadLines(filePath).TakeLast(lines).ToList();
return string.Join("\n", allLines);
}
catch (Exception ex)
{
return $"Error reading log file: {ex.Message}";
}
}
private static LogFileInfo CreateLogFileInfo(string filePath)
{
var fileInfo = new FileInfo(filePath);
return new LogFileInfo
{
FileName = fileInfo.Name,
FullPath = filePath,
LastModified = fileInfo.LastWriteTime,
SizeBytes = fileInfo.Length
};
}
}

View File

@ -0,0 +1,139 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using MaksIT.UScheduler.ScheduleManager.Models;
namespace MaksIT.UScheduler.ScheduleManager.Services;
/// <summary>
/// Service for loading and saving scriptsettings.json files.
/// </summary>
public class ScriptSettingsService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
/// <summary>
/// Scans a directory for scriptsettings.json files and loads their schedule configurations.
/// </summary>
public List<ScriptSchedule> LoadScriptsFromDirectory(string examplesPath)
{
var schedules = new List<ScriptSchedule>();
if (!Directory.Exists(examplesPath))
return schedules;
var settingsFiles = Directory.GetFiles(examplesPath, "scriptsettings.json", SearchOption.AllDirectories);
foreach (var filePath in settingsFiles)
{
try
{
var schedule = LoadScheduleFromFile(filePath);
if (schedule != null)
schedules.Add(schedule);
}
catch
{
// Skip files that can't be parsed
}
}
return schedules;
}
/// <summary>
/// Loads the schedule configuration from a single scriptsettings.json file.
/// </summary>
public ScriptSchedule? LoadScheduleFromFile(string filePath)
{
if (!File.Exists(filePath))
return null;
var json = File.ReadAllText(filePath);
var doc = JsonNode.Parse(json);
if (doc == null)
return null;
var title = doc["title"]?.GetValue<string>() ?? Path.GetDirectoryName(filePath) ?? "Unknown";
var name = doc["name"]?.GetValue<string>();
// Use name from JSON, or title with " Script Settings" suffix stripped for listbox display
var displayName = !string.IsNullOrWhiteSpace(name)
? name
: StripScriptSettingsSuffix(title);
var schedule = new ScriptSchedule
{
FilePath = filePath,
Name = displayName,
Title = title
};
var scheduleNode = doc["schedule"];
if (scheduleNode != null)
{
schedule.RunMonth = GetStringArray(scheduleNode["runMonth"]);
schedule.RunWeekday = GetStringArray(scheduleNode["runWeekday"]);
schedule.RunTime = GetStringArray(scheduleNode["runTime"]);
schedule.MinIntervalMinutes = scheduleNode["minIntervalMinutes"]?.GetValue<int>() ?? 10;
}
return schedule;
}
/// <summary>
/// Saves only the schedule section back to the scriptsettings.json file,
/// preserving all other content.
/// </summary>
public void SaveScheduleToFile(ScriptSchedule schedule)
{
if (!File.Exists(schedule.FilePath))
throw new FileNotFoundException("Script settings file not found.", schedule.FilePath);
var json = File.ReadAllText(schedule.FilePath);
var doc = JsonNode.Parse(json);
if (doc == null)
throw new InvalidOperationException("Failed to parse JSON file.");
// Update or create the schedule section
var scheduleNode = new JsonObject
{
["runMonth"] = new JsonArray(schedule.RunMonth.Select(m => JsonValue.Create(m)).ToArray()),
["runWeekday"] = new JsonArray(schedule.RunWeekday.Select(d => JsonValue.Create(d)).ToArray()),
["runTime"] = new JsonArray(schedule.RunTime.Select(t => JsonValue.Create(t)).ToArray()),
["minIntervalMinutes"] = schedule.MinIntervalMinutes
};
doc["schedule"] = scheduleNode;
// Write back with proper formatting
var options = new JsonWriterOptions { Indented = true };
using var stream = File.Create(schedule.FilePath);
using var writer = new Utf8JsonWriter(stream, options);
doc.WriteTo(writer);
}
/// <summary>Strips " Script Settings" (case-insensitive) from the end of a title for listbox display.</summary>
private static string StripScriptSettingsSuffix(string title)
{
const string suffix = " Script Settings";
if (title.Length > suffix.Length && title.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
return title[..^suffix.Length].TrimEnd();
return title;
}
private static List<string> GetStringArray(JsonNode? node)
{
if (node is not JsonArray array)
return [];
return array
.Where(item => item != null)
.Select(item => item!.GetValue<string>())
.ToList();
}
}

View File

@ -0,0 +1,94 @@
using System.IO;
using MaksIT.UScheduler.ScheduleManager.Models;
namespace MaksIT.UScheduler.ScheduleManager.Services;
/// <summary>
/// Service for managing script status files (lock files, last run).
/// </summary>
public class ScriptStatusService
{
/// <summary>
/// Gets the status of a script from its scriptsettings.json path.
/// </summary>
public ScriptStatus GetScriptStatus(string scriptSettingsPath)
{
var directory = Path.GetDirectoryName(scriptSettingsPath) ?? string.Empty;
var scriptName = Path.GetFileName(directory);
// Find the .ps1 file in the same directory
var scriptFiles = Directory.GetFiles(directory, "*.ps1", SearchOption.TopDirectoryOnly);
var scriptPath = scriptFiles.FirstOrDefault() ?? string.Empty;
if (string.IsNullOrEmpty(scriptPath))
{
return new ScriptStatus
{
ScriptPath = string.Empty,
ScriptName = scriptName,
HasLockFile = false,
LastRun = null
};
}
var lockFilePath = Path.ChangeExtension(scriptPath, ".lock");
var lastRunFilePath = Path.ChangeExtension(scriptPath, ".lastRun");
return new ScriptStatus
{
ScriptPath = scriptPath,
ScriptName = scriptName,
HasLockFile = File.Exists(lockFilePath),
LockFilePath = lockFilePath,
LastRun = GetLastRunTime(lastRunFilePath),
LastRunFilePath = lastRunFilePath
};
}
/// <summary>
/// Removes the lock file for a script.
/// </summary>
public bool RemoveLockFile(string lockFilePath)
{
try
{
if (File.Exists(lockFilePath))
{
File.Delete(lockFilePath);
return true;
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Reads the last run time from the .lastRun file.
/// </summary>
private static DateTime? GetLastRunTime(string lastRunFilePath)
{
try
{
if (!File.Exists(lastRunFilePath))
return null;
var content = File.ReadAllText(lastRunFilePath).Trim();
if (DateTime.TryParse(
content,
null,
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal,
out var lastRun))
{
return lastRun;
}
return null;
}
catch
{
return null;
}
}
}

View File

@ -0,0 +1,110 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using MaksIT.UScheduler.Shared.Helpers;
namespace MaksIT.UScheduler.ScheduleManager.Services;
/// <summary>
/// UI-specific settings stored in the ScheduleManager's appsettings.json.
/// </summary>
public class UISettings
{
/// <summary>
/// Path to the MaksIT.UScheduler bin folder containing appsettings.json and MaksIT.UScheduler.exe.
/// </summary>
public string ServiceBinPath { get; set; } = string.Empty;
}
/// <summary>
/// Service for loading and saving the ScheduleManager's own appsettings.json.
/// </summary>
public class UISettingsService
{
private readonly string _settingsFilePath;
public UISettingsService()
{
_settingsFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "appsettings.json");
}
/// <summary>
/// Loads the UI settings from appsettings.json.
/// </summary>
public UISettings Load()
{
try
{
if (!File.Exists(_settingsFilePath))
return new UISettings();
var json = File.ReadAllText(_settingsFilePath);
var doc = JsonNode.Parse(json);
if (doc == null)
return new UISettings();
var settingsNode = doc["USchedulerSettings"];
if (settingsNode == null)
return new UISettings();
return new UISettings
{
ServiceBinPath = settingsNode["ServiceBinPath"]?.GetValue<string>() ?? string.Empty
};
}
catch
{
return new UISettings();
}
}
/// <summary>
/// Saves the UI settings to appsettings.json.
/// </summary>
public void Save(UISettings settings)
{
try
{
JsonNode? doc;
// Load existing file or create new
if (File.Exists(_settingsFilePath))
{
var json = File.ReadAllText(_settingsFilePath);
doc = JsonNode.Parse(json) ?? new JsonObject();
}
else
{
doc = new JsonObject();
}
// Update the USchedulerSettings section
doc["USchedulerSettings"] = new JsonObject
{
["ServiceBinPath"] = settings.ServiceBinPath
};
// Write back
var options = new JsonWriterOptions { Indented = true };
using var stream = File.Create(_settingsFilePath);
using var writer = new Utf8JsonWriter(stream, options);
doc.WriteTo(writer);
}
catch
{
// Ignore save errors
}
}
/// <summary>
/// Gets the path to the UI's appsettings.json file.
/// </summary>
public string SettingsFilePath => _settingsFilePath;
/// <summary>
/// Resolves a path relative to the application's base directory.
/// If the path is already absolute, returns it as-is.
/// </summary>
public static string ResolvePath(string path) => PathHelper.ResolvePath(path);
}

View File

@ -0,0 +1,239 @@
using System.Diagnostics;
using System.IO;
using System.Text.RegularExpressions;
namespace MaksIT.UScheduler.ScheduleManager.Services;
/// <summary>
/// Service status enumeration.
/// </summary>
public enum ServiceStatus
{
NotInstalled,
Stopped,
StartPending,
StopPending,
Running,
ContinuePending,
PausePending,
Paused,
Unknown
}
/// <summary>
/// Result of a service operation.
/// </summary>
public record ServiceOperationResult(bool Success, string Message);
/// <summary>
/// Manages Windows service operations for MaksIT.UScheduler by calling the executable with CLI args.
/// </summary>
public class WindowsServiceManager
{
private const string DefaultServiceName = "MaksIT.UScheduler";
public string ServiceName { get; }
public WindowsServiceManager(string? serviceName = null)
{
ServiceName = serviceName ?? DefaultServiceName;
}
/// <summary>
/// Gets the current status of the service by running --status command.
/// </summary>
public ServiceStatus GetStatus(string? executablePath = null)
{
// Try using sc.exe query directly for status
var result = RunScCommand($"query \"{ServiceName}\"");
if (!result.Success)
{
// Service might not exist
if (result.Message.Contains("1060") || result.Message.Contains("does not exist"))
return ServiceStatus.NotInstalled;
return ServiceStatus.Unknown;
}
// Parse state from sc.exe output. Use numeric state code (locale-independent);
// sc query shows e.g. "STATE : 4 RUNNING" (text may be localized).
var match = Regex.Match(result.Message, @"STATE\s*:\s*(\d+)", RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups[1].Value, out var state))
{
return state switch
{
1 => ServiceStatus.Stopped,
2 => ServiceStatus.StartPending,
3 => ServiceStatus.StopPending,
4 => ServiceStatus.Running,
5 => ServiceStatus.ContinuePending,
6 => ServiceStatus.PausePending,
7 => ServiceStatus.Paused,
_ => ServiceStatus.Unknown
};
}
// Fallback: text match (English)
var output = result.Message.ToUpperInvariant();
if (output.Contains("RUNNING")) return ServiceStatus.Running;
if (output.Contains("STOPPED")) return ServiceStatus.Stopped;
if (output.Contains("START_PENDING")) return ServiceStatus.StartPending;
if (output.Contains("STOP_PENDING")) return ServiceStatus.StopPending;
if (output.Contains("PAUSED")) return ServiceStatus.Paused;
if (output.Contains("PAUSE_PENDING")) return ServiceStatus.PausePending;
if (output.Contains("CONTINUE_PENDING")) return ServiceStatus.ContinuePending;
return ServiceStatus.Unknown;
}
/// <summary>
/// Starts the service using the executable's --start command.
/// </summary>
public ServiceOperationResult Start(string? executablePath = null)
{
if (!string.IsNullOrEmpty(executablePath) && File.Exists(executablePath))
{
return RunExecutable(executablePath, "--start");
}
// Fallback to sc.exe
return RunScCommand($"start \"{ServiceName}\"");
}
/// <summary>
/// Stops the service using the executable's --stop command.
/// </summary>
public ServiceOperationResult Stop(string? executablePath = null)
{
if (!string.IsNullOrEmpty(executablePath) && File.Exists(executablePath))
{
return RunExecutable(executablePath, "--stop");
}
// Fallback to sc.exe
return RunScCommand($"stop \"{ServiceName}\"");
}
/// <summary>
/// Registers (installs) the service using the executable's --install command.
/// </summary>
public ServiceOperationResult Register(string executablePath)
{
if (string.IsNullOrWhiteSpace(executablePath))
return new ServiceOperationResult(false, "Executable path is required.");
if (!File.Exists(executablePath))
return new ServiceOperationResult(false, $"Executable not found: {executablePath}");
var status = GetStatus();
if (status != ServiceStatus.NotInstalled)
return new ServiceOperationResult(false, "Service is already installed.");
return RunExecutable(executablePath, "--install");
}
/// <summary>
/// Unregisters (removes) the service using the executable's --uninstall command.
/// </summary>
public ServiceOperationResult Unregister(string? executablePath = null)
{
var status = GetStatus();
if (status == ServiceStatus.NotInstalled)
return new ServiceOperationResult(false, "Service is not installed.");
if (!string.IsNullOrEmpty(executablePath) && File.Exists(executablePath))
{
return RunExecutable(executablePath, "--uninstall");
}
// Fallback: stop first, then delete via sc.exe
if (status == ServiceStatus.Running)
{
RunScCommand($"stop \"{ServiceName}\"");
}
return RunScCommand($"delete \"{ServiceName}\"");
}
/// <summary>
/// Runs the UScheduler executable with the specified arguments.
/// </summary>
private static ServiceOperationResult RunExecutable(string executablePath, string arguments)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = executablePath,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(executablePath) ?? ""
};
using var process = Process.Start(startInfo);
if (process == null)
return new ServiceOperationResult(false, "Failed to start process.");
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit(30000); // 30 second timeout
var message = string.IsNullOrEmpty(error) ? output.Trim() : $"{output}\n{error}".Trim();
if (process.ExitCode == 0)
return new ServiceOperationResult(true, message);
return new ServiceOperationResult(false, message);
}
catch (Exception ex)
{
return new ServiceOperationResult(false, $"Failed to execute: {ex.Message}");
}
}
/// <summary>
/// Runs an sc.exe command and returns the result.
/// </summary>
private static ServiceOperationResult RunScCommand(string arguments)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "sc.exe",
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null)
return new ServiceOperationResult(false, "Failed to start sc.exe process.");
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
// sc.exe often writes to stderr; combine both so status parsing sees the STATE line
var message = $"{output}{error}".Trim();
if (string.IsNullOrEmpty(message))
message = process.ExitCode == 0 ? "OK" : "Command failed.";
if (process.ExitCode == 0)
return new ServiceOperationResult(true, message);
return new ServiceOperationResult(false, message);
}
catch (Exception ex)
{
return new ServiceOperationResult(false, $"Failed to execute sc.exe: {ex.Message}");
}
}
}

View File

@ -0,0 +1,953 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MaksIT.UScheduler.ScheduleManager.Models;
using MaksIT.UScheduler.ScheduleManager.Services;
using MaksIT.UScheduler.Shared;
using MaksIT.UScheduler.Shared.Helpers;
namespace MaksIT.UScheduler.ScheduleManager.ViewModels;
public partial class MainViewModel : ObservableObject
{
/// <summary>
/// Returns true if the current process is running as administrator.
/// </summary>
public bool IsAdmin
{
get
{
try
{
var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
var principal = new System.Security.Principal.WindowsPrincipal(identity);
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
}
catch
{
return false;
}
}
}
private readonly ScriptSettingsService _scriptSettingsService;
private readonly UISettingsService _uiSettingsService;
private readonly AppSettingsService _appSettingsService;
private readonly ScriptStatusService _scriptStatusService;
private readonly LogViewerService _logViewerService;
private WindowsServiceManager _serviceManager;
private Configuration? _serviceConfig;
private ScriptSchedule? _originalSchedule;
public MainViewModel()
{
_scriptSettingsService = new ScriptSettingsService();
_uiSettingsService = new UISettingsService();
_appSettingsService = new AppSettingsService();
_scriptStatusService = new ScriptStatusService();
_logViewerService = new LogViewerService();
_serviceManager = new WindowsServiceManager();
// Initialize month/weekday options
MonthOptions = [
new MonthOption("January"), new MonthOption("February"), new MonthOption("March"),
new MonthOption("April"), new MonthOption("May"), new MonthOption("June"),
new MonthOption("July"), new MonthOption("August"), new MonthOption("September"),
new MonthOption("October"), new MonthOption("November"), new MonthOption("December")
];
WeekdayOptions = [
new WeekdayOption("Monday"), new WeekdayOption("Tuesday"), new WeekdayOption("Wednesday"),
new WeekdayOption("Thursday"), new WeekdayOption("Friday"), new WeekdayOption("Saturday"),
new WeekdayOption("Sunday")
];
// Load UI settings first (stored paths)
LoadUISettings();
// Initial load
RefreshScripts();
RefreshServiceStatus();
RefreshServiceLogs();
}
/// <summary>
/// Loads UI settings from the ScheduleManager's appsettings.json.
/// If paths are empty, tries auto-detection.
/// </summary>
private void LoadUISettings()
{
var uiSettings = _uiSettingsService.Load();
// Use stored bin path - no auto-detection, user will configure manually if empty
ServiceBinPath = uiSettings.ServiceBinPath ?? string.Empty;
// Load the UScheduler's appsettings.json to get service config
LoadServiceAppSettings();
}
/// <summary>
/// Gets the resolved service bin path (absolute path).
/// </summary>
private string ResolvedServiceBinPath => UISettingsService.ResolvePath(ServiceBinPath);
/// <summary>
/// Gets the resolved appsettings.json path from the service bin folder.
/// </summary>
private string ResolvedAppSettingsPath => string.IsNullOrEmpty(ResolvedServiceBinPath)
? string.Empty
: Path.Combine(ResolvedServiceBinPath, "appsettings.json");
/// <summary>
/// Gets the resolved executable path from the service bin folder.
/// </summary>
private string ResolvedExecutablePath => string.IsNullOrEmpty(ResolvedServiceBinPath)
? string.Empty
: Path.Combine(ResolvedServiceBinPath, "MaksIT.UScheduler.exe");
/// <summary>
/// Loads the UScheduler's appsettings.json from the path in ScheduleManager appsettings (ServiceBinPath).
/// LogDir is read from that file and evaluated against the UScheduler program path (ServiceBinPath).
/// </summary>
private void LoadServiceAppSettings()
{
// Path to UScheduler appsettings = path from ScheduleManager appsettings (ServiceBinPath) + appsettings.json
var uschedulerAppSettingsPath = ResolvedAppSettingsPath;
if (!string.IsNullOrEmpty(uschedulerAppSettingsPath) && File.Exists(uschedulerAppSettingsPath))
{
_serviceConfig = _appSettingsService.LoadAppSettings(uschedulerAppSettingsPath);
}
else
{
_serviceConfig = null;
}
if (_serviceConfig != null)
{
AppSettingsLoadError = null;
// Update service manager with correct service name
_serviceManager = new WindowsServiceManager(_serviceConfig.ServiceName);
ServiceName = _serviceConfig.ServiceName;
// Log dir: retrieve from MaksIT.UScheduler appsettings.json, show relative path in UI (e.g. ".\Logs")
// Configuration.LogDir is required, so it is always set when _serviceConfig is loaded.
LogDirectory = AppSettingsService.GetLogDirFromAppSettingsFile(uschedulerAppSettingsPath)
?? _serviceConfig.LogDir;
// Refresh process list for Processes Management tab
ProcessList.Clear();
foreach (var p in _serviceConfig.Processes)
ProcessList.Add(p);
}
else
{
// appsettings.json not loaded — surface error; do not use fake defaults
AppSettingsLoadError = string.IsNullOrEmpty(uschedulerAppSettingsPath) || !File.Exists(uschedulerAppSettingsPath)
? "Service Bin Path must be set and must point to a folder that contains appsettings.json."
: "Failed to load appsettings.json. Ensure the file is valid and contains required settings (e.g. LogDir).";
ServiceName = string.Empty;
LogDirectory = string.Empty;
ProcessList.Clear();
}
}
/// <summary>
/// Saves the current paths to the UI's appsettings.json.
/// </summary>
private void SaveUISettings()
{
var settings = new UISettings
{
ServiceBinPath = ServiceBinPath
};
_uiSettingsService.Save(settings);
}
#region AppSettings
[ObservableProperty]
private string _serviceBinPath = string.Empty;
[ObservableProperty]
private string _serviceName = "MaksIT.UScheduler";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AppSettingsLoadErrorVisibility))]
private string? _appSettingsLoadError;
/// <summary>
/// Visibility for the app settings load error message (Visible when <see cref="AppSettingsLoadError"/> is set).
/// </summary>
public Visibility AppSettingsLoadErrorVisibility =>
string.IsNullOrEmpty(AppSettingsLoadError) ? Visibility.Collapsed : Visibility.Visible;
[RelayCommand]
private void BrowseServiceBinPath()
{
var dialog = new Microsoft.Win32.OpenFolderDialog
{
Title = "Select MaksIT.UScheduler bin folder"
};
if (dialog.ShowDialog() == true)
{
ServiceBinPath = dialog.FolderName;
SaveUISettings();
LoadServiceAppSettings();
RefreshScripts();
RefreshServiceStatus();
RefreshServiceLogs();
}
}
[RelayCommand]
private void ReloadAppSettings()
{
LoadServiceAppSettings();
RefreshScripts();
RefreshServiceStatus();
RefreshServiceLogs();
}
[RelayCommand]
private void SavePaths()
{
SaveUISettings();
MessageBox.Show("Paths saved successfully.", "Save", MessageBoxButton.OK, MessageBoxImage.Information);
}
#endregion
#region Service Management
[ObservableProperty]
private ServiceStatus _serviceStatus = ServiceStatus.Unknown;
[ObservableProperty]
private string _serviceStatusText = "Unknown";
[ObservableProperty]
private string _serviceStatusColor = "Gray";
[RelayCommand]
private void RefreshServiceStatus()
{
ServiceStatus = _serviceManager.GetStatus(ResolvedExecutablePath);
(ServiceStatusText, ServiceStatusColor) = GetServiceStatusDisplay(ServiceStatus);
}
[RelayCommand]
private void StartService()
{
var result = _serviceManager.Start(ResolvedExecutablePath);
if (!result.Success)
{
MessageBox.Show(result.Message, "Start Service", MessageBoxButton.OK, MessageBoxImage.Warning);
}
RefreshServiceStatus();
}
[RelayCommand]
private void StopService()
{
var result = _serviceManager.Stop(ResolvedExecutablePath);
if (!result.Success)
{
MessageBox.Show(result.Message, "Stop Service", MessageBoxButton.OK, MessageBoxImage.Warning);
}
RefreshServiceStatus();
}
[RelayCommand]
private void RegisterService()
{
if (string.IsNullOrWhiteSpace(ServiceBinPath))
{
MessageBox.Show("Please specify the path to MaksIT.UScheduler bin folder", "Register Service",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var result = _serviceManager.Register(ResolvedExecutablePath);
MessageBox.Show(result.Message, "Register Service",
MessageBoxButton.OK, result.Success ? MessageBoxImage.Information : MessageBoxImage.Warning);
RefreshServiceStatus();
}
[RelayCommand]
private void UnregisterService()
{
var confirm = MessageBox.Show(
"Are you sure you want to unregister the service?",
"Unregister Service",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes)
return;
var result = _serviceManager.Unregister(ResolvedExecutablePath);
MessageBox.Show(result.Message, "Unregister Service",
MessageBoxButton.OK, result.Success ? MessageBoxImage.Information : MessageBoxImage.Warning);
RefreshServiceStatus();
}
private static (string statusText, string statusColor) GetServiceStatusDisplay(ServiceStatus status)
{
return status switch
{
ServiceStatus.Running => ("Running", "Green"),
ServiceStatus.Stopped => ("Stopped", "Red"),
ServiceStatus.StartPending => ("Starting...", "Orange"),
ServiceStatus.StopPending => ("Stopping...", "Orange"),
ServiceStatus.Paused => ("Paused", "Orange"),
ServiceStatus.NotInstalled => ("Not Installed", "Gray"),
_ => ("Unknown", "Gray")
};
}
#endregion
#region Schedule Management
[ObservableProperty]
private ObservableCollection<ScriptSchedule> _scripts = [];
/// <summary>
/// Process entries from service config (for Processes Management tab).
/// </summary>
[ObservableProperty]
private ObservableCollection<ProcessConfiguration> _processList = [];
[ObservableProperty]
private ScriptSchedule? _selectedScript;
[ObservableProperty]
private bool _hasSelectedScript;
/// <summary>Require script to be digitally signed (from service config).</summary>
[ObservableProperty]
private bool _scriptIsSigned = true;
/// <summary>Script disabled and will not be executed (from service config).</summary>
[ObservableProperty]
private bool _scriptDisabled;
/// <summary>Display name for the selected script (from service config; editable).</summary>
[ObservableProperty]
private string? _scriptName;
private bool _isSyncingSelection;
[ObservableProperty]
private bool _isDirty;
[ObservableProperty]
private ObservableCollection<MonthOption> _monthOptions = [];
[ObservableProperty]
private ObservableCollection<WeekdayOption> _weekdayOptions = [];
[ObservableProperty]
private ObservableCollection<string> _runTimes = [];
[ObservableProperty]
private string? _selectedRunTime;
[ObservableProperty]
private string _newRunTime = "00:00";
[ObservableProperty]
private int _minIntervalMinutes = 10;
partial void OnSelectedScriptChanged(ScriptSchedule? value)
{
HasSelectedScript = value != null;
LaunchScriptCommand.NotifyCanExecuteChanged();
_isSyncingSelection = true;
ScriptIsSigned = value?.IsSigned ?? true;
ScriptDisabled = value?.Disabled ?? false;
ScriptName = value?.Name ?? string.Empty;
_isSyncingSelection = false;
if (value != null)
{
LoadScheduleToUI(value);
_originalSchedule = CloneSchedule(value);
RefreshScriptStatus();
}
else
{
ClearUI();
_originalSchedule = null;
CurrentScriptStatus = null;
}
IsDirty = false;
}
partial void OnScriptIsSignedChanged(bool value)
{
if (_isSyncingSelection || SelectedScript == null)
return;
SelectedScript.IsSigned = value;
UpdateServiceConfigScriptEntry(SelectedScript.ConfigScriptPath, SelectedScript.Name, value, SelectedScript.Disabled);
}
partial void OnScriptDisabledChanged(bool value)
{
if (_isSyncingSelection || SelectedScript == null)
return;
SelectedScript.Disabled = value;
UpdateServiceConfigScriptEntry(SelectedScript.ConfigScriptPath, SelectedScript.Name, SelectedScript.IsSigned, value);
}
partial void OnScriptNameChanged(string? value)
{
if (_isSyncingSelection || SelectedScript == null)
return;
var name = value ?? string.Empty;
SelectedScript.Name = name;
UpdateServiceConfigScriptEntry(SelectedScript.ConfigScriptPath, name, SelectedScript.IsSigned, SelectedScript.Disabled);
}
private void UpdateServiceConfigScriptEntry(string configScriptPath, string? name, bool isSigned, bool disabled)
{
if (_serviceConfig == null)
return;
var entry = _serviceConfig.Powershell.FirstOrDefault(p => p.Path == configScriptPath);
if (entry != null)
{
if (name != null)
entry.Name = name;
entry.IsSigned = isSigned;
entry.Disabled = disabled;
}
_appSettingsService.UpdatePowershellScriptEntry(configScriptPath, name, isSigned, disabled);
}
partial void OnMinIntervalMinutesChanged(int value)
{
MarkDirty();
}
[RelayCommand]
private void RefreshScripts()
{
Scripts.Clear();
// Load scripts from service configuration (MaksIT.UScheduler appsettings.json)
if (_serviceConfig != null)
{
foreach (var psConfig in _serviceConfig.Powershell)
{
if (string.IsNullOrEmpty(psConfig.Path))
continue;
// Resolve relative path against service directory
var resolvedScriptPath = _appSettingsService.ResolvePathRelativeToService(psConfig.Path);
var scriptDir = Path.GetDirectoryName(resolvedScriptPath);
if (string.IsNullOrEmpty(scriptDir))
continue;
// scriptsettings.json should be in the same folder as the script
var settingsPath = Path.Combine(scriptDir, "scriptsettings.json");
if (File.Exists(settingsPath))
{
var schedule = _scriptSettingsService.LoadScheduleFromFile(settingsPath);
if (schedule != null)
{
schedule.ConfigScriptPath = psConfig.Path;
if (!string.IsNullOrWhiteSpace(psConfig.Name))
schedule.Name = psConfig.Name;
schedule.IsSigned = psConfig.IsSigned;
schedule.Disabled = psConfig.Disabled;
Scripts.Add(schedule);
}
}
}
}
}
/// <summary>
/// Launches the selected script via its .bat file in a separate window.
/// </summary>
[RelayCommand(CanExecute = nameof(CanLaunchScript))]
private void LaunchScript()
{
if (SelectedScript == null)
return;
var scriptDir = Path.GetDirectoryName(SelectedScript.FilePath);
if (string.IsNullOrEmpty(scriptDir) || !Directory.Exists(scriptDir))
{
MessageBox.Show("Script directory not found.", "Launch Script",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var batFiles = Directory.GetFiles(scriptDir, "*.bat");
if (batFiles.Length == 0)
{
MessageBox.Show($"No .bat file found in script folder.\n{scriptDir}", "Launch Script",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
try
{
var batPath = batFiles[0];
var startInfo = new ProcessStartInfo
{
FileName = batPath,
WorkingDirectory = scriptDir,
UseShellExecute = true,
CreateNoWindow = false
};
Process.Start(startInfo);
}
catch (Exception ex)
{
MessageBox.Show($"Failed to launch script: {ex.Message}", "Launch Script",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private bool CanLaunchScript() => SelectedScript != null;
[RelayCommand]
private void AddRunTime()
{
if (string.IsNullOrWhiteSpace(NewRunTime))
return;
// Validate time format
if (!TimeOnly.TryParse(NewRunTime, out var time))
{
MessageBox.Show("Invalid time format. Please use HH:mm format.", "Invalid Time",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var formattedTime = time.ToString("HH:mm");
if (!RunTimes.Contains(formattedTime))
{
RunTimes.Add(formattedTime);
MarkDirty();
}
NewRunTime = "00:00";
}
[RelayCommand]
private void RemoveRunTime()
{
if (SelectedRunTime != null)
{
RunTimes.Remove(SelectedRunTime);
MarkDirty();
}
}
[RelayCommand]
private void SaveSchedule()
{
if (SelectedScript == null)
return;
try
{
// Update the selected script from UI
UpdateScheduleFromUI(SelectedScript);
// Save to file
_scriptSettingsService.SaveScheduleToFile(SelectedScript);
// Update original for dirty tracking
_originalSchedule = CloneSchedule(SelectedScript);
IsDirty = false;
MessageBox.Show("Schedule saved successfully.", "Save",
MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show($"Failed to save schedule: {ex.Message}", "Save Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
[RelayCommand]
private void RevertSchedule()
{
if (_originalSchedule != null && SelectedScript != null)
{
// Copy original values back
SelectedScript.RunMonth = new List<string>(_originalSchedule.RunMonth);
SelectedScript.RunWeekday = new List<string>(_originalSchedule.RunWeekday);
SelectedScript.RunTime = new List<string>(_originalSchedule.RunTime);
SelectedScript.MinIntervalMinutes = _originalSchedule.MinIntervalMinutes;
LoadScheduleToUI(SelectedScript);
IsDirty = false;
}
}
public void OnMonthCheckedChanged()
{
MarkDirty();
}
public void OnWeekdayCheckedChanged()
{
MarkDirty();
}
private void LoadScheduleToUI(ScriptSchedule schedule)
{
// Load months
foreach (var month in MonthOptions)
{
month.IsChecked = schedule.RunMonth.Contains(month.Name);
}
// Load weekdays
foreach (var weekday in WeekdayOptions)
{
weekday.IsChecked = schedule.RunWeekday.Contains(weekday.Name);
}
// Load times
RunTimes = new ObservableCollection<string>(schedule.RunTime);
// Load interval
MinIntervalMinutes = schedule.MinIntervalMinutes;
}
private void UpdateScheduleFromUI(ScriptSchedule schedule)
{
schedule.RunMonth = MonthOptions.Where(m => m.IsChecked).Select(m => m.Name).ToList();
schedule.RunWeekday = WeekdayOptions.Where(w => w.IsChecked).Select(w => w.Name).ToList();
schedule.RunTime = RunTimes.ToList();
schedule.MinIntervalMinutes = MinIntervalMinutes;
}
private void ClearUI()
{
foreach (var month in MonthOptions) month.IsChecked = false;
foreach (var weekday in WeekdayOptions) weekday.IsChecked = false;
RunTimes.Clear();
MinIntervalMinutes = 10;
}
private void MarkDirty()
{
if (SelectedScript != null)
{
IsDirty = true;
}
}
private static ScriptSchedule CloneSchedule(ScriptSchedule source)
{
return new ScriptSchedule
{
FilePath = source.FilePath,
Name = source.Name,
Title = source.Title,
ConfigScriptPath = source.ConfigScriptPath,
IsSigned = source.IsSigned,
Disabled = source.Disabled,
RunMonth = new List<string>(source.RunMonth),
RunWeekday = new List<string>(source.RunWeekday),
RunTime = new List<string>(source.RunTime),
MinIntervalMinutes = source.MinIntervalMinutes
};
}
#endregion
#region Script Status (Lock File, Last Run)
[ObservableProperty]
private ScriptStatus? _currentScriptStatus;
[ObservableProperty]
private bool _hasLockFile;
[RelayCommand]
private void RefreshScriptStatus()
{
if (SelectedScript == null)
{
CurrentScriptStatus = null;
HasLockFile = false;
return;
}
CurrentScriptStatus = _scriptStatusService.GetScriptStatus(SelectedScript.FilePath);
HasLockFile = CurrentScriptStatus?.HasLockFile ?? false;
}
[RelayCommand]
private void RemoveLockFile()
{
if (CurrentScriptStatus == null || !CurrentScriptStatus.HasLockFile)
{
MessageBox.Show("No lock file exists.", "Remove Lock File",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var confirm = MessageBox.Show(
"Are you sure you want to remove the lock file?\n\n" +
"This should only be done if the script crashed or was interrupted.\n" +
"Removing the lock while the script is running may cause issues.",
"Remove Lock File",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (confirm != MessageBoxResult.Yes)
return;
if (_scriptStatusService.RemoveLockFile(CurrentScriptStatus.LockFilePath))
{
MessageBox.Show("Lock file removed successfully.", "Remove Lock File",
MessageBoxButton.OK, MessageBoxImage.Information);
RefreshScriptStatus();
}
else
{
MessageBox.Show("Failed to remove lock file.", "Remove Lock File",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
#endregion
#region Log Viewer
[ObservableProperty]
private string _logDirectory = string.Empty;
[ObservableProperty]
private ObservableCollection<LogFileInfo> _serviceLogs = [];
/// <summary>Script log folder names (e.g. file-sync, hyper-v-backup) under LogDir.</summary>
[ObservableProperty]
private ObservableCollection<string> _scriptLogFolders = [];
[ObservableProperty]
private string? _selectedScriptLogFolder;
[ObservableProperty]
private ObservableCollection<LogFileInfo> _scriptLogFiles = [];
[ObservableProperty]
private LogFileInfo? _selectedLogFile;
[ObservableProperty]
private string _logContent = string.Empty;
[ObservableProperty]
private int _selectedLogTab;
partial void OnSelectedScriptLogFolderChanged(string? value)
{
ScriptLogFiles.Clear();
SelectedLogFile = null;
if (string.IsNullOrEmpty(value))
return;
var resolvedLogDir = ResolvedLogDirectory;
if (string.IsNullOrEmpty(resolvedLogDir))
return;
var logs = _logViewerService.GetScriptLogs(resolvedLogDir, value);
foreach (var log in logs)
{
ScriptLogFiles.Add(log);
}
}
partial void OnSelectedLogFileChanged(LogFileInfo? value)
{
if (value != null)
{
LoadLogContent(value.FullPath);
}
else
{
LogContent = string.Empty;
}
}
[RelayCommand]
private void BrowseLogDirectory()
{
var dialog = new Microsoft.Win32.OpenFolderDialog
{
Title = "Select Log Directory"
};
if (dialog.ShowDialog() == true)
{
LogDirectory = dialog.FolderName;
RefreshServiceLogs();
RefreshScriptLogs();
}
}
/// <summary>
/// Gets the resolved log directory path for reading logs. LogDir (e.g. .\logs) is always resolved
/// against ServiceBinPath so the result is e.g. ..\..\..\..\MaksIT.UScheduler\bin\Debug\net10.0\win-x64\logs.
/// </summary>
private string ResolvedLogDirectory
{
get
{
if (string.IsNullOrEmpty(ResolvedServiceBinPath))
return string.Empty;
// Configuration.LogDir is required; fallback only when config is not loaded.
var logDir = _serviceConfig != null ? _serviceConfig.LogDir : ".\\logs";
return PathHelper.ResolvePath(logDir, ResolvedServiceBinPath);
}
}
[RelayCommand]
private void RefreshServiceLogs()
{
ServiceLogs.Clear();
var resolvedLogDir = ResolvedLogDirectory;
if (string.IsNullOrEmpty(resolvedLogDir))
return;
var logs = _logViewerService.GetServiceLogs(resolvedLogDir);
foreach (var log in logs)
{
ServiceLogs.Add(log);
}
// Also refresh script logs so the Script Logs tab stays in sync
RefreshScriptLogs();
}
[RelayCommand]
private void RefreshScriptLogs()
{
ScriptLogFolders.Clear();
SelectedScriptLogFolder = null;
ScriptLogFiles.Clear();
SelectedLogFile = null;
var resolvedLogDir = ResolvedLogDirectory;
if (string.IsNullOrEmpty(resolvedLogDir))
return;
var folderNames = _logViewerService.GetScriptLogFolderNames(resolvedLogDir);
foreach (var name in folderNames)
{
ScriptLogFolders.Add(name);
}
if (ScriptLogFolders.Count > 0)
{
SelectedScriptLogFolder = ScriptLogFolders[0];
}
}
[RelayCommand]
private void RefreshLogContent()
{
if (SelectedLogFile != null)
{
LoadLogContent(SelectedLogFile.FullPath);
}
}
[RelayCommand]
private void OpenLogInExplorer()
{
if (SelectedLogFile == null)
return;
try
{
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{SelectedLogFile.FullPath}\"");
}
catch (Exception ex)
{
MessageBox.Show($"Failed to open explorer: {ex.Message}", "Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
[RelayCommand]
private void OpenLogDirectory()
{
var resolvedLogDir = ResolvedLogDirectory;
if (string.IsNullOrEmpty(resolvedLogDir) || !Directory.Exists(resolvedLogDir))
{
MessageBox.Show("Log directory not found.", "Error",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
try
{
System.Diagnostics.Process.Start("explorer.exe", resolvedLogDir);
}
catch (Exception ex)
{
MessageBox.Show($"Failed to open explorer: {ex.Message}", "Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void LoadLogContent(string filePath)
{
LogContent = _logViewerService.ReadLogFile(filePath);
}
#endregion
}
/// <summary>
/// Represents a month checkbox option.
/// </summary>
public partial class MonthOption : ObservableObject
{
public string Name { get; }
[ObservableProperty]
private bool _isChecked;
public MonthOption(string name)
{
Name = name;
}
}
/// <summary>
/// Represents a weekday checkbox option.
/// </summary>
public partial class WeekdayOption : ObservableObject
{
public string Name { get; }
[ObservableProperty]
private bool _isChecked;
public WeekdayOption(string name)
{
Name = name;
}
}

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MaksIT.UScheduler.ScheduleManager"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- UAC Manifest Options
If you want to change the Windows User Account Control level replace the
requestedExecutionLevel node with one of the following.
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
-->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>

View File

@ -0,0 +1,5 @@
{
"USchedulerSettings": {
"ServiceBinPath": "..\\..\\..\\..\\MaksIT.UScheduler\\bin\\Debug\\net10.0\\win-x64"
}
}

View File

@ -0,0 +1,78 @@
namespace MaksIT.UScheduler.Shared;
/// <summary>
/// Base configuration class for scheduled tasks.
/// </summary>
public abstract class TaskConfiguration
{
/// <summary>
/// Gets or sets the path to the executable or script.
/// </summary>
public required string Path { get; set; }
/// <summary>
/// Gets or sets whether this task is disabled. Disabled tasks will not be executed.
/// </summary>
public bool Disabled { get; set; }
}
/// <summary>
/// Configuration for a scheduled PowerShell script.
/// </summary>
public class PowershellScript : TaskConfiguration
{
/// <summary>
/// Gets or sets the display name for the script in the UI.
/// When set in appsettings, overrides the name from scriptsettings.json.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets whether the script must be digitally signed.
/// When true, only scripts with valid Authenticode signatures will be executed.
/// </summary>
public bool IsSigned { get; set; } = true;
}
/// <summary>
/// Configuration for a scheduled process/executable.
/// </summary>
public class ProcessConfiguration : TaskConfiguration
{
/// <summary>
/// Gets or sets the command-line arguments to pass to the process.
/// </summary>
public string[]? Args { get; set; }
/// <summary>
/// Gets or sets whether the process should automatically restart on failure.
/// </summary>
public bool RestartOnFailure { get; set; }
}
/// <summary>
/// Root configuration class for the UScheduler service.
/// </summary>
public class Configuration
{
/// <summary>
/// Gets or sets the Windows service name used for registration.
/// </summary>
public string ServiceName { get; set; } = "MaksIT.UScheduler";
/// <summary>
/// Gets or sets the directory path for log files (required).
/// May be relative to the application base directory (e.g. ".\Logs") or absolute.
/// </summary>
public required string LogDir { get; set; }
/// <summary>
/// Gets or sets the list of PowerShell scripts to execute.
/// </summary>
public List<PowershellScript> Powershell { get; set; } = [];
/// <summary>
/// Gets or sets the list of processes/executables to run.
/// </summary>
public List<ProcessConfiguration> Processes { get; set; } = [];
}

View File

@ -0,0 +1,20 @@
using MaksIT.Core.Logging;
using Microsoft.Extensions.Logging;
namespace MaksIT.UScheduler.Shared.Extensions;
public static class LoggerFactoryExtensions
{
/// <summary>
/// Creates a logger that logs to a dedicated subfolder based on the file name.
/// Uses the Folder: prefix pattern from MaksIT.Core.Logging.FileLoggerProvider.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="filePath">The file path to extract the folder name from.</param>
/// <returns>An ILogger instance configured for folder-based logging.</returns>
public static ILogger CreateFolderLogger(this ILoggerFactory loggerFactory, string filePath)
{
var fileName = Path.GetFileNameWithoutExtension(filePath);
return loggerFactory.CreateLogger($"{LoggerPrefix.Folder}{fileName}");
}
}

View File

@ -0,0 +1,37 @@
namespace MaksIT.UScheduler.Shared.Helpers;
/// <summary>
/// Resolves relative and absolute paths. Relative paths are resolved against a base directory;
/// absolute (rooted) paths are returned unchanged.
/// </summary>
public static class PathHelper
{
/// <summary>
/// Resolves a path against the application base directory.
/// If the path is null, empty, or already rooted (absolute), returns it unchanged.
/// </summary>
/// <param name="path">The path to resolve.</param>
/// <returns>The fully resolved absolute path, or the original value if rooted/null/empty.</returns>
public static string ResolvePath(string path)
{
return ResolvePath(path, AppDomain.CurrentDomain.BaseDirectory);
}
/// <summary>
/// Resolves a path against the specified base directory.
/// If the path is null, empty, or already rooted (absolute), returns it unchanged.
/// </summary>
/// <param name="path">The path to resolve.</param>
/// <param name="baseDirectory">The base directory for relative path resolution.</param>
/// <returns>The fully resolved absolute path, or the original path if rooted/null/empty or base is null/empty.</returns>
public static string ResolvePath(string path, string baseDirectory)
{
if (string.IsNullOrEmpty(path) || Path.IsPathRooted(path))
return path;
if (string.IsNullOrEmpty(baseDirectory))
return path;
return Path.GetFullPath(Path.Combine(baseDirectory, path));
}
}

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MaksIT.UScheduler.Shared</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.6.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,210 @@
using MaksIT.UScheduler;
using MaksIT.UScheduler.BackgroundServices;
using MaksIT.UScheduler.Services;
using MaksIT.UScheduler.Shared;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace MaksIT.UScheduler.Tests.BackgroundServices;
/// <summary>
/// Unit tests for PSScriptBackgroundService.
/// </summary>
public class PSScriptBackgroundServiceTests {
private readonly Mock<ILogger<PSScriptBackgroundService>> _loggerMock;
private readonly Mock<IPSScriptService> _psScriptServiceMock;
public PSScriptBackgroundServiceTests() {
_loggerMock = new Mock<ILogger<PSScriptBackgroundService>>();
_psScriptServiceMock = new Mock<IPSScriptService>();
}
private PSScriptBackgroundService CreateService(Configuration config) {
var options = Options.Create(config);
return new PSScriptBackgroundService(_loggerMock.Object, options, _psScriptServiceMock.Object);
}
[Fact]
public async Task ExecuteAsync_WithNoScripts_DoesNotCallRunScriptAsync() {
// Arrange
var config = new Configuration { LogDir = ".\\Logs", Powershell = [] };
var service = CreateService(config);
using var cts = new CancellationTokenSource();
// Act
cts.Cancel();
await service.StartAsync(cts.Token);
// Assert
_psScriptServiceMock.Verify(
x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_WithDisabledScript_DoesNotRunDisabledScript() {
// Arrange
var config = new Configuration {
LogDir = ".\\Logs",
Powershell = [
new PowershellScript { Path = @"C:\Scripts\test.ps1", Disabled = true }
]
};
var service = CreateService(config);
using var cts = new CancellationTokenSource();
// Act
cts.Cancel();
await service.StartAsync(cts.Token);
// Assert
_psScriptServiceMock.Verify(
x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_WithEmptyPath_DoesNotRunScript() {
// Arrange
var config = new Configuration {
LogDir = ".\\Logs",
Powershell = [ new PowershellScript { Path = "" } ]
};
var service = CreateService(config);
using var cts = new CancellationTokenSource();
// Act
cts.Cancel();
await service.StartAsync(cts.Token);
// Assert
_psScriptServiceMock.Verify(
x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task StopAsync_TerminatesAllScripts() {
// Arrange
var config = new Configuration { LogDir = ".\\Logs" };
var service = CreateService(config);
// Act
await service.StopAsync(CancellationToken.None);
// Assert
_psScriptServiceMock.Verify(x => x.TerminateAllScripts(), Times.Once);
}
[Fact]
public async Task ExecuteAsync_WithEnabledScript_CallsRunScriptAsync() {
// Arrange
var config = new Configuration {
LogDir = ".\\Logs",
Powershell = [
new PowershellScript { Path = @"C:\Scripts\test.ps1", Disabled = false, IsSigned = true }
]
};
var service = CreateService(config);
using var cts = new CancellationTokenSource();
_psScriptServiceMock
.Setup(x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
var executeTask = service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
try {
await service.StopAsync(CancellationToken.None);
}
catch (OperationCanceledException) {
// Expected
}
// Assert
_psScriptServiceMock.Verify(
x => x.RunScriptAsync(@"C:\Scripts\test.ps1", true, It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_WithUnsignedScript_PassesIsSignedFalse() {
// Arrange
var config = new Configuration {
LogDir = ".\\Logs",
Powershell = [
new PowershellScript { Path = @"C:\Scripts\test.ps1", Disabled = false, IsSigned = false }
]
};
var service = CreateService(config);
using var cts = new CancellationTokenSource();
_psScriptServiceMock
.Setup(x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
var executeTask = service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
try {
await service.StopAsync(CancellationToken.None);
}
catch (OperationCanceledException) {
// Expected
}
// Assert - verify IsSigned=false was passed
_psScriptServiceMock.Verify(
x => x.RunScriptAsync(@"C:\Scripts\test.ps1", false, It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_WithMultipleScripts_RunsAllEnabledScripts() {
// Arrange
var config = new Configuration {
LogDir = ".\\Logs",
Powershell = [
new PowershellScript { Path = @"C:\Scripts\script1.ps1", Disabled = false },
new PowershellScript { Path = @"C:\Scripts\script2.ps1", Disabled = true },
new PowershellScript { Path = @"C:\Scripts\script3.ps1", Disabled = false }
]
};
var service = CreateService(config);
using var cts = new CancellationTokenSource();
_psScriptServiceMock
.Setup(x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
var executeTask = service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
try {
await service.StopAsync(CancellationToken.None);
}
catch (OperationCanceledException) {
// Expected
}
// Assert - only enabled scripts should run
_psScriptServiceMock.Verify(
x => x.RunScriptAsync(@"C:\Scripts\script1.ps1", It.IsAny<bool>(), It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
_psScriptServiceMock.Verify(
x => x.RunScriptAsync(@"C:\Scripts\script2.ps1", It.IsAny<bool>(), It.IsAny<CancellationToken>()),
Times.Never);
_psScriptServiceMock.Verify(
x => x.RunScriptAsync(@"C:\Scripts\script3.ps1", It.IsAny<bool>(), It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
}
}

View File

@ -0,0 +1,179 @@
using MaksIT.UScheduler;
using MaksIT.UScheduler.BackgroundServices;
using MaksIT.UScheduler.Services;
using MaksIT.UScheduler.Shared;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace MaksIT.UScheduler.Tests.BackgroundServices;
/// <summary>
/// Unit tests for ProcessBackgroundService.
/// </summary>
public class ProcessBackgroundServiceTests {
private readonly Mock<ILogger<ProcessBackgroundService>> _loggerMock;
private readonly Mock<IProcessService> _processServiceMock;
public ProcessBackgroundServiceTests() {
_loggerMock = new Mock<ILogger<ProcessBackgroundService>>();
_processServiceMock = new Mock<IProcessService>();
}
private ProcessBackgroundService CreateService(Configuration config) {
var options = Options.Create(config);
return new ProcessBackgroundService(_loggerMock.Object, options, _processServiceMock.Object);
}
[Fact]
public async Task ExecuteAsync_WithNoProcesses_DoesNotCallRunProcessAsync() {
// Arrange
var config = new Configuration { LogDir = ".\\Logs", Processes = [] };
var service = CreateService(config);
using var cts = new CancellationTokenSource();
// Act - cancel immediately to stop the service
cts.Cancel();
// Start the service - it should complete without errors
await service.StartAsync(cts.Token);
// Assert
_processServiceMock.Verify(
x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_WithDisabledProcess_DoesNotRunDisabledProcess() {
// Arrange
var config = new Configuration {
LogDir = ".\\Logs",
Processes = [
new ProcessConfiguration { Path = @"C:\app.exe", Disabled = true }
]
};
var service = CreateService(config);
using var cts = new CancellationTokenSource();
// Act
cts.Cancel();
await service.StartAsync(cts.Token);
// Assert - disabled processes should not be run
_processServiceMock.Verify(
x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ExecuteAsync_WithEmptyPath_DoesNotRunProcess() {
// Arrange
var config = new Configuration {
LogDir = ".\\Logs",
Processes = [ new ProcessConfiguration { Path = "" } ]
};
var service = CreateService(config);
using var cts = new CancellationTokenSource();
// Act
cts.Cancel();
await service.StartAsync(cts.Token);
// Assert - processes with empty paths should not be run
_processServiceMock.Verify(
x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task StopAsync_TerminatesAllProcesses() {
// Arrange
var config = new Configuration { LogDir = ".\\Logs" };
var service = CreateService(config);
// Act
await service.StopAsync(CancellationToken.None);
// Assert
_processServiceMock.Verify(x => x.TerminateAllProcesses(), Times.Once);
}
[Fact]
public async Task ExecuteAsync_WithEnabledProcess_CallsRunProcessAsync() {
// Arrange
var config = new Configuration {
LogDir = ".\\Logs",
Processes = [
new ProcessConfiguration { Path = @"C:\app.exe", Disabled = false }
]
};
var service = CreateService(config);
using var cts = new CancellationTokenSource();
// Setup mock to complete immediately
_processServiceMock
.Setup(x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act - start the service then cancel after a short delay
var executeTask = service.StartAsync(cts.Token);
// Give it a moment to start processing, then cancel
await Task.Delay(100);
cts.Cancel();
// Wait for the service to stop gracefully
try {
await service.StopAsync(CancellationToken.None);
}
catch (OperationCanceledException) {
// Expected when cancelling
}
// Assert - the enabled process should have been run at least once
_processServiceMock.Verify(
x => x.RunProcessAsync(@"C:\app.exe", It.IsAny<string[]?>(), It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
}
[Fact]
public async Task ExecuteAsync_WithProcessArgs_PassesArgsToService() {
// Arrange
var expectedArgs = new[] { "--verbose", "--log" };
var config = new Configuration {
LogDir = ".\\Logs",
Processes = [
new ProcessConfiguration {
Path = @"C:\app.exe",
Disabled = false,
Args = expectedArgs
}
]
};
var service = CreateService(config);
using var cts = new CancellationTokenSource();
_processServiceMock
.Setup(x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
var executeTask = service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
try {
await service.StopAsync(CancellationToken.None);
}
catch (OperationCanceledException) {
// Expected
}
// Assert - verify args were passed correctly
_processServiceMock.Verify(
x => x.RunProcessAsync(@"C:\app.exe", expectedArgs, It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
}
}

View File

@ -0,0 +1,155 @@
using MaksIT.UScheduler.Shared;
using Xunit;
namespace MaksIT.UScheduler.Tests;
/// <summary>
/// Unit tests for configuration classes.
/// </summary>
public class ConfigurationTests {
private const string TestLogDir = ".\\Logs";
[Fact]
public void Configuration_DefaultValues_AreCorrect() {
// Arrange & Act
var config = new Configuration { LogDir = TestLogDir };
// Assert
Assert.Equal("MaksIT.UScheduler", config.ServiceName);
Assert.Equal(TestLogDir, config.LogDir);
Assert.NotNull(config.Powershell);
Assert.Empty(config.Powershell);
Assert.NotNull(config.Processes);
Assert.Empty(config.Processes);
}
[Fact]
public void Configuration_CanSetServiceName() {
// Arrange
var config = new Configuration { LogDir = TestLogDir };
// Act
config.ServiceName = "CustomServiceName";
// Assert
Assert.Equal("CustomServiceName", config.ServiceName);
}
[Fact]
public void Configuration_CanSetLogDir() {
// Arrange
var config = new Configuration { LogDir = TestLogDir };
// Act
config.LogDir = @"C:\Logs";
// Assert
Assert.Equal(@"C:\Logs", config.LogDir);
}
[Fact]
public void Configuration_CanAddPowerShellScripts() {
// Arrange
var config = new Configuration { LogDir = TestLogDir };
var script = new PowershellScript { Path = @"C:\Scripts\test.ps1" };
// Act
config.Powershell.Add(script);
// Assert
Assert.Single(config.Powershell);
Assert.Equal(@"C:\Scripts\test.ps1", config.Powershell[0].Path);
}
[Fact]
public void Configuration_CanAddProcesses() {
// Arrange
var config = new Configuration { LogDir = TestLogDir };
var process = new ProcessConfiguration {
Path = @"C:\App\app.exe",
Args = ["--verbose", "--output", "log.txt"]
};
// Act
config.Processes.Add(process);
// Assert
Assert.Single(config.Processes);
Assert.Equal(@"C:\App\app.exe", config.Processes[0].Path);
Assert.Equal(3, config.Processes[0].Args!.Length);
}
[Fact]
public void PowershellScript_DefaultValues_AreCorrect() {
// Arrange & Act
var script = new PowershellScript { Path = @"C:\test.ps1" };
// Assert
Assert.Equal(@"C:\test.ps1", script.Path);
Assert.True(script.IsSigned); // Default should be true
Assert.False(script.Disabled); // Default should be false
}
[Fact]
public void PowershellScript_CanSetIsSigned() {
// Arrange
var script = new PowershellScript { Path = @"C:\test.ps1" };
// Act
script.IsSigned = false;
// Assert
Assert.False(script.IsSigned);
}
[Fact]
public void PowershellScript_CanSetDisabled() {
// Arrange
var script = new PowershellScript { Path = @"C:\test.ps1" };
// Act
script.Disabled = true;
// Assert
Assert.True(script.Disabled);
}
[Fact]
public void ProcessConfiguration_DefaultValues_AreCorrect() {
// Arrange & Act
var process = new ProcessConfiguration { Path = @"C:\app.exe" };
// Assert
Assert.Equal(@"C:\app.exe", process.Path);
Assert.Null(process.Args);
Assert.False(process.RestartOnFailure); // Default should be false
Assert.False(process.Disabled); // Default should be false
}
[Fact]
public void ProcessConfiguration_CanSetArgs() {
// Arrange
var process = new ProcessConfiguration { Path = @"C:\app.exe" };
// Act
process.Args = ["arg1", "arg2"];
// Assert
Assert.NotNull(process.Args);
Assert.Equal(2, process.Args.Length);
Assert.Equal("arg1", process.Args[0]);
Assert.Equal("arg2", process.Args[1]);
}
[Fact]
public void ProcessConfiguration_CanSetRestartOnFailure() {
// Arrange
var process = new ProcessConfiguration { Path = @"C:\app.exe" };
// Act
process.RestartOnFailure = true;
// Assert
Assert.True(process.RestartOnFailure);
}
}

View File

@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.*" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.*" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MaksIT.UScheduler\MaksIT.UScheduler.csproj" />
<ProjectReference Include="..\MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj" />
</ItemGroup>
</Project>

View File

@ -1,10 +1,15 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.0.11222.15 d18.0
VisualStudioVersion = 18.0.11222.15
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler", "MaksIT.UScheduler\MaksIT.UScheduler.csproj", "{DE1F347C-D201-42E2-8D22-924508FD30AA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaksIT.UScheduler.Tests", "MaksIT.UScheduler.Tests\MaksIT.UScheduler.Tests.csproj", "{DC193ABC-89F8-131B-060F-6C3A3CE2652A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler.ScheduleManager", "MaksIT.UScheduler.ScheduleManager\MaksIT.UScheduler.ScheduleManager.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler.Shared", "MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -15,6 +20,18 @@ Global
{DE1F347C-D201-42E2-8D22-924508FD30AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DE1F347C-D201-42E2-8D22-924508FD30AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DE1F347C-D201-42E2-8D22-924508FD30AA}.Release|Any CPU.Build.0 = Release|Any CPU
{DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -1,25 +1,42 @@
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Options;
using MaksIT.UScheduler.Services;
using MaksIT.UScheduler.Shared;
namespace MaksIT.UScheduler.BackgroundServices;
/// <summary>
/// Background service that manages the execution of configured PowerShell scripts.
/// Continuously monitors and launches scripts based on the application configuration.
/// </summary>
public sealed class PSScriptBackgroundService : BackgroundService {
private readonly ILogger<PSScriptBackgroundService> _logger;
private readonly Configuration _configuration;
private readonly PSScriptService _psScriptService;
private readonly IPSScriptService _psScriptService;
/// <summary>
/// Initializes a new instance of the <see cref="PSScriptBackgroundService"/> class.
/// </summary>
/// <param name="logger">The logger instance for this service.</param>
/// <param name="options">The configuration options containing PowerShell script definitions.</param>
/// <param name="psScriptService">The PowerShell script service for executing scripts.</param>
public PSScriptBackgroundService(
ILogger<PSScriptBackgroundService> logger,
IOptions<Configuration> options,
PSScriptService psScriptService
IPSScriptService psScriptService
) {
_logger = logger;
_configuration = options.Value;
_psScriptService = psScriptService;
}
/// <summary>
/// Executes the background service, continuously launching configured PowerShell scripts in parallel.
/// Scripts are checked and launched every 10 seconds.
/// </summary>
/// <param name="stoppingToken">Cancellation token that signals when the service should stop.</param>
/// <returns>A task representing the background operation.</returns>
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
_logger.LogInformation("Starting PSScriptBackgroundService");
@ -29,14 +46,19 @@ public sealed class PSScriptBackgroundService : BackgroundService {
while (!stoppingToken.IsCancellationRequested) {
_logger.LogInformation("Checking for PowerShell scripts to run");
foreach (var psScript in psScripts) {
var scriptPath = psScript.Path;
// Launch all enabled scripts in parallel
var scriptTasks = psScripts
.Where(psScript => !psScript.Disabled && !string.IsNullOrEmpty(psScript.Path))
.Select(psScript => {
_logger.LogInformation($"Launching PowerShell script {psScript.Path}");
return _psScriptService.RunScriptAsync(psScript.Path, psScript.IsSigned, stoppingToken);
})
.ToList();
if (scriptPath == string.Empty)
continue;
_logger.LogInformation($"Running PowerShell script {scriptPath}");
_psScriptService.RunScript(scriptPath, psScript.IsSigned, stoppingToken);
if (scriptTasks.Count > 0) {
_logger.LogInformation($"Waiting for {scriptTasks.Count} PowerShell script(s) to complete");
await Task.WhenAll(scriptTasks);
_logger.LogInformation("All PowerShell scripts completed");
}
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
@ -63,12 +85,19 @@ public sealed class PSScriptBackgroundService : BackgroundService {
}
/// <summary>
/// Stops the background service and terminates all running PowerShell scripts.
/// </summary>
/// <param name="stoppingToken">Cancellation token for the stop operation.</param>
/// <returns>A task representing the stop operation.</returns>
public override Task StopAsync(CancellationToken stoppingToken) {
// Perform cleanup tasks here
_logger.LogInformation("Stopping PSScriptBackgroundService");
_psScriptService.TerminateAllScripts();
_logger.LogInformation("PSScriptBackgroundService stopped");
return Task.CompletedTask;
return base.StopAsync(stoppingToken);
}
}

View File

@ -1,25 +1,42 @@
using Microsoft.Extensions.Options;
using MaksIT.UScheduler.Services;
using MaksIT.UScheduler.Shared;
namespace MaksIT.UScheduler.BackgroundServices;
/// <summary>
/// Background service that manages the execution of configured external processes.
/// Continuously monitors and launches processes based on the application configuration.
/// </summary>
public sealed class ProcessBackgroundService : BackgroundService {
private readonly ILogger<ProcessBackgroundService> _logger;
private readonly Configuration _configuration;
private readonly ProcessService _processService;
private readonly IProcessService _processService;
/// <summary>
/// Initializes a new instance of the <see cref="ProcessBackgroundService"/> class.
/// </summary>
/// <param name="logger">The logger instance for this service.</param>
/// <param name="options">The configuration options containing process definitions.</param>
/// <param name="processService">The process service for executing processes.</param>
public ProcessBackgroundService(
ILogger<ProcessBackgroundService> logger,
IOptions<Configuration> options,
ProcessService processService
IProcessService processService
) {
_logger = logger;
_configuration = options.Value;
_processService = processService;
}
/// <summary>
/// Executes the background service, continuously launching configured processes in parallel.
/// Processes are checked and launched every 10 seconds.
/// </summary>
/// <param name="stoppingToken">Cancellation token that signals when the service should stop.</param>
/// <returns>A task representing the background operation.</returns>
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
_logger.LogInformation("Starting ProcessBackgroundService");
@ -29,15 +46,20 @@ public sealed class ProcessBackgroundService : BackgroundService {
while (!stoppingToken.IsCancellationRequested) {
_logger.LogInformation("Checking for processes to run");
foreach (var process in processes) {
var processPath = process.Path;
var processArgs = process.Args;
// Launch all enabled processes in parallel
var processTasks = processes
.Where(process => !process.Disabled && !string.IsNullOrEmpty(process.Path))
.Select(process => {
var argsString = process.Args != null ? string.Join(", ", process.Args) : "";
_logger.LogInformation($"Launching process {process.Path} with arguments {argsString}");
return _processService.RunProcessAsync(process.Path, process.Args, stoppingToken);
})
.ToList();
if (processPath == string.Empty)
continue;
_logger.LogInformation($"Running process {processPath} with arguments {string.Join(", ", processArgs)}");
_processService.RunProcess(processPath, processArgs, stoppingToken);
if (processTasks.Count > 0) {
_logger.LogInformation($"Waiting for {processTasks.Count} process(es) to complete");
await Task.WhenAll(processTasks);
_logger.LogInformation("All processes completed");
}
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
@ -63,12 +85,19 @@ public sealed class ProcessBackgroundService : BackgroundService {
}
}
/// <summary>
/// Stops the background service and terminates all running processes.
/// </summary>
/// <param name="stoppingToken">Cancellation token for the stop operation.</param>
/// <returns>A task representing the stop operation.</returns>
public override Task StopAsync(CancellationToken stoppingToken) {
// Perform cleanup tasks here
_logger.LogInformation("Stopping ProcessBackgroundService");
_processService.TerminateAllProcesses();
_logger.LogInformation("All processes terminated");
return Task.CompletedTask;
return base.StopAsync(stoppingToken);
}
}

View File

@ -1,20 +0,0 @@
namespace MaksIT.UScheduler;
public class PowershellScript {
public required string Path { get; set; }
public bool IsSigned { get; set; } = false;
}
public class ProcessConfiguration {
public required string Path { get; set; }
public string[]? Args { get; set; }
public bool RestartOnFailure { get; set; } = false;
}
public class Configuration {
public string ServiceName { get; set; } = "MaksIT.UScheduler";
public string? LogDir { get; set; }
public List<PowershellScript> Powershell { get; set; } = [];
public List<ProcessConfiguration> Processes { get; set; } = [];
}

View File

@ -1,3 +0,0 @@
sc.exe create "MaksIT.UScheduler Service" binpath="%~dp0MaksIT.UScheduler.exe"
sc description "MaksIT.UScheduler Service" "Windows service, which allows you to schedule and invoke PowerShell Scripts and Processes"
pause

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Maksym Sadovnychyy (Maks-IT)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>1.0.0</Version>
<Version>1.0.1</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66</UserSecretsId>
@ -13,15 +13,19 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.6.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
<ProjectReference Include="..\MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.6.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.2" />
<PackageReference Include="Microsoft.PowerShell.Commands.Diagnostics" Version="7.5.4" />
<PackageReference Include="Microsoft.PowerShell.Commands.Management" Version="7.5.4" />
<PackageReference Include="Microsoft.PowerShell.Commands.Utility" Version="7.5.4" />
<PackageReference Include="Microsoft.PowerShell.ConsoleHost" Version="7.5.3" />
<PackageReference Include="Microsoft.WSMan.Management" Version="7.5.4" />
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.0" />
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.2" />
<PackageReference Include="System.Management.Automation" Version="7.5.4" />
</ItemGroup>
@ -30,19 +34,11 @@
</ItemGroup>
<ItemGroup>
<None Update="CHANGELOG.md">
<None Include="..\..\README.md;..\..\LICENSE.md;..\..\CHANGELOG.md">
<Link>%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Install.cmd">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="LICENSE.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="README.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Uninstall.cmd">
<None Include="..\..\badges\**\*" Link="badges\%(RecursiveDir)%(Filename)%(Extension)">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

View File

@ -1,24 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaksIT.UScheduler", "MaksIT.UScheduler.csproj", "{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F6EE17FC-4D3A-420D-B534-433875D783B3}
EndGlobalSection
EndGlobal

View File

@ -2,12 +2,20 @@ using MaksIT.Core.Logging;
using MaksIT.UScheduler;
using MaksIT.UScheduler.BackgroundServices;
using MaksIT.UScheduler.Services;
using MaksIT.UScheduler.Shared;
using Microsoft.Extensions.Logging.Configuration;
using Microsoft.Extensions.Logging.EventLog;
using System.Diagnostics;
using System.Runtime.InteropServices;
// read configuration from appsettings.json
// OS Guard - This application only supports Windows
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
Console.WriteLine("Error: MaksIT.UScheduler only supports Windows.");
return 1;
}
// Read configuration from appsettings.json
var configurationRoot = new ConfigurationBuilder()
.SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
@ -15,7 +23,46 @@ var configurationRoot = new ConfigurationBuilder()
// Configure strongly typed settings objects
var configurationSection = configurationRoot.GetSection("Configuration");
var appSettings = configurationSection.Get<Configuration>() ?? throw new ArgumentNullException();
var appSettings = configurationSection.Get<Configuration>() ?? throw new InvalidOperationException("Configuration section is missing.");
if (string.IsNullOrWhiteSpace(appSettings.LogDir))
throw new InvalidOperationException("Configuration.LogDir is required. Set it in appsettings.json (e.g. \".\\Logs\").");
// Handle command-line arguments for service management
if (args.Length > 0) {
var command = args[0].ToLowerInvariant();
var exePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "MaksIT.UScheduler.exe");
var serviceName = appSettings.ServiceName;
var serviceDescription = "Windows service that allows you to schedule and invoke PowerShell Scripts and Processes";
switch (command) {
case "--install":
case "-i":
return InstallService(serviceName, exePath, serviceDescription);
case "--uninstall":
case "-u":
return UninstallService(serviceName);
case "--start":
return StartService(serviceName);
case "--stop":
return StopService(serviceName);
case "--status":
return GetServiceStatus(serviceName);
case "--help":
case "-h":
case "/?":
PrintHelp(serviceName);
return 0;
default:
// If not a recognized command, continue with normal startup
break;
}
}
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(options => {
@ -25,10 +72,11 @@ builder.Services.AddWindowsService(options => {
// Allow configurations to be available through IOptions<Configuration>
builder.Services.Configure<Configuration>(configurationSection);
// Logging
var logPath = !string.IsNullOrEmpty(appSettings.LogDir)
? Path.Combine(appSettings.LogDir)
: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
// Logging: resolve LogDir (required); relative paths are resolved against application base directory
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
var logPath = Path.IsPathRooted(appSettings.LogDir)
? appSettings.LogDir
: Path.GetFullPath(Path.Combine(baseDir, appSettings.LogDir));
if (!Directory.Exists(logPath)) {
Directory.CreateDirectory(logPath);
@ -36,15 +84,13 @@ if (!Directory.Exists(logPath)) {
builder.Logging.AddConsoleLogger(logPath);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
LoggerProviderOptions.RegisterProviderOptions<
EventLogSettings, EventLogLoggerProvider>(builder.Services);
}
builder.Services.AddSingleton<ProcessService>();
builder.Services.AddSingleton<IProcessService, ProcessService>();
builder.Services.AddHostedService<ProcessBackgroundService>();
builder.Services.AddSingleton<PSScriptService>();
builder.Services.AddSingleton<IPSScriptService, PSScriptService>();
builder.Services.AddHostedService<PSScriptBackgroundService>();
IHost host = builder.Build();
@ -55,3 +101,138 @@ var testLogger = loggerFactory.CreateLogger("LoggerTest");
testLogger.LogInformation("Logger test: This should appear in your log file.");
host.Run();
return 0;
// Service management functions
static int InstallService(string serviceName, string exePath, string description) {
Console.WriteLine($"Installing service '{serviceName}'...");
// Create the service
var createResult = RunScCommand($"create \"{serviceName}\" binpath=\"{exePath}\" start=auto");
if (createResult != 0) {
Console.WriteLine("Failed to create service.");
return createResult;
}
// Set description
var descResult = RunScCommand($"description \"{serviceName}\" \"{description}\"");
if (descResult != 0) {
Console.WriteLine("Warning: Failed to set service description.");
}
Console.WriteLine($"Service '{serviceName}' installed successfully.");
Console.WriteLine($"Use '--start' to start the service or start it from services.msc");
return 0;
}
static int UninstallService(string serviceName) {
Console.WriteLine($"Stopping service '{serviceName}'...");
RunScCommand($"stop \"{serviceName}\"");
Console.WriteLine($"Uninstalling service '{serviceName}'...");
var result = RunScCommand($"delete \"{serviceName}\"");
if (result == 0) {
Console.WriteLine($"Service '{serviceName}' uninstalled successfully.");
}
else {
Console.WriteLine("Failed to uninstall service.");
}
return result;
}
static int StartService(string serviceName) {
Console.WriteLine($"Starting service '{serviceName}'...");
var result = RunScCommand($"start \"{serviceName}\"");
if (result == 0) {
Console.WriteLine($"Service '{serviceName}' started successfully.");
}
else {
Console.WriteLine("Failed to start service.");
}
return result;
}
static int StopService(string serviceName) {
Console.WriteLine($"Stopping service '{serviceName}'...");
var result = RunScCommand($"stop \"{serviceName}\"");
if (result == 0) {
Console.WriteLine($"Service '{serviceName}' stopped successfully.");
}
else {
Console.WriteLine("Failed to stop service.");
}
return result;
}
static int GetServiceStatus(string serviceName) {
return RunScCommand($"query \"{serviceName}\"");
}
static int RunScCommand(string arguments) {
try {
var process = new Process {
StartInfo = new ProcessStartInfo {
FileName = "sc.exe",
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
}
};
process.Start();
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (!string.IsNullOrWhiteSpace(output))
Console.WriteLine(output);
if (!string.IsNullOrWhiteSpace(error))
Console.WriteLine(error);
return process.ExitCode;
}
catch (Exception ex) {
Console.WriteLine($"Error executing sc.exe: {ex.Message}");
return 1;
}
}
static void PrintHelp(string serviceName) {
Console.WriteLine($"""
MaksIT.UScheduler - Windows Service Scheduler
Usage: MaksIT.UScheduler.exe [command]
Commands:
--install, -i Install the Windows service
--uninstall, -u Uninstall the Windows service
--start Start the service
--stop Stop the service
--status Query service status
--help, -h Show this help message
Configuration:
Service Name: {serviceName}
Config File: appsettings.json
Examples:
MaksIT.UScheduler.exe --install # Install and register the service
MaksIT.UScheduler.exe --start # Start the service
MaksIT.UScheduler.exe --stop # Stop the service
MaksIT.UScheduler.exe --uninstall # Remove the service
Note: Service management commands require administrator privileges.
""");
}

View File

@ -0,0 +1,32 @@
namespace MaksIT.UScheduler.Services;
/// <summary>
/// Interface for executing and managing PowerShell scripts.
/// </summary>
public interface IPSScriptService : IDisposable {
/// <summary>
/// Executes a PowerShell script asynchronously with optional signature verification.
/// </summary>
/// <param name="scriptPath">The path to the PowerShell script to execute.</param>
/// <param name="signed">If true, validates the script's Authenticode signature before execution.</param>
/// <param name="stoppingToken">Cancellation token to signal when script execution should stop.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task RunScriptAsync(string scriptPath, bool signed, CancellationToken stoppingToken);
/// <summary>
/// Gets a list of script paths that are currently being executed.
/// </summary>
/// <returns>A list of script paths currently running.</returns>
List<string> GetRunningScriptTasks();
/// <summary>
/// Terminates a running PowerShell script by its path.
/// </summary>
/// <param name="scriptPath">The path of the script to terminate.</param>
void TerminateScript(string scriptPath);
/// <summary>
/// Terminates all currently running PowerShell scripts.
/// </summary>
void TerminateAllScripts();
}

View File

@ -0,0 +1,36 @@
using System.Collections.Concurrent;
using System.Diagnostics;
namespace MaksIT.UScheduler.Services;
/// <summary>
/// Interface for managing and executing external processes.
/// </summary>
public interface IProcessService {
/// <summary>
/// Starts and monitors an external process asynchronously.
/// </summary>
/// <param name="processPath">The path to the executable to run.</param>
/// <param name="args">Optional command-line arguments to pass to the process.</param>
/// <param name="stoppingToken">Cancellation token to signal when the process should stop.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task RunProcessAsync(string processPath, string[]? args, CancellationToken stoppingToken);
/// <summary>
/// Gets the dictionary of currently running processes.
/// </summary>
/// <returns>A concurrent dictionary mapping process IDs to their Process objects.</returns>
ConcurrentDictionary<int, Process> GetRunningProcesses();
/// <summary>
/// Terminates a running process by its ID.
/// </summary>
/// <param name="processId">The ID of the process to terminate.</param>
void TerminateProcessById(int processId);
/// <summary>
/// Terminates all currently running processes managed by this service.
/// </summary>
void TerminateAllProcesses();
}

View File

@ -1,65 +1,104 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using MaksIT.UScheduler.Shared.Helpers;
using MaksIT.UScheduler.Shared.Extensions;
namespace MaksIT.UScheduler.Services;
public sealed class PSScriptService {
/// <summary>
/// Service responsible for executing and managing PowerShell scripts.
/// Provides parallel script execution using a RunspacePool and supports
/// signature verification for signed scripts.
/// </summary>
public sealed class PSScriptService : IPSScriptService {
private readonly ILogger<PSScriptService> _logger;
private readonly ConcurrentDictionary<string, PowerShell> _runningScripts = new ConcurrentDictionary<string, PowerShell>();
private readonly Runspace _rs = RunspaceFactory.CreateRunspace();
private readonly ILoggerFactory _loggerFactory;
private readonly ConcurrentDictionary<string, PowerShell> _runningScripts = new();
private readonly RunspacePool _runspacePool;
private bool _disposed;
public PSScriptService(ILogger<PSScriptService> logger) {
/// <summary>
/// Initializes a new instance of the <see cref="PSScriptService"/> class.
/// Creates a RunspacePool for parallel script execution.
/// </summary>
/// <param name="logger">The logger instance for this service.</param>
/// <param name="loggerFactory">The logger factory for creating script-specific loggers.</param>
public PSScriptService(
ILogger<PSScriptService> logger,
ILoggerFactory loggerFactory
) {
_logger = logger;
if (_rs.RunspaceStateInfo.State != RunspaceState.Opened) {
_rs.Open();
_logger.LogInformation($"Runspace opened");
}
_loggerFactory = loggerFactory;
// Create a RunspacePool to allow parallel script execution
_runspacePool = RunspaceFactory.CreateRunspacePool(1, Environment.ProcessorCount);
_runspacePool.Open();
_logger.LogInformation($"RunspacePool opened with max {Environment.ProcessorCount} runspaces");
}
public Task RunScript(string scriptPath, bool signed, CancellationToken stoppingToken) {
_logger.LogInformation($"Preparing to run script {scriptPath}");
/// <summary>
/// Executes a PowerShell script asynchronously with optional signature verification.
/// Automatically passes Automated and CurrentDateTimeUtc parameters to the script.
/// </summary>
/// <param name="scriptPath">The path to the PowerShell script to execute.</param>
/// <param name="signed">If true, validates the script's Authenticode signature before execution.</param>
/// <param name="stoppingToken">Cancellation token to signal when script execution should stop.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public async Task RunScriptAsync(string scriptPath, bool signed, CancellationToken stoppingToken) {
// Resolve relative paths against application base directory
var resolvedPath = PathHelper.ResolvePath(scriptPath);
if (GetRunningScriptTasks().Contains(scriptPath)) {
_logger.LogInformation($"PowerShell script {scriptPath} is already running");
return Task.CompletedTask;
_logger.LogInformation($"Preparing to run script {resolvedPath}");
if (GetRunningScriptTasks().Contains(resolvedPath)) {
_logger.LogInformation($"PowerShell script {resolvedPath} is already running");
return;
}
if (!File.Exists(scriptPath)) {
_logger.LogError($"Script file {scriptPath} does not exist");
return Task.CompletedTask;
if (!File.Exists(resolvedPath)) {
_logger.LogError($"Script file {resolvedPath} does not exist");
return;
}
if (!TryUnblockScript(scriptPath)) {
_logger.LogError($"Script {scriptPath} could not be unblocked. Aborting execution.");
return Task.CompletedTask;
if (!TryUnblockScript(resolvedPath)) {
_logger.LogError($"Script {resolvedPath} could not be unblocked. Aborting execution.");
return;
}
var ps = PowerShell.Create();
ps.Runspace = _rs;
_runningScripts.TryAdd(scriptPath, ps);
ps.RunspacePool = _runspacePool;
if (!_runningScripts.TryAdd(resolvedPath, ps)) {
_logger.LogWarning($"Script {resolvedPath} was already added by another thread");
ps.Dispose();
return;
}
try {
// Set execution policy
var scriptPolicy = signed ? "AllSigned" : "Unrestricted";
ps.AddScript($"Set-ExecutionPolicy -Scope Process -ExecutionPolicy {scriptPolicy}");
ps.Invoke();
await Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);
if (signed) {
ps.Commands.Clear();
ps.AddScript($"Get-AuthenticodeSignature \"{scriptPath}\"");
var signatureResults = ps.Invoke();
ps.AddScript($"Get-AuthenticodeSignature \"{resolvedPath}\"");
var signatureResults = await Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);
if (signatureResults.Count == 0 || ((Signature)signatureResults[0].BaseObject).Status != SignatureStatus.Valid) {
_logger.LogWarning($"Script {scriptPath} signature is invalid. Correct and restart the service.");
return Task.CompletedTask;
_logger.LogWarning($"Script {resolvedPath} signature is invalid. Correct and restart the service.");
return;
}
}
_logger.LogInformation($"Invoking: {scriptPath}");
stoppingToken.ThrowIfCancellationRequested();
_logger.LogInformation($"Invoking: {resolvedPath}");
ps.Commands.Clear();
var myCommand = new Command(scriptPath);
var myCommand = new Command(resolvedPath);
var currentDateTimeUtcString = DateTime.UtcNow.ToString("o");
myCommand.Parameters.Add(new CommandParameter("Automated", true));
@ -68,35 +107,45 @@ public sealed class PSScriptService {
_logger.LogInformation($"Added parameters: Automated=true, CurrentDateTimeUtc={currentDateTimeUtcString}");
// Execute asynchronously
var outputResults = await Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);
#region Script Output Logging
var scriptLogger = _loggerFactory.CreateFolderLogger(resolvedPath);
// Log standard output
var outputResults = ps.Invoke();
if (outputResults != null && outputResults.Count > 0) {
foreach (var outputItem in outputResults) {
_logger.LogInformation($"[PS Output] {outputItem}");
scriptLogger.LogInformation($"[PS Output] {outputItem}");
}
}
// Log errors
if (ps.Streams.Error.Count > 0) {
foreach (var errorItem in ps.Streams.Error) {
_logger.LogError($"[PS Error] {errorItem}");
scriptLogger.LogError($"[PS Error] {errorItem}");
}
}
#endregion
}
catch (OperationCanceledException) {
_logger.LogInformation($"Stopping script {scriptPath} due to cancellation request");
_logger.LogInformation($"Stopping script {resolvedPath} due to cancellation request");
}
catch (Exception ex) {
_logger.LogError($"Error running script {scriptPath}: {ex.Message}");
_logger.LogError($"Error running script {resolvedPath}: {ex.Message}");
}
finally {
TerminateScript(scriptPath);
_logger.LogInformation($"Script {scriptPath} completed and removed from running scripts");
}
return Task.CompletedTask;
TerminateScript(resolvedPath);
_logger.LogInformation($"Script {resolvedPath} completed and removed from running scripts");
}
}
/// <summary>
/// Attempts to unblock a downloaded script by removing the Zone.Identifier alternate data stream.
/// This is equivalent to right-clicking a file and selecting "Unblock" in Windows.
/// </summary>
/// <param name="scriptPath">The path to the script to unblock.</param>
/// <returns>True if the script was successfully unblocked or was not blocked; false if unblocking failed.</returns>
private bool TryUnblockScript(string scriptPath) {
try {
var zoneIdentifier = scriptPath + ":Zone.Identifier";
@ -112,20 +161,57 @@ public sealed class PSScriptService {
}
}
/// <summary>
/// Gets a list of script paths that are currently being executed.
/// </summary>
/// <returns>A list of script paths currently running.</returns>
public List<string> GetRunningScriptTasks() {
_logger.LogInformation($"Retrieving running script tasks. Current count: {_runningScripts.Count}");
return _runningScripts.Keys.ToList();
}
/// <summary>
/// Terminates a running PowerShell script by its path.
/// Stops the script execution and disposes of its PowerShell instance.
/// </summary>
/// <param name="scriptPath">The path of the script to terminate.</param>
public void TerminateScript(string scriptPath) {
_logger.LogInformation($"Attempting to terminate script {scriptPath}");
if (_runningScripts.TryRemove(scriptPath, out var ps)) {
ps.Stop();
ps.Dispose();
_logger.LogInformation($"Script {scriptPath} terminated");
}
else {
_logger.LogWarning($"Failed to terminate script {scriptPath}. Script not found.");
}
}
/// <summary>
/// Terminates all currently running PowerShell scripts.
/// </summary>
public void TerminateAllScripts() {
_logger.LogInformation("Terminating all running scripts");
foreach (var scriptPath in _runningScripts.Keys.ToList()) {
TerminateScript(scriptPath);
}
}
/// <summary>
/// Releases all resources used by the <see cref="PSScriptService"/>.
/// Terminates all running scripts and closes the RunspacePool.
/// </summary>
public void Dispose() {
if (_disposed)
return;
_disposed = true;
TerminateAllScripts();
_runspacePool?.Close();
_runspacePool?.Dispose();
_logger.LogInformation("RunspacePool disposed");
}
}

View File

@ -1,90 +1,126 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Collections.Concurrent;
using MaksIT.UScheduler.Shared.Helpers;
using MaksIT.UScheduler.Shared.Extensions;
namespace MaksIT.UScheduler.Services;
public sealed class ProcessService {
/// <summary>
/// Service responsible for managing and executing external processes.
/// Tracks running processes and provides methods for starting, monitoring, and terminating them.
/// </summary>
public sealed class ProcessService : IProcessService {
private readonly ILogger<ProcessService> _logger;
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ConcurrentDictionary<int, Process> _runningProcesses = new();
public ProcessService(ILogger<ProcessService> logger) {
/// <summary>
/// Initializes a new instance of the <see cref="ProcessService"/> class.
/// </summary>
/// <param name="logger">The logger instance for this service.</param>
/// <param name="loggerFactory">The logger factory for creating process-specific loggers.</param>
public ProcessService(
ILogger<ProcessService> logger,
ILoggerFactory loggerFactory
) {
_logger = logger;
_loggerFactory = loggerFactory;
}
public async Task RunProcess(string processPath, string[] args, CancellationToken stoppingToken) {
_logger.LogInformation($"Starting process {processPath} with arguments {string.Join(", ", args)}");
/// <summary>
/// Starts and monitors an external process asynchronously.
/// If the process exits with a non-zero code, it will be automatically restarted.
/// </summary>
/// <param name="processPath">The path to the executable to run.</param>
/// <param name="args">Optional command-line arguments to pass to the process.</param>
/// <param name="stoppingToken">Cancellation token to signal when the process should stop.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public async Task RunProcessAsync(string processPath, string[]? args, CancellationToken stoppingToken) {
// Resolve relative paths against application base directory
var resolvedPath = PathHelper.ResolvePath(processPath);
var processLogger = _loggerFactory.CreateFolderLogger(resolvedPath);
var argsString = args != null ? string.Join(", ", args) : "";
processLogger.LogInformation($"Starting process {resolvedPath} with arguments {argsString}");
Process? process = null;
try {
if (GetRunningProcesses().Any(x => x.Value.StartInfo.FileName == processPath)) {
_logger.LogInformation($"Process {processPath} is already running");
if (GetRunningProcesses().Any(x => x.Value.StartInfo.FileName == resolvedPath)) {
processLogger.LogInformation($"Process {resolvedPath} is already running");
return;
}
process = new Process();
process.StartInfo = new ProcessStartInfo {
FileName = processPath,
WorkingDirectory = Path.GetDirectoryName(processPath),
FileName = resolvedPath,
WorkingDirectory = Path.GetDirectoryName(resolvedPath),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
if (args != null) {
foreach (var arg in args)
process.StartInfo.ArgumentList.Add(arg);
}
process.Start();
_runningProcesses.TryAdd(process.Id, process);
_logger.LogInformation($"Process {processPath} started with ID {process.Id}");
processLogger.LogInformation($"Process {resolvedPath} started with ID {process.Id}");
await process.WaitForExitAsync();
if (process.ExitCode != 0 && !stoppingToken.IsCancellationRequested) {
_logger.LogWarning($"Process {processPath} exited with code {process.ExitCode}");
await RunProcess(processPath, args, stoppingToken);
processLogger.LogWarning($"Process {resolvedPath} exited with code {process.ExitCode}, restarting...");
await RunProcessAsync(resolvedPath, args, stoppingToken);
}
else {
_logger.LogInformation($"Process {processPath} completed successfully");
processLogger.LogInformation($"Process {resolvedPath} completed successfully with exit code {process.ExitCode}");
}
}
catch (OperationCanceledException) {
// When the stopping token is canceled, for example, a call made from services.msc,
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
_logger.LogWarning($"Process {processPath} was canceled");
processLogger.LogInformation($"Process {resolvedPath} was canceled");
}
catch (Exception ex) {
_logger.LogError($"Error running process {processPath}: {ex.Message}");
processLogger.LogError($"Error running process {resolvedPath}: {ex.Message}");
}
finally {
if (process != null && _runningProcesses.ContainsKey(process.Id)) {
TerminateProcessById(process.Id);
_logger.LogInformation($"Process {processPath} with ID {process.Id} removed from running processes");
processLogger.LogInformation($"Process {resolvedPath} with ID {process.Id} removed from running processes");
}
}
}
/// <summary>
/// Gets the dictionary of currently running processes.
/// </summary>
/// <returns>A concurrent dictionary mapping process IDs to their Process objects.</returns>
public ConcurrentDictionary<int, Process> GetRunningProcesses() {
_logger.LogInformation($"Retrieving running processes. Current count: {_runningProcesses.Count}");
return _runningProcesses;
}
/// <summary>
/// Terminates a running process by its ID.
/// Recursively attempts to kill the process until it has exited.
/// </summary>
/// <param name="processId">The ID of the process to terminate.</param>
public void TerminateProcessById(int processId) {
// Check if the process is in the running processes list
if (!_runningProcesses.TryGetValue(processId, out var processToTerminate)) {
_logger.LogWarning($"Failed to terminate process {processId}. Process not found.");
return;
}
// Kill the process
try {
processToTerminate.Kill(true);
_logger.LogInformation($"Process {processId} terminated");
}
catch (Exception ex) {
_logger.LogError($"Error terminating process {processId}: {ex.Message}");
@ -92,7 +128,17 @@ public sealed class ProcessService {
// Check if the process has exited
if (!processToTerminate.HasExited) {
_logger.LogWarning($"Failed to terminate process {processId}. Process still running.");
TerminateProcessById(processId);
}
}
/// <summary>
/// Terminates all currently running processes managed by this service.
/// </summary>
public void TerminateAllProcesses() {
_logger.LogInformation("Terminating all running processes");
foreach (var processId in _runningProcesses.Keys.ToList()) {
TerminateProcessById(processId);
}
}

View File

@ -1,2 +0,0 @@
sc.exe delete "MaksIT.UScheduler Service"
pause

View File

@ -13,7 +13,32 @@
}
},
"Configuration": {
"LogDir": ".\\logs",
"Powershell": [
{
"Path": "..\\..\\..\\..\\..\\Scripts\\File-Sync\\file-sync.ps1",
"Name": "File Sync",
"IsSigned": false,
"Disabled": true
},
{
"Path": "..\\..\\..\\..\\..\\Scripts\\HyperV-Backup\\hyper-v-backup.ps1",
"Name": "HyperV Backup",
"IsSigned": false,
"Disabled": true
},
{
"Path": "..\\..\\..\\..\\..\\Scripts\\Native-Sync\\native-sync.ps1",
"Name": "Native Sync",
"IsSigned": false,
"Disabled": true
},
{
"Path": "..\\..\\..\\..\\..\\Scripts\\Windows-Update\\windows-update.ps1",
"Name": "Windows Update",
"IsSigned": false,
"Disabled": true
}
],
"Processes": [

View File

@ -1,173 +0,0 @@
# Set GH_TOKEN from custom environment variable for GitHub CLI authentication
$env:GH_TOKEN = $env:GITHUB_MAKS_IT_COM
# Paths
$csprojPath = "MaksIT.UScheduler\MaksIT.UScheduler.csproj"
$publishDir = "publish"
$releaseDir = "release"
$changelogPath = "..\CHANGELOG.md"
# Helper: ensure required commands exist
function Assert-Command {
param([string]$cmd)
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
Write-Error "Required command '$cmd' is missing. Aborting."
exit 1
}
}
Assert-Command dotnet
Assert-Command git
Assert-Command gh
# 1. Get version from .csproj
[xml]$csproj = Get-Content $csprojPath
# Support multiple PropertyGroups
$version = ($csproj.Project.PropertyGroup |
Where-Object { $_.Version } |
Select-Object -First 1).Version
if (-not $version) {
Write-Error "Version not found in $csprojPath"
exit 1
}
Write-Host "Version detected: $version"
# 2. Publish the project
Write-Host "Publishing project..."
dotnet publish $csprojPath -c Release -o $publishDir
if ($LASTEXITCODE -ne 0) {
Write-Error "dotnet publish failed."
exit 1
}
# 3. Prepare release directory
if (!(Test-Path $releaseDir)) {
New-Item -ItemType Directory -Path $releaseDir | Out-Null
}
# 4. Create zip file
$zipName = "maksit.uscheduler-$version.zip"
$zipPath = Join-Path $releaseDir $zipName
if (Test-Path $zipPath) {
Remove-Item $zipPath -Force
}
Write-Host "Creating archive $zipName ..."
Compress-Archive -Path "$publishDir\*" -DestinationPath $zipPath -Force
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $zipPath)) {
Write-Error "Failed to create archive $zipPath"
exit 1
}
Write-Host "Release zip created: $zipPath"
# 5.a Extract related changelog section from CHANGELOG.md
if (-not (Test-Path $changelogPath)) {
Write-Error "CHANGELOG.md not found."
exit 1
}
$changelog = Get-Content $changelogPath -Raw
# Regex pattern to get the changelog section for the current version
$pattern = "(?ms)^##\s+v$([regex]::Escape($version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)"
$match = [regex]::Match($changelog, $pattern)
if (-not $match.Success) {
Write-Error "Changelog entry for version $version not found."
exit 1
}
$releaseNotes = $match.Value.Trim()
Write-Host "Extracted release notes for ${version}:"
Write-Host "----------------------------------------"
Write-Host $releaseNotes
Write-Host "----------------------------------------"
# 5. Create GitHub Release (requires GitHub CLI)
# Get remote URL
$remoteUrl = git config --get remote.origin.url
if ($LASTEXITCODE -ne 0 -or -not $remoteUrl) {
Write-Error "Could not determine git remote origin URL."
exit 1
}
# Extract owner/repo from URL (supports HTTPS and SSH)
if ($remoteUrl -match "[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.git)?$") {
$owner = $matches['owner']
$repoName = $matches['repo']
$repo = "$owner/$repoName"
} else {
Write-Error "Could not parse GitHub repo from remote URL: $remoteUrl"
exit 1
}
$tag = "v$version"
$releaseName = "Release $version"
Write-Host "Repository detected: $repo"
Write-Host "Tag to be created: $tag"
# Ensure GH_TOKEN is set
if (-not $env:GH_TOKEN) {
Write-Error "GH_TOKEN environment variable is not set. Set GITHUB_MAKS_IT_COM and rerun."
exit 1
}
Write-Host "Authenticating GitHub CLI using GH_TOKEN..."
# Reliable authentication test
$authTest = gh api user 2>$null
if ($LASTEXITCODE -ne 0 -or -not $authTest) {
Write-Error "GitHub CLI authentication failed. GH_TOKEN may be invalid or missing repo scope."
exit 1
}
Write-Host "GitHub CLI authenticated successfully via GH_TOKEN."
# Create or replace release
Write-Host "Creating GitHub release for $repo ..."
# Check if release already exists
$existing = gh release list --repo $repo | Select-String "^$tag\s"
if ($existing) {
Write-Host "Tag $tag already exists. Deleting old release..."
gh release delete $tag --repo $repo --yes
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to delete existing release $tag."
exit 1
}
}
# Create new release with extracted changelog section
gh release create $tag $zipPath `
--repo $repo `
--title "${releaseName}" `
--notes "${releaseNotes}"
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to create GitHub release for tag $tag."
exit 1
}
Write-Host "GitHub release created successfully."
# Cleanup temporary directories
if (Test-Path $publishDir) {
Remove-Item $publishDir -Recurse -Force
Write-Host "Cleaned up $publishDir directory."
}
# Keep release artifacts
Write-Host "Release artifacts kept in: $releaseDir"
Write-Host "Done."

View File

@ -6,8 +6,12 @@
"lastModified": "2026-01-26",
"schedule": {
"runMonth": [],
"runWeekday": ["Monday"],
"runTime": ["00:00"],
"runWeekday": [
"Monday"
],
"runTime": [
"00:00"
],
"minIntervalMinutes": 10
},
"freeFileSyncExe": "C:\\Program Files\\FreeFileSync\\FreeFileSync.exe",

View File

@ -62,10 +62,7 @@ Windows-Update/
"runWeekday": ["Wednesday"],
"runTime": ["02:00"]
},
"updateCategories": [
"Critical Updates",
"Security Updates"
]
"updateCategories": "all"
}
```
@ -92,20 +89,38 @@ Windows-Update/
### Update Categories
Available categories to install:
The `updateCategories` setting supports two formats:
| Format | Description |
|--------|-------------|
| `"all"` | Install updates from all categories (default) |
| `["Category1", "Category2"]` | Install only from specified categories |
**Available Categories:**
| Category | Description |
|----------|-------------|
| `Critical Updates` | Critical security and stability updates |
| `Security Updates` | Security-focused updates |
| `Definition Updates` | Antivirus and malware definition updates |
| `Definition Updates` | Antivirus/Defender definition updates (e.g., KB2267602) |
| `Update Rollups` | Cumulative update packages |
| `Updates` | General updates |
| `Feature Packs` | New feature additions |
| `Service Packs` | Major cumulative updates |
| `Tools` | System tools and utilities |
| `Drivers` | Hardware driver updates |
| `Upgrades` | Windows version upgrades |
**Example:**
**Examples:**
Install all updates (default):
```json
{
"updateCategories": "all"
}
```
Security-focused updates only:
```json
{
"updateCategories": [
@ -116,6 +131,22 @@ Available categories to install:
}
```
Everything except drivers:
```json
{
"updateCategories": [
"Critical Updates",
"Security Updates",
"Definition Updates",
"Update Rollups",
"Updates",
"Feature Packs",
"Service Packs",
"Tools"
]
}
```
### Exclusions
| Property | Type | Description | Example |
@ -268,6 +299,8 @@ When `-Automated` is specified:
[INFO] ==========================================
[INFO] Windows Update Process Started
[INFO] Script Version: 1.0.0 (2026-01-28)
[INFO] Update Categories: ALL (installing all available updates)
[Info] Available: Critical Updates, Definition Updates, Security Updates, Update Rollups, Updates
[INFO] ==========================================
[SUCCESS] PSWindowsUpdate module loaded
[INFO] Running pre-update checks...
@ -422,12 +455,13 @@ Test without installing updates (set in scriptsettings.json):
1. **Test First** - Always test with `dryRun: true` before actual execution
2. **Schedule Wisely** - Run during maintenance windows (nights, weekends)
3. **Start Conservative** - Begin with Critical/Security updates only
4. **Monitor Results** - Review update reports and logs regularly
5. **Backup First** - Ensure system backups before major updates
6. **Reboot Testing** - Test `rebootBehavior: "auto"` in non-production environment first
7. **Exclusion Management** - Keep exclusions list minimal and documented
8. **Review Failures** - Investigate and resolve failed updates promptly
3. **Use "all" Categories** - Default `"all"` ensures no updates are missed (including Defender definitions)
4. **Use Exclusions** - Exclude specific problematic updates by KB number or title pattern instead of limiting categories
5. **Monitor Results** - Review update reports and logs regularly
6. **Backup First** - Ensure system backups before major updates
7. **Reboot Testing** - Test `rebootBehavior: "auto"` in non-production environment first
8. **Exclusion Management** - Keep exclusions list minimal and documented
9. **Review Failures** - Investigate and resolve failed updates promptly
## Security Considerations
@ -457,6 +491,12 @@ Test without installing updates (set in scriptsettings.json):
## Version History
### 1.0.1 (2026-01-30)
- Added `"all"` option for updateCategories (new default)
- Fixed category matching for PSWindowsUpdate Category objects
- Added `Updates` and `Upgrades` to documented categories
- Improved logging to show category mode at startup
### 1.0.0 (2026-01-28)
- Initial release
- PSWindowsUpdate integration

View File

@ -10,12 +10,7 @@
"runTime": ["02:00"],
"minIntervalMinutes": 60
},
"updateCategories": [
"Critical Updates",
"Security Updates",
"Definition Updates",
"Update Rollups"
],
"updateCategories": "all",
"exclusions": {
"kbNumbers": [],
"titlePatterns": [
@ -33,7 +28,7 @@
"dryRun": false
},
"reporting": {
"generateReport": true,
"generateReport": false,
"emailNotification": false,
"emailSettings": {
"smtpServer": "smtp.example.com",
@ -53,7 +48,7 @@
"runTime": "Array of UTC times in HH:mm format when updates should run.",
"minIntervalMinutes": "Minimum minutes between update runs to prevent duplicate executions."
},
"updateCategories": "Array of update categories to install. Available: 'Critical Updates', 'Security Updates', 'Definition Updates', 'Update Rollups', 'Feature Packs', 'Service Packs', 'Tools', 'Drivers'",
"updateCategories": "Set to 'all' to install all updates regardless of category, or provide an array of specific categories. Available categories: 'Critical Updates', 'Security Updates', 'Definition Updates', 'Update Rollups', 'Updates', 'Feature Packs', 'Service Packs', 'Tools', 'Drivers', 'Upgrades'",
"exclusions": {
"kbNumbers": "Array of KB numbers to exclude (e.g. 'KB5034441')",
"titlePatterns": "Array of wildcard patterns to exclude by title (e.g. '*Preview*', '*Optional*')"

View File

@ -55,7 +55,7 @@ catch {
# Process Settings =========================================================
# Validate required settings
$requiredSettings = @('updateCategories', 'preChecks', 'options')
$requiredSettings = @('preChecks', 'options')
foreach ($setting in $requiredSettings) {
if (-not $settings.$setting) {
Write-Error "Required setting '$setting' is missing or empty in $settingsFile"
@ -63,13 +63,34 @@ foreach ($setting in $requiredSettings) {
}
}
# Validate updateCategories separately (can be string "all" or array)
if (-not $settings.updateCategories) {
Write-Error "Required setting 'updateCategories' is missing in $settingsFile"
exit 1
}
# Extract settings
$UpdateCategories = $settings.updateCategories
$UpdateCategoriesSetting = $settings.updateCategories
$Exclusions = $settings.exclusions
$PreChecks = $settings.preChecks
$Options = $settings.options
$Reporting = $settings.reporting
# Process updateCategories - can be "all" or an array of category names
$InstallAllCategories = $false
$UpdateCategories = @()
if ($UpdateCategoriesSetting -is [string] -and $UpdateCategoriesSetting -eq "all") {
$InstallAllCategories = $true
}
elseif ($UpdateCategoriesSetting -is [array]) {
$UpdateCategories = $UpdateCategoriesSetting
}
else {
Write-Error "Invalid updateCategories setting. Must be 'all' or an array of category names."
exit 1
}
# Get DryRun from settings
$DryRun = $Options.dryRun
@ -130,6 +151,77 @@ function Test-PSWindowsUpdate {
}
}
function Get-AllUpdateCategories {
param([switch]$Automated)
try {
# Use Windows Update COM API
$updateSession = New-Object -ComObject Microsoft.Update.Session
$updateSearcher = $updateSession.CreateUpdateSearcher()
# Set to search Microsoft Update (includes more categories)
$microsoftUpdateServiceId = "7971f918-a847-4430-9279-4a52d1efe18d"
try {
$updateSearcher.ServiceID = $microsoftUpdateServiceId
$updateSearcher.ServerSelection = 3 # ssOthers
}
catch {
# Fall back to default
}
$categories = @{}
# Method 1: Search for pending and installed updates
$searches = @("IsInstalled=0", "IsInstalled=1 and IsHidden=0")
foreach ($searchCriteria in $searches) {
try {
$searchResult = $updateSearcher.Search($searchCriteria)
foreach ($update in $searchResult.Updates) {
foreach ($cat in $update.Categories) {
# Filter by Type = "UpdateClassification" (excludes product names)
if ($cat.Name -and $cat.Type -eq "UpdateClassification") {
if (-not $categories.ContainsKey($cat.Name)) {
$categories[$cat.Name] = $true
}
}
}
}
}
catch {
# Continue with next search
}
}
# Method 2: Also check update history for more categories
try {
$historyCount = $updateSearcher.GetTotalHistoryCount()
if ($historyCount -gt 0) {
$history = $updateSearcher.QueryHistory(0, [Math]::Min($historyCount, 200))
foreach ($entry in $history) {
if ($entry.Categories) {
foreach ($cat in $entry.Categories) {
if ($cat.Name -and $cat.Type -eq "UpdateClassification") {
if (-not $categories.ContainsKey($cat.Name)) {
$categories[$cat.Name] = $true
}
}
}
}
}
}
}
catch {
# History query failed, continue with what we have
}
return $categories
}
catch {
return @{}
}
}
function Test-PreUpdateChecks {
param([switch]$Automated)
@ -178,6 +270,54 @@ function Test-PreUpdateChecks {
return $true
}
function Update-DefenderSignatures {
param([switch]$Automated)
Write-Log "Updating Microsoft Defender signatures..." -Level Info -Automated:$Automated
try {
# Get current signature version before update
$defenderStatus = Get-MpComputerStatus -ErrorAction SilentlyContinue
$beforeVersion = if ($defenderStatus) { $defenderStatus.AntivirusSignatureVersion } else { "Unknown" }
# Update signatures using built-in cmdlet (more reliable than Windows Update)
Update-MpSignature -ErrorAction Stop
# Get new version
$defenderStatus = Get-MpComputerStatus -ErrorAction SilentlyContinue
$afterVersion = if ($defenderStatus) { $defenderStatus.AntivirusSignatureVersion } else { "Unknown" }
if ($beforeVersion -ne $afterVersion) {
Write-Log "Defender signatures updated: $beforeVersion -> $afterVersion" -Level Success -Automated:$Automated
$script:UpdateStats.Installed++
}
else {
Write-Log "Defender signatures already up to date (Version: $afterVersion)" -Level Info -Automated:$Automated
}
return $true
}
catch {
Write-Log "Failed to update Defender signatures: $_" -Level Warning -Automated:$Automated
return $false
}
}
function Invoke-WindowsUpdateRescan {
param([switch]$Automated)
Write-Log "Refreshing Windows Update UI..." -Level Info -Automated:$Automated
try {
# startinteractivescan refreshes the Windows Update GUI to reflect actual installed state
$null = Start-Process -FilePath "UsoClient.exe" -ArgumentList "startinteractivescan" -Wait -NoNewWindow -PassThru -ErrorAction SilentlyContinue
Write-Log "Windows Update UI refreshed" -Level Success -Automated:$Automated
}
catch {
Write-Log "Could not refresh Windows Update UI: $_" -Level Warning -Automated:$Automated
}
}
function Get-AvailableUpdates {
param([switch]$Automated)
@ -189,13 +329,23 @@ function Get-AvailableUpdates {
$update = $_
$included = $false
# Check categories
# Check categories - if "all", include everything; otherwise filter by category list
if ($InstallAllCategories) {
$included = $true
}
else {
# Categories is a collection of Category objects with Name property
foreach ($cat in $UpdateCategories) {
if ($update.Categories -match $cat) {
foreach ($updateCategory in $update.Categories) {
$categoryName = if ($updateCategory.Name) { $updateCategory.Name } else { $updateCategory.ToString() }
if ($categoryName -match $cat) {
$included = $true
break
}
}
if ($included) { break }
}
}
# Apply KB exclusions
if ($included -and $Exclusions.kbNumbers.Count -gt 0) {
@ -219,6 +369,12 @@ function Get-AvailableUpdates {
}
}
# Exclude Defender signature updates (handled separately via Update-MpSignature)
if ($included -and $update.Title -like "*Security Intelligence Update for Microsoft Defender*") {
Write-Log "Skipping (handled separately): $($update.Title)" -Level Info -Automated:$Automated
$included = $false
}
return $included
}
@ -246,7 +402,8 @@ function Install-AvailableUpdates {
foreach ($update in $Updates) {
$sizeKB = [math]::Round($update.Size / 1KB, 2)
Write-Log " [$($update.KBArticleIDs -join ',')] $($update.Title) ($sizeKB KB)" -Level Info -Automated:$Automated
$kbDisplay = if ($update.KBArticleIDs) { $update.KBArticleIDs -join ',' } else { "N/A" }
Write-Log " [$kbDisplay] $($update.Title) ($sizeKB KB)" -Level Info -Automated:$Automated
}
Write-Log "========================================" -Level Info -Automated:$Automated
@ -257,24 +414,18 @@ function Install-AvailableUpdates {
return
}
# Install updates
# Install updates - pipe directly to preserve update selection
Write-Log "Installing updates..." -Level Info -Automated:$Automated
try {
$installParams = @{
MicrosoftUpdate = $true
AcceptAll = $true
IgnoreReboot = ($Options.rebootBehavior -ne 'auto')
Verbose = $false
}
# Use KBArticleID filter if available
$kbList = $Updates | ForEach-Object { $_.KBArticleIDs } | Where-Object { $_ }
if ($kbList.Count -gt 0) {
$installParams['KBArticleID'] = $kbList
}
$result = Install-WindowsUpdate @installParams
# Pipe updates directly to Install-WindowsUpdate for reliable installation
$result = $Updates | Install-WindowsUpdate @installParams
# Process results
foreach ($item in $result) {
@ -417,6 +568,21 @@ function Start-BusinessLogic {
Write-Log "========================================" -Level Info -Automated:$Automated
Write-Log "Windows Update Process Started" -Level Info -Automated:$Automated
Write-Log "Script Version: $ScriptVersion ($ScriptDate)" -Level Info -Automated:$Automated
if ($InstallAllCategories) {
Write-Log "Update Categories: ALL" -Level Info -Automated:$Automated
# Query and display all known categories from Windows Update
$knownCategories = Get-AllUpdateCategories -Automated:$Automated
if ($knownCategories.Count -gt 0) {
$categoryList = ($knownCategories.Keys | Sort-Object) -join ', '
Write-Log " Available: $categoryList" -Level Info -Automated:$Automated
}
else {
Write-Log " Available: (unable to query)" -Level Info -Automated:$Automated
}
}
else {
Write-Log "Update Categories: $($UpdateCategories -join ', ')" -Level Info -Automated:$Automated
}
if ($DryRun) {
Write-Log "DRY RUN MODE - No changes will be made" -Level Warning -Automated:$Automated
}
@ -434,6 +600,14 @@ function Start-BusinessLogic {
exit 1
}
# Update Microsoft Defender signatures first (more reliable than Windows Update)
if (-not $DryRun) {
$null = Update-DefenderSignatures -Automated:$Automated
}
else {
Write-Log "DRY RUN: Skipping Defender signature update" -Level Info -Automated:$Automated
}
# Scan for updates
$updates = Get-AvailableUpdates -Automated:$Automated
@ -448,6 +622,11 @@ function Start-BusinessLogic {
Invoke-PostUpdateActions -Automated:$Automated
}
# Refresh Windows Update cache (clears stale pending updates from UI)
if (-not $DryRun) {
Invoke-WindowsUpdateRescan -Automated:$Automated
}
# Print summary
Write-UpdateSummary -Automated:$Automated

View File

@ -0,0 +1,9 @@
@echo off
REM Change directory to the location of the script
cd /d %~dp0
REM Run Force Amend Tagged Commit script
powershell -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
pause

View File

@ -0,0 +1,242 @@
<#
.SYNOPSIS
Amends the latest commit, recreates its associated tag, and force pushes both to remote.
.DESCRIPTION
This script performs the following operations:
1. Gets the last commit and verifies it has an associated tag
2. Stages all pending changes
3. Amends the latest commit (keeps existing message)
4. Deletes and recreates the tag on the amended commit
5. Force pushes the branch and tag to remote
All configuration is in scriptsettings.json.
.PARAMETER DryRun
If specified, shows what would be done without making changes.
.EXAMPLE
.\Force-AmendTaggedCommit.ps1
.EXAMPLE
.\Force-AmendTaggedCommit.ps1 -DryRun
.NOTES
CONFIGURATION (scriptsettings.json):
- git.remote: Remote name to push to (default: "origin")
- git.confirmBeforeAmend: Prompt before amending (default: true)
- git.confirmWhenNoChanges: Prompt if no pending changes (default: true)
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
# ==============================================================================
# PATH CONFIGURATION
# ==============================================================================
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# ==============================================================================
# LOAD SETTINGS
# ==============================================================================
$settingsPath = Join-Path $scriptDir "scriptsettings.json"
if (-not (Test-Path $settingsPath)) {
Write-Error "Settings file not found: $settingsPath"
exit 1
}
$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
# Extract settings with defaults
$Remote = if ($settings.git.remote) { $settings.git.remote } else { "origin" }
$ConfirmBeforeAmend = if ($null -ne $settings.git.confirmBeforeAmend) { $settings.git.confirmBeforeAmend } else { $true }
$ConfirmWhenNoChanges = if ($null -ne $settings.git.confirmWhenNoChanges) { $settings.git.confirmWhenNoChanges } else { $true }
# ==============================================================================
# HELPER FUNCTIONS
# ==============================================================================
function Write-Step {
param([string]$Text)
Write-Host "`n>> $Text" -ForegroundColor Cyan
}
function Write-Success {
param([string]$Text)
Write-Host " $Text" -ForegroundColor Green
}
function Write-Info {
param([string]$Text)
Write-Host " $Text" -ForegroundColor Gray
}
function Write-Warn {
param([string]$Text)
Write-Host " $Text" -ForegroundColor Yellow
}
function Invoke-Git {
param(
[Parameter(Mandatory = $true)]
[string[]]$Arguments,
[Parameter(Mandatory = $false)]
[switch]$CaptureOutput,
[Parameter(Mandatory = $false)]
[string]$ErrorMessage = "Git command failed"
)
if ($CaptureOutput) {
$output = & git @Arguments 2>&1
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
throw "$ErrorMessage (exit code: $exitCode)"
}
return $output
} else {
& git @Arguments
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
throw "$ErrorMessage (exit code: $exitCode)"
}
}
}
# ==============================================================================
# MAIN EXECUTION
# ==============================================================================
try {
Write-Host "`n========================================" -ForegroundColor Magenta
Write-Host " Force Amend Tagged Commit Script" -ForegroundColor Magenta
Write-Host "========================================`n" -ForegroundColor Magenta
if ($DryRun) {
Write-Warn "*** DRY RUN MODE - No changes will be made ***`n"
}
# Get current branch
Write-Step "Getting current branch..."
$Branch = Invoke-Git -Arguments @("rev-parse", "--abbrev-ref", "HEAD") -CaptureOutput -ErrorMessage "Failed to get current branch"
Write-Info "Branch: $Branch"
# Get last commit info
Write-Step "Getting last commit..."
$null = Invoke-Git -Arguments @("rev-parse", "HEAD") -CaptureOutput -ErrorMessage "Failed to get HEAD commit"
$CommitMessage = Invoke-Git -Arguments @("log", "-1", "--format=%s") -CaptureOutput
$CommitHash = Invoke-Git -Arguments @("log", "-1", "--format=%h") -CaptureOutput
Write-Info "Commit: $CommitHash - $CommitMessage"
# Find tag pointing to HEAD
Write-Step "Finding tag on last commit..."
$Tags = & git tag --points-at HEAD 2>&1
if (-not $Tags -or [string]::IsNullOrWhiteSpace("$Tags")) {
throw "No tag found on the last commit ($CommitHash). This script requires the last commit to have an associated tag."
}
# If multiple tags, use the first one
$TagName = ("$Tags" -split "`n")[0].Trim()
Write-Success "Found tag: $TagName"
# Show current status
Write-Step "Checking pending changes..."
$Status = & git status --short 2>&1
if ($Status -and -not [string]::IsNullOrWhiteSpace("$Status")) {
Write-Info "Pending changes:"
"$Status" -split "`n" | ForEach-Object { Write-Info " $_" }
} else {
Write-Warn "No pending changes found"
if ($ConfirmWhenNoChanges -and -not $DryRun) {
$confirm = Read-Host "`n No changes to amend. Continue to recreate tag and force push? (y/N)"
if ($confirm -ne 'y' -and $confirm -ne 'Y') {
Write-Host "`nAborted by user" -ForegroundColor Yellow
exit 0
}
}
}
# Confirm operation
Write-Host "`n----------------------------------------" -ForegroundColor White
Write-Host " Summary of operations:" -ForegroundColor White
Write-Host "----------------------------------------" -ForegroundColor White
Write-Host " Branch: $Branch" -ForegroundColor White
Write-Host " Commit: $CommitHash" -ForegroundColor White
Write-Host " Tag: $TagName" -ForegroundColor White
Write-Host " Remote: $Remote" -ForegroundColor White
Write-Host "----------------------------------------`n" -ForegroundColor White
if ($ConfirmBeforeAmend -and -not $DryRun) {
$confirm = Read-Host " Proceed with amend and force push? (y/N)"
if ($confirm -ne 'y' -and $confirm -ne 'Y') {
Write-Host "`nAborted by user" -ForegroundColor Yellow
exit 0
}
}
# Stage all changes
Write-Step "Staging all changes..."
if (-not $DryRun) {
Invoke-Git -Arguments @("add", "-A") -ErrorMessage "Failed to stage changes"
}
Write-Success "All changes staged"
# Amend commit
Write-Step "Amending commit..."
if (-not $DryRun) {
Invoke-Git -Arguments @("commit", "--amend", "--no-edit") -ErrorMessage "Failed to amend commit"
}
Write-Success "Commit amended"
# Delete local tag
Write-Step "Deleting local tag '$TagName'..."
if (-not $DryRun) {
Invoke-Git -Arguments @("tag", "-d", $TagName) -ErrorMessage "Failed to delete local tag"
}
Write-Success "Local tag deleted"
# Recreate tag on new commit
Write-Step "Recreating tag '$TagName' on amended commit..."
if (-not $DryRun) {
Invoke-Git -Arguments @("tag", $TagName) -ErrorMessage "Failed to create tag"
}
Write-Success "Tag recreated"
# Force push branch
Write-Step "Force pushing branch '$Branch' to $Remote..."
if (-not $DryRun) {
Invoke-Git -Arguments @("push", "--force", $Remote, $Branch) -ErrorMessage "Failed to force push branch"
}
Write-Success "Branch force pushed"
# Force push tag
Write-Step "Force pushing tag '$TagName' to $Remote..."
if (-not $DryRun) {
Invoke-Git -Arguments @("push", "--force", $Remote, $TagName) -ErrorMessage "Failed to force push tag"
}
Write-Success "Tag force pushed"
Write-Host "`n========================================" -ForegroundColor Green
Write-Host " Operation completed successfully!" -ForegroundColor Green
Write-Host "========================================`n" -ForegroundColor Green
# Show final state
Write-Host "Final state:" -ForegroundColor White
& git log -1 --oneline
Write-Host ""
} catch {
Write-Host "`n========================================" -ForegroundColor Red
Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "========================================`n" -ForegroundColor Red
exit 1
}

View File

@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$comment": "Configuration for Force-AmendTaggedCommit.ps1",
"git": {
"remote": "origin",
"confirmBeforeAmend": true,
"confirmWhenNoChanges": true
}
}

View File

@ -0,0 +1,9 @@
@echo off
REM Generate-CoverageBadges.bat - Wrapper for Generate-CoverageBadges.ps1
REM Runs tests and generates SVG coverage badges for README
pushd "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1" %*
set EXITCODE=%ERRORLEVEL%
popd
exit /b %EXITCODE%

View File

@ -0,0 +1,198 @@
<#
.SYNOPSIS
Generates SVG coverage badges for README.
.DESCRIPTION
This script runs unit tests via TestRunner.psm1, then generates shields.io-style
SVG badges for line, branch, and method coverage.
Configuration is stored in scriptsettings.json:
- paths.testProject : Relative path to test project
- paths.badgesDir : Relative path to badges output directory
- badges : Array of badges to generate (name, label, metric)
- colorThresholds : Coverage percentages for badge colors
Badge colors based on coverage:
- brightgreen (>=80%), green (>=60%), yellowgreen (>=40%)
- yellow (>=20%), orange (>=10%), red (<10%)
.PARAMETER OpenReport
Generate and open a full HTML coverage report in the default browser.
Requires ReportGenerator: dotnet tool install -g dotnet-reportgenerator-globaltool
.EXAMPLE
.\Generate-CoverageBadges.ps1
Runs tests and generates coverage badges.
.EXAMPLE
.\Generate-CoverageBadges.ps1 -OpenReport
Runs tests, generates badges, and opens HTML report in browser.
.OUTPUTS
SVG badge files in the configured badges directory.
.NOTES
Author: MaksIT
Requires: .NET SDK, Coverlet (included in test project)
#>
param(
[switch]$OpenReport
)
$ErrorActionPreference = "Stop"
$ScriptDir = $PSScriptRoot
# Import TestRunner module
$ModulePath = Join-Path (Split-Path $ScriptDir -Parent) "TestRunner.psm1"
if (-not (Test-Path $ModulePath)) {
Write-Host "TestRunner module not found at: $ModulePath" -ForegroundColor Red
exit 1
}
Import-Module $ModulePath -Force
# Load settings
$SettingsFile = Join-Path $ScriptDir "scriptsettings.json"
if (-not (Test-Path $SettingsFile)) {
Write-Host "Settings file not found: $SettingsFile" -ForegroundColor Red
exit 1
}
$Settings = Get-Content $SettingsFile | ConvertFrom-Json
# Resolve paths from settings
$TestProjectPath = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.testProject))
$BadgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.badgesDir))
# Ensure badges directory exists
if (-not (Test-Path $BadgesDir)) {
New-Item -ItemType Directory -Path $BadgesDir | Out-Null
}
# Run tests with coverage
$coverage = Invoke-TestsWithCoverage -TestProjectPath $TestProjectPath -KeepResults:$OpenReport
if (-not $coverage.Success) {
Write-Host "Tests failed: $($coverage.Error)" -ForegroundColor Red
exit 1
}
Write-Host "Tests passed!" -ForegroundColor Green
# Store metrics in a hashtable for easy lookup
$metrics = @{
"line" = $coverage.LineRate
"branch" = $coverage.BranchRate
"method" = $coverage.MethodRate
}
# Function to get badge color based on coverage percentage and thresholds from settings
function Get-BadgeColor {
param([double]$percentage)
$thresholds = $Settings.colorThresholds
if ($percentage -ge $thresholds.brightgreen) { return "brightgreen" }
if ($percentage -ge $thresholds.green) { return "green" }
if ($percentage -ge $thresholds.yellowgreen) { return "yellowgreen" }
if ($percentage -ge $thresholds.yellow) { return "yellow" }
if ($percentage -ge $thresholds.orange) { return "orange" }
return "red"
}
# Function to create shields.io style SVG badge
function New-Badge {
param(
[string]$label,
[string]$value,
[string]$color
)
# Calculate widths (approximate character width of 6.5px for the font)
$labelWidth = [math]::Max(($label.Length * 6.5) + 10, 50)
$valueWidth = [math]::Max(($value.Length * 6.5) + 10, 40)
$totalWidth = $labelWidth + $valueWidth
$labelX = $labelWidth / 2
$valueX = $labelWidth + ($valueWidth / 2)
$colorMap = @{
"brightgreen" = "#4c1"
"green" = "#97ca00"
"yellowgreen" = "#a4a61d"
"yellow" = "#dfb317"
"orange" = "#fe7d37"
"red" = "#e05d44"
}
$hexColor = $colorMap[$color]
if (-not $hexColor) { $hexColor = "#9f9f9f" }
return @"
<svg xmlns="http://www.w3.org/2000/svg" width="$totalWidth" height="20" role="img" aria-label="$label`: $value">
<title>$label`: $value</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="$totalWidth" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="$labelWidth" height="20" fill="#555"/>
<rect x="$labelWidth" width="$valueWidth" height="20" fill="$hexColor"/>
<rect width="$totalWidth" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="$labelX" y="15" fill="#010101" fill-opacity=".3">$label</text>
<text x="$labelX" y="14" fill="#fff">$label</text>
<text aria-hidden="true" x="$valueX" y="15" fill="#010101" fill-opacity=".3">$value</text>
<text x="$valueX" y="14" fill="#fff">$value</text>
</g>
</svg>
"@
}
# Generate badges from settings
Write-Host "Generating coverage badges..." -ForegroundColor Cyan
foreach ($badge in $Settings.badges) {
$metricValue = $metrics[$badge.metric]
$color = Get-BadgeColor $metricValue
$svg = New-Badge -label $badge.label -value "$metricValue%" -color $color
$path = Join-Path $BadgesDir $badge.name
$svg | Out-File -FilePath $path -Encoding utf8
Write-Host " $($badge.name): $($badge.label) = $metricValue%" -ForegroundColor Green
}
# Display summary
Write-Host ""
Write-Host "=== Coverage Summary ===" -ForegroundColor Yellow
Write-Host " Line Coverage: $($coverage.LineRate)%"
Write-Host " Branch Coverage: $($coverage.BranchRate)%"
Write-Host " Method Coverage: $($coverage.MethodRate)% ($($coverage.CoveredMethods) of $($coverage.TotalMethods) methods)"
Write-Host "========================" -ForegroundColor Yellow
Write-Host ""
Write-Host "Badges generated in: $BadgesDir" -ForegroundColor Green
Write-Host "Commit the badges/ folder to update README." -ForegroundColor Cyan
# Optionally generate full HTML report
if ($OpenReport -and $coverage.CoverageFile) {
Write-Host ""
Write-Host "Generating HTML report..." -ForegroundColor Cyan
$ResultsDir = Split-Path (Split-Path $coverage.CoverageFile -Parent) -Parent
$ReportDir = Join-Path $ResultsDir "report"
$reportGenArgs = @(
"-reports:$($coverage.CoverageFile)"
"-targetdir:$ReportDir"
"-reporttypes:Html"
)
& reportgenerator @reportGenArgs
$IndexFile = Join-Path $ReportDir "index.html"
if (Test-Path $IndexFile) {
Start-Process $IndexFile
}
Write-Host "TestResults kept for HTML report viewing." -ForegroundColor Gray
}

View File

@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Generate Coverage Badges Script Settings",
"description": "Configuration for Generate-CoverageBadges.ps1 script",
"paths": {
"testProject": "..\\..\\src\\MaksIT.UScheduler.Tests",
"badgesDir": "..\\..\\badges"
},
"badges": [
{
"name": "coverage-lines.svg",
"label": "Line Coverage",
"metric": "line"
},
{
"name": "coverage-branches.svg",
"label": "Branch Coverage",
"metric": "branch"
},
{
"name": "coverage-methods.svg",
"label": "Method Coverage",
"metric": "method"
}
],
"colorThresholds": {
"brightgreen": 80,
"green": 60,
"yellowgreen": 40,
"yellow": 20,
"orange": 10,
"red": 0
}
}

View File

@ -3,7 +3,7 @@
REM Change directory to the location of the script
cd /d %~dp0
REM Invoke the PowerShell script (Release-ToGitHub.ps1) in the same directory
REM Run GitHub release script
powershell -ExecutionPolicy Bypass -File "%~dp0Release-ToGitHub.ps1"
pause

View File

@ -0,0 +1,695 @@
<#
.SYNOPSIS
Automated GitHub release script for MaksIT.UScheduler.
.DESCRIPTION
Creates a GitHub release by performing the following steps:
Pre-flight checks:
- Detects current branch (main or dev)
- On main: requires clean working directory; on dev: uncommitted changes allowed
- Reads version from .csproj (source of truth)
- On main: requires matching tag (vX.Y.Z format)
- Ensures version consistency with CHANGELOG.md
- Confirms GitHub CLI authentication via GH_TOKEN (main branch only)
Test execution:
- Runs all unit tests via Run-Tests.ps1
- Aborts release if any tests fail
- Displays coverage summary (line, branch, method)
Build and release:
- Publishes the .NET project in Release configuration
- Copies Scripts folder into the release
- Creates a versioned ZIP archive
- Extracts release notes from CHANGELOG.md
- Pushes tag to remote if not already present (main branch only)
- Creates (or recreates) the GitHub release with assets (main branch only)
Branch-based behavior (configurable in scriptsettings.json):
- On dev branch: Local build only, no tag required, uncommitted changes allowed
- On release branch: Full GitHub release, tag required, clean working directory required
- On other branches: Blocked
.NOTES
File: Release-ToGitHub.ps1
Author: Maksym Sadovnychyy (MAKS-IT)
Requires: dotnet, git, gh (GitHub CLI - required on main branch only)
Configuration is loaded from scriptsettings.json in the same directory.
Set the GitHub token in an environment variable specified by github.tokenEnvVar.
.EXAMPLE
.\Release-ToGitHub.ps1
Runs the release process using settings from scriptsettings.json.
On dev branch: creates local build (no tag needed).
On main branch: publishes to GitHub (tag required).
.EXAMPLE
# Recommended workflow:
# 1. On dev branch: Update version in .csproj and CHANGELOG.md
# 2. Commit changes
# 3. Run: .\Release-ToGitHub.ps1
# (creates local build for testing - no tag needed)
# 4. Test the build
# 5. Merge to main: git checkout main && git merge dev
# 6. Create tag: git tag v1.0.1
# 7. Run: .\Release-ToGitHub.ps1
# (publishes to GitHub)
#>
# No parameters - behavior is controlled by current branch (configured in scriptsettings.json):
# - dev branch -> Local build only (no tag required, uncommitted changes allowed)
# - release branch -> Full release to GitHub (tag required, clean working directory)
# Load settings from scriptsettings.json
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$settingsPath = Join-Path $scriptDir "scriptsettings.json"
if (-not (Test-Path $settingsPath)) {
Write-Error "Settings file not found: $settingsPath"
exit 1
}
$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
# Import TestRunner module
$modulePath = Join-Path (Split-Path $scriptDir -Parent) "TestRunner.psm1"
if (-not (Test-Path $modulePath)) {
Write-Error "TestRunner module not found at: $modulePath"
exit 1
}
Import-Module $modulePath -Force
# Set GH_TOKEN from custom environment variable for GitHub CLI authentication
$tokenEnvVar = $settings.github.tokenEnvVar
$env:GH_TOKEN = [System.Environment]::GetEnvironmentVariable($tokenEnvVar)
# Paths from settings (resolve relative to script directory)
$csprojPaths = @()
if ($settings.paths.csprojPath -is [System.Collections.IEnumerable] -and -not ($settings.paths.csprojPath -is [string])) {
foreach ($path in $settings.paths.csprojPath) {
$csprojPaths += [System.IO.Path]::GetFullPath((Join-Path $scriptDir $path))
}
}
else {
$csprojPaths += [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.csprojPath))
}
$stagingDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.stagingDir))
$releaseDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.releaseDir))
$changelogPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.changelogPath))
$scriptsPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.scriptsPath))
$testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testProject))
# Release naming patterns
$zipNamePattern = $settings.release.zipNamePattern
$releaseTitlePattern = $settings.release.releaseTitlePattern
# Branch configuration
$releaseBranch = $settings.branches.release
$devBranch = $settings.branches.dev
# Project configuration (avoid hardcoding project names)
$projectsSettings = $settings.projects
$scheduleManagerCsprojEndsWith = $projectsSettings.scheduleManagerCsprojEndsWith
$uschedulerCsprojEndsWith = $projectsSettings.uschedulerCsprojEndsWith
$scheduleManagerAppSettingsFile = $projectsSettings.scheduleManagerAppSettingsFile
$uschedulerAppSettingsFile = $projectsSettings.uschedulerAppSettingsFile
$scheduleManagerServiceBinPath = $projectsSettings.scheduleManagerServiceBinPath
$uschedulerLogDir = $projectsSettings.uschedulerLogDir
$scriptsRelativeToExe = $projectsSettings.scriptsRelativeToExe
# Helper: ensure required commands exist
function Assert-Command {
param([string]$cmd)
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
Write-Error "Required command '$cmd' is missing. Aborting."
exit 1
}
}
# Helper: extract a csproj property (first match)
function Get-CsprojPropertyValue {
param(
[Parameter(Mandatory=$true)][xml]$csproj,
[Parameter(Mandatory=$true)][string]$propertyName
)
$propNode = $csproj.Project.PropertyGroup |
Where-Object { $_.$propertyName } |
Select-Object -First 1
if ($propNode) {
return $propNode.$propertyName
}
return $null
}
# Helper: resolve output assembly name for published exe
function Resolve-ProjectExeName {
param(
[Parameter(Mandatory=$true)][string]$projPath
)
[xml]$csproj = Get-Content $projPath
$assemblyName = Get-CsprojPropertyValue -csproj $csproj -propertyName "AssemblyName"
if ($assemblyName) {
return $assemblyName
}
return [System.IO.Path]::GetFileNameWithoutExtension($projPath)
}
# Helper: find csproj by configured suffix
function Find-CsprojByEndsWith {
param(
[Parameter(Mandatory=$true)][string[]]$paths,
[Parameter(Mandatory=$true)][string]$endsWith
)
if (-not $endsWith) {
return $null
}
return $paths | Where-Object { $_ -like "*$endsWith" } | Select-Object -First 1
}
Assert-Command dotnet
Assert-Command git
# gh command check deferred until after branch detection (only needed on main branch)
Write-Host ""
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host "RELEASE BUILD" -ForegroundColor Cyan
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host ""
# ==============================================================================
# PRE-FLIGHT CHECKS
# ==============================================================================
# 1. Detect current branch and determine release mode
Write-Host "Detecting current branch..." -ForegroundColor Gray
$currentBranch = git rev-parse --abbrev-ref HEAD 2>$null
if ($LASTEXITCODE -ne 0 -or -not $currentBranch) {
Write-Error "Could not determine current branch."
exit 1
}
$currentBranch = $currentBranch.Trim()
Write-Host " Branch: $currentBranch" -ForegroundColor Green
$isDevBranch = $currentBranch -eq $devBranch
$isReleaseBranch = $currentBranch -eq $releaseBranch
if (-not $isDevBranch -and -not $isReleaseBranch) {
Write-Error "Releases can only be created from '$releaseBranch' or '$devBranch' branches. Current branch: $currentBranch"
exit 1
}
if ($isDevBranch) {
Write-Host " Dev branch ($devBranch) - local build only (no GitHub release)." -ForegroundColor Yellow
}
else {
Write-Host " Release branch ($releaseBranch) - will publish to GitHub." -ForegroundColor Cyan
Assert-Command gh
}
# 2. Check for uncommitted changes (required on main, allowed on dev)
$gitStatus = git status --porcelain 2>$null
if ($gitStatus) {
if ($isReleaseBranch) {
Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing."
Write-Host ""
Write-Host "Uncommitted files:" -ForegroundColor Yellow
$gitStatus | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow }
exit 1
}
else {
Write-Host " Uncommitted changes detected (allowed on dev branch)." -ForegroundColor Yellow
}
} else {
Write-Host " Working directory is clean." -ForegroundColor Green
}
# 3. Get version from csproj (source of truth)
Write-Host "Reading version(s) from csproj(s)..." -ForegroundColor Gray
$projectVersions = @{}
foreach ($projPath in $csprojPaths) {
if (-not (Test-Path $projPath)) {
Write-Error "Csproj not found at: $projPath"
exit 1
}
[xml]$csproj = Get-Content $projPath
$version = Get-CsprojPropertyValue -csproj $csproj -propertyName "Version"
if (-not $version) {
Write-Error "Version not found in $projPath"
exit 1
}
$projectVersions[$projPath] = $version
Write-Host " $([System.IO.Path]::GetFileName($projPath)): $version" -ForegroundColor Green
}
# Use the first project's version as the main version for tag/release
$version = $projectVersions[$csprojPaths[0]]
# 4. Handle tag based on branch
if ($isReleaseBranch) {
# Main branch: tag is required and must match version
Write-Host "Checking for tag on current commit..." -ForegroundColor Gray
$tag = git describe --tags --exact-match HEAD 2>$null
if ($LASTEXITCODE -ne 0 -or -not $tag) {
Write-Error "No tag found on current commit. Create a tag: git tag v$version"
exit 1
}
$tag = $tag.Trim()
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
Write-Error "Tag '$tag' does not match expected format 'vX.Y.Z' (e.g., v$version)."
exit 1
}
$tagVersion = $Matches[1]
if ($tagVersion -ne $version) {
Write-Error "Tag version ($tagVersion) does not match csproj version ($version)."
Write-Host " Either update the tag or the csproj version." -ForegroundColor Yellow
exit 1
}
Write-Host " Tag found: $tag (matches csproj)" -ForegroundColor Green
}
else {
# Dev branch: no tag required, use version from csproj
$tag = "v$version"
Write-Host " Using version from csproj (no tag required on dev)." -ForegroundColor Gray
}
# 5. Verify CHANGELOG.md has matching version entry
Write-Host "Verifying CHANGELOG.md..." -ForegroundColor Gray
if (-not (Test-Path $changelogPath)) {
Write-Error "CHANGELOG.md not found at: $changelogPath"
exit 1
}
$changelog = Get-Content $changelogPath -Raw
if ($changelog -notmatch '##\s+v(\d+\.\d+\.\d+)') {
Write-Error "No version entry found in CHANGELOG.md"
exit 1
}
$changelogVersion = $Matches[1]
if ($changelogVersion -ne $version) {
Write-Error "Csproj version ($version) does not match latest CHANGELOG.md version ($changelogVersion)."
Write-Host " Update CHANGELOG.md or the csproj version." -ForegroundColor Yellow
exit 1
}
Write-Host " CHANGELOG.md version matches: v$changelogVersion" -ForegroundColor Green
# 6. Check GitHub authentication (skip for local-only builds)
if (-not $isDevBranch) {
Write-Host "Checking GitHub authentication..." -ForegroundColor Gray
if (-not $env:GH_TOKEN) {
Write-Error "GH_TOKEN environment variable is not set. Set $tokenEnvVar and rerun."
exit 1
}
$authTest = gh api user 2>$null
if ($LASTEXITCODE -ne 0 -or -not $authTest) {
Write-Error "GitHub CLI authentication failed. GH_TOKEN may be invalid or missing repo scope."
exit 1
}
Write-Host " GitHub CLI authenticated." -ForegroundColor Green
}
else {
Write-Host "Skipping GitHub authentication (local-only mode)." -ForegroundColor Gray
}
Write-Host ""
Write-Host "All pre-flight checks passed!" -ForegroundColor Green
Write-Host ""
# ==============================================================================
# RUN TESTS
# ==============================================================================
Write-Host "Running tests..." -ForegroundColor Cyan
# Run tests using TestRunner module
$testResult = Invoke-TestsWithCoverage -TestProjectPath $testProjectPath -Silent
if (-not $testResult.Success) {
Write-Error "Tests failed. Release aborted."
Write-Host " Error: $($testResult.Error)" -ForegroundColor Red
exit 1
}
Write-Host " All tests passed!" -ForegroundColor Green
Write-Host " Line Coverage: $($testResult.LineRate)%" -ForegroundColor Gray
Write-Host " Branch Coverage: $($testResult.BranchRate)%" -ForegroundColor Gray
Write-Host " Method Coverage: $($testResult.MethodRate)%" -ForegroundColor Gray
Write-Host ""
# ==============================================================================
# BUILD AND RELEASE
# ==============================================================================
# 7. Prepare staging directory
Write-Host "Preparing staging directory..." -ForegroundColor Cyan
if (Test-Path $stagingDir) {
Remove-Item $stagingDir -Recurse -Force
}
New-Item -ItemType Directory -Path $stagingDir | Out-Null
$binDir = Join-Path $stagingDir "bin"
# 8. Publish the project to staging/bin
Write-Host "Publishing projects to bin folder..." -ForegroundColor Cyan
$publishSuccess = $true
$publishedProjects = @()
foreach ($projPath in $csprojPaths) {
$projName = [System.IO.Path]::GetFileNameWithoutExtension($projPath)
$projBinDir = Join-Path $binDir $projName
dotnet publish $projPath -c Release -o $projBinDir
if ($LASTEXITCODE -ne 0) {
Write-Error "dotnet publish failed for $projName."
$publishSuccess = $false
}
else {
$exeBaseName = Resolve-ProjectExeName -projPath $projPath
$publishedProjects += [PSCustomObject]@{
ProjPath = $projPath
ProjName = $projName
BinDir = $projBinDir
ExeBaseName = $exeBaseName
}
Write-Host " Published $projName successfully to: $projBinDir" -ForegroundColor Green
}
}
if (-not $publishSuccess) {
exit 1
}
# 9. Copy Scripts folder to staging
Write-Host "Copying Scripts folder..." -ForegroundColor Cyan
if (-not (Test-Path $scriptsPath)) {
Write-Error "Scripts folder not found at: $scriptsPath"
exit 1
}
$scriptsDestination = Join-Path $stagingDir "Scripts"
Copy-Item -Path $scriptsPath -Destination $scriptsDestination -Recurse
Write-Host " Scripts copied to: $scriptsDestination" -ForegroundColor Green
Write-Host "Updating ScheduleManager appsettings with UScheduler path..." -ForegroundColor Cyan
# 10. Update appsettings.json with scripts in disabled state
# Dynamically locate ScheduleManager appsettings based on settings.projects.scheduleManagerCsprojEndsWith
$scheduleManagerCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $scheduleManagerCsprojEndsWith
if ($scheduleManagerCsprojPath) {
$scheduleManagerProjName = [System.IO.Path]::GetFileNameWithoutExtension($scheduleManagerCsprojPath)
$scheduleManagerBinDir = Join-Path $binDir $scheduleManagerProjName
$scheduleManagerAppSettingsPath = Join-Path $scheduleManagerBinDir $scheduleManagerAppSettingsFile
if (Test-Path $scheduleManagerAppSettingsPath) {
$smAppSettings = Get-Content $scheduleManagerAppSettingsPath -Raw | ConvertFrom-Json
if ($smAppSettings.USchedulerSettings) {
$smAppSettings.USchedulerSettings.ServiceBinPath = $scheduleManagerServiceBinPath
$jsonOutput = $smAppSettings | ConvertTo-Json -Depth 10
Set-Content -Path $scheduleManagerAppSettingsPath -Value $jsonOutput -Encoding UTF8
Write-Host " Updated ServiceBinPath in ScheduleManager appsettings" -ForegroundColor Green
}
else {
Write-Host " Warning: USchedulerSettings section not found in ScheduleManager appsettings" -ForegroundColor Yellow
}
}
else {
Write-Host " Warning: $scheduleManagerAppSettingsFile not found in $scheduleManagerProjName bin folder" -ForegroundColor Yellow
}
}
else {
Write-Host " Warning: ScheduleManager csproj not found in csprojPaths array" -ForegroundColor Yellow
}
Write-Host "Updating UScheduler appsettings with new LogDir bundled scripts paths..." -ForegroundColor Cyan
# Resolve UScheduler csproj by configured suffix (avoid hardcoded ScheduleManager exclusion)
$uschedulerCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $uschedulerCsprojEndsWith
if ($uschedulerCsprojPath) {
$uschedulerProjName = [System.IO.Path]::GetFileNameWithoutExtension($uschedulerCsprojPath)
$uschedulerBinDir = Join-Path $binDir $uschedulerProjName
$appSettingsPath = Join-Path $uschedulerBinDir $uschedulerAppSettingsFile
if (Test-Path $appSettingsPath) {
$appSettings = Get-Content $appSettingsPath -Raw | ConvertFrom-Json
# Update LogDir for release
if ($appSettings.Configuration) {
$appSettings.Configuration.LogDir = $uschedulerLogDir
Write-Host " Updated LogDir in UScheduler appsettings" -ForegroundColor Green
}
else {
Write-Host " Warning: Configuration section not found in UScheduler appsettings" -ForegroundColor Yellow
}
# Find all .ps1 files in Scripts folder (exclude utility scripts in subfolders named "Utilities")
$psScripts = Get-ChildItem -Path $scriptsDestination -Filter "*.ps1" -Recurse |
Where-Object { $_.Directory.Name -ne "Utilities" } |
ForEach-Object {
$relativePath = $_.FullName.Substring($scriptsDestination.Length + 1).Replace('/', '\')
$scriptPath = "$scriptsRelativeToExe\$relativePath"
[PSCustomObject]@{
Path = $scriptPath
IsSigned = $false
Disabled = $true
}
}
# Add scripts to Powershell configuration
if ($psScripts) {
if (-not $appSettings.Configuration) {
$appSettings | Add-Member -MemberType NoteProperty -Name "Configuration" -Value ([PSCustomObject]@{})
}
$appSettings.Configuration.Powershell = @($psScripts)
$jsonOutput = $appSettings | ConvertTo-Json -Depth 10
Set-Content -Path $appSettingsPath -Value $jsonOutput -Encoding UTF8
Write-Host " Added $($psScripts.Count) PowerShell script(s) to appsettings (disabled)" -ForegroundColor Green
$psScripts | ForEach-Object { Write-Host " - $($_.Path)" -ForegroundColor Gray }
}
}
else {
Write-Host " Warning: $uschedulerAppSettingsFile not found in $uschedulerProjName bin folder" -ForegroundColor Yellow
}
}
else {
Write-Host " Warning: UScheduler csproj not found in csprojPaths array" -ForegroundColor Yellow
}
# 11. Create launcher batch file (if enabled)
if ($settings.launcher -and $settings.launcher.enabled) {
Write-Host "Creating launcher batch file..." -ForegroundColor Cyan
$launcherFileName = $settings.launcher.fileName
$targetProject = $settings.launcher.targetProject
# Determine which project to launch
$targetCsprojPath = $null
$targetExeName = $null
$targetProjName = $null
if ($targetProject -eq "scheduleManager") {
$targetCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $scheduleManagerCsprojEndsWith
}
elseif ($targetProject -eq "uscheduler") {
$targetCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $uschedulerCsprojEndsWith
}
else {
Write-Host " Warning: Unknown targetProject '$targetProject' in launcher settings" -ForegroundColor Yellow
}
if ($targetCsprojPath) {
$targetProjName = [System.IO.Path]::GetFileNameWithoutExtension($targetCsprojPath)
$targetExeName = Resolve-ProjectExeName -projPath $targetCsprojPath
$batPath = Join-Path $stagingDir $launcherFileName
$exePath = "%~dp0bin\$targetProjName\$targetExeName.exe"
$batContent = @"
@echo off
start "" "$exePath"
"@
Set-Content -Path $batPath -Value $batContent -Encoding ASCII
Write-Host " Created launcher: $launcherFileName -> $exePath" -ForegroundColor Green
}
else {
Write-Host " Warning: Could not find target project for launcher" -ForegroundColor Yellow
}
}
else {
Write-Host "Skipping launcher batch file creation (disabled in settings)." -ForegroundColor Gray
}
Write-Host ""
# 12. Prepare release directory
if (!(Test-Path $releaseDir)) {
New-Item -ItemType Directory -Path $releaseDir | Out-Null
}
# 13. Create zip file
$zipName = $zipNamePattern -replace '\{version\}', $version
$zipPath = Join-Path $releaseDir $zipName
if (Test-Path $zipPath) {
Remove-Item $zipPath -Force
}
Write-Host "Creating archive $zipName..." -ForegroundColor Cyan
Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath -Force
if (-not (Test-Path $zipPath)) {
Write-Error "Failed to create archive $zipPath"
exit 1
}
Write-Host " Archive created: $zipPath" -ForegroundColor Green
# 14. Extract release notes from CHANGELOG.md
Write-Host "Extracting release notes..." -ForegroundColor Cyan
$pattern = "(?ms)^##\s+v$([regex]::Escape($version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)"
$match = [regex]::Match($changelog, $pattern)
if (-not $match.Success) {
Write-Error "Changelog entry for version $version not found."
exit 1
}
$releaseNotes = $match.Value.Trim()
Write-Host " Release notes extracted." -ForegroundColor Green
# 15. Get repository info
$remoteUrl = git config --get remote.origin.url
if ($LASTEXITCODE -ne 0 -or -not $remoteUrl) {
Write-Error "Could not determine git remote origin URL."
exit 1
}
if ($remoteUrl -match "[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.git)?$") {
$owner = $matches['owner']
$repoName = $matches['repo']
$repo = "$owner/$repoName"
} else {
Write-Error "Could not parse GitHub repo from remote URL: $remoteUrl"
exit 1
}
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
Write-Host ""
Write-Host "Release Summary:" -ForegroundColor Cyan
Write-Host " Repository: $repo" -ForegroundColor White
Write-Host " Tag: $tag" -ForegroundColor White
Write-Host " Title: $releaseName" -ForegroundColor White
Write-Host ""
# 16. Check if tag is pushed to remote (skip on dev branch)
if (-not $isDevBranch) {
Write-Host "Verifying tag is pushed to remote..." -ForegroundColor Cyan
$remoteTag = git ls-remote --tags origin $tag 2>$null
if (-not $remoteTag) {
Write-Host " Tag $tag not found on remote. Pushing..." -ForegroundColor Yellow
git push origin $tag
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to push tag $tag to remote."
exit 1
}
Write-Host " Tag pushed successfully." -ForegroundColor Green
}
else {
Write-Host " Tag exists on remote." -ForegroundColor Green
}
# 17. Create or update GitHub release
Write-Host "Creating GitHub release..." -ForegroundColor Cyan
# Check if release already exists
gh release view $tag --repo $repo 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host " Release $tag already exists. Deleting..." -ForegroundColor Yellow
gh release delete $tag --repo $repo --yes
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to delete existing release $tag."
exit 1
}
}
# Create new release using existing tag
$ghArgs = @(
"release", "create", $tag, $zipPath
"--repo", $repo
"--title", $releaseName
"--notes", $releaseNotes
)
& gh @ghArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to create GitHub release for tag $tag."
exit 1
}
Write-Host " GitHub release created successfully." -ForegroundColor Green
}
else {
Write-Host "Skipping GitHub release (dev branch)." -ForegroundColor Yellow
}
# 18. Cleanup
if (Test-Path $stagingDir) {
Remove-Item $stagingDir -Recurse -Force
Write-Host " Cleaned up staging directory." -ForegroundColor Gray
}
Write-Host ""
Write-Host "==================================================" -ForegroundColor Green
if ($isDevBranch) {
Write-Host "DEV BUILD COMPLETE" -ForegroundColor Green
}
else {
Write-Host "RELEASE COMPLETE" -ForegroundColor Green
}
Write-Host "==================================================" -ForegroundColor Green
Write-Host ""
if (-not $isDevBranch) {
Write-Host "Release URL: https://github.com/$repo/releases/tag/$tag" -ForegroundColor Cyan
}
Write-Host "Artifacts location: $releaseDir" -ForegroundColor Gray
if ($isDevBranch) {
Write-Host ""
Write-Host "To publish to GitHub, merge to main, tag, and run the script again:" -ForegroundColor Yellow
Write-Host " git checkout main" -ForegroundColor Yellow
Write-Host " git merge dev" -ForegroundColor Yellow
Write-Host " git tag v$version" -ForegroundColor Yellow
Write-Host " .\Release-ToGitHub.ps1" -ForegroundColor Yellow
}
Write-Host ""

View File

@ -0,0 +1,57 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Release to GitHub Script Settings",
"description": "Configuration file for Release-ToGitHub.ps1 script (automated GitHub release creation)",
"github": {
"tokenEnvVar": "GITHUB_MAKS_IT_COM"
},
"branches": {
"release": "main",
"dev": "dev"
},
"paths": {
"csprojPath": [
"..\\..\\src\\MaksIT.UScheduler\\MaksIT.UScheduler.csproj",
"..\\..\\src\\MaksIT.UScheduler.ScheduleManager\\MaksIT.UScheduler.ScheduleManager.csproj"
],
"stagingDir": "..\\..\\staging",
"releaseDir": "..\\..\\release",
"changelogPath": "..\\..\\CHANGELOG.md",
"scriptsPath": "..\\..\\src\\Scripts",
"testProject": "..\\..\\src\\MaksIT.UScheduler.Tests"
},
"release": {
"zipNamePattern": "maksit.uscheduler-{version}.zip",
"releaseTitlePattern": "Release {version}"
},
"launcher": {
"enabled": true,
"fileName": "Start-ScheduleManager.bat",
"targetProject": "scheduleManager"
},
"projects": {
"scheduleManagerCsprojEndsWith": "MaksIT.UScheduler.ScheduleManager.csproj",
"uschedulerCsprojEndsWith": "MaksIT.UScheduler.csproj",
"scheduleManagerAppSettingsFile": "appsettings.json",
"uschedulerAppSettingsFile": "appsettings.json",
"scheduleManagerServiceBinPath": "..\\MaksIT.UScheduler\\",
"uschedulerLogDir": "..\\..\\Logs",
"scriptsRelativeToExe": "..\\..\\Scripts"
},
"_comments": {
"projects": {
"scheduleManagerCsprojEndsWith": "Used to detect ScheduleManager csproj from csprojPath list",
"uschedulerCsprojEndsWith": "Used to detect UScheduler csproj from csprojPath list",
"scheduleManagerAppSettingsFile": "Config file name inside published output for ScheduleManager",
"uschedulerAppSettingsFile": "Config file name inside published output for UScheduler",
"scheduleManagerServiceBinPath": "Value written into USchedulerSettings.ServiceBinPath in ScheduleManager config",
"uschedulerLogDir": "Value written into Configuration.LogDir in UScheduler config",
"scriptsRelativeToExe": "Scripts base path relative to executable folder (used for appsettings script list)"
},
"shortcut": {
"enabled": "If true, creates a .lnk in staging root",
"projectRole": "Which project to point the shortcut to (ScheduleManager or UScheduler)",
"fileName": "Shortcut file name in staging root"
}
}
}

158
utils/TestRunner.psm1 Normal file
View File

@ -0,0 +1,158 @@
<#
.SYNOPSIS
PowerShell module for running tests with code coverage.
.DESCRIPTION
Provides the Invoke-TestsWithCoverage function for running .NET tests
with Coverlet code coverage collection and parsing results.
.NOTES
Author: MaksIT
Usage: Import-Module .\TestRunner.psm1
#>
function Invoke-TestsWithCoverage {
<#
.SYNOPSIS
Runs unit tests with code coverage and returns coverage metrics.
.PARAMETER TestProjectPath
Path to the test project directory.
.PARAMETER Silent
Suppress console output (for JSON consumption).
.PARAMETER KeepResults
Keep the TestResults folder after execution.
.OUTPUTS
PSCustomObject with properties:
- Success: bool
- Error: string (if failed)
- LineRate: double
- BranchRate: double
- MethodRate: double
- TotalMethods: int
- CoveredMethods: int
- CoverageFile: string
.EXAMPLE
$result = Invoke-TestsWithCoverage -TestProjectPath ".\Tests"
if ($result.Success) { Write-Host "Line coverage: $($result.LineRate)%" }
#>
param(
[Parameter(Mandatory = $true)]
[string]$TestProjectPath,
[switch]$Silent,
[switch]$KeepResults
)
$ErrorActionPreference = "Stop"
# Resolve path
$TestProjectDir = Resolve-Path $TestProjectPath -ErrorAction SilentlyContinue
if (-not $TestProjectDir) {
return [PSCustomObject]@{
Success = $false
Error = "Test project not found at: $TestProjectPath"
}
}
$ResultsDir = Join-Path $TestProjectDir "TestResults"
# Clean previous results
if (Test-Path $ResultsDir) {
Remove-Item -Recurse -Force $ResultsDir
}
if (-not $Silent) {
Write-Host "Running tests with code coverage..." -ForegroundColor Cyan
Write-Host " Test Project: $TestProjectDir" -ForegroundColor Gray
}
# Run tests with coverage collection
Push-Location $TestProjectDir
try {
$dotnetArgs = @(
"test"
"--collect:XPlat Code Coverage"
"--results-directory", $ResultsDir
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
)
if ($Silent) {
$null = & dotnet @dotnetArgs 2>&1
} else {
& dotnet @dotnetArgs
}
$testExitCode = $LASTEXITCODE
if ($testExitCode -ne 0) {
return [PSCustomObject]@{
Success = $false
Error = "Tests failed with exit code $testExitCode"
}
}
}
finally {
Pop-Location
}
# Find the coverage file
$CoverageFile = Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Select-Object -First 1
if (-not $CoverageFile) {
return [PSCustomObject]@{
Success = $false
Error = "Coverage file not found"
}
}
if (-not $Silent) {
Write-Host "Coverage file found: $($CoverageFile.FullName)" -ForegroundColor Green
Write-Host "Parsing coverage data..." -ForegroundColor Cyan
}
# Parse coverage data from Cobertura XML
[xml]$coverageXml = Get-Content $CoverageFile.FullName
$lineRate = [math]::Round([double]$coverageXml.coverage.'line-rate' * 100, 1)
$branchRate = [math]::Round([double]$coverageXml.coverage.'branch-rate' * 100, 1)
# Calculate method coverage from packages
$totalMethods = 0
$coveredMethods = 0
foreach ($package in $coverageXml.coverage.packages.package) {
foreach ($class in $package.classes.class) {
foreach ($method in $class.methods.method) {
$totalMethods++
if ([double]$method.'line-rate' -gt 0) {
$coveredMethods++
}
}
}
}
$methodRate = if ($totalMethods -gt 0) { [math]::Round(($coveredMethods / $totalMethods) * 100, 1) } else { 0 }
# Cleanup unless KeepResults is specified
if (-not $KeepResults) {
if (Test-Path $ResultsDir) {
Remove-Item -Recurse -Force $ResultsDir
}
}
# Return results
return [PSCustomObject]@{
Success = $true
LineRate = $lineRate
BranchRate = $branchRate
MethodRate = $methodRate
TotalMethods = $totalMethods
CoveredMethods = $coveredMethods
CoverageFile = $CoverageFile.FullName
}
}
Export-ModuleMember -Function Invoke-TestsWithCoverage