diff --git a/CHANGELOG.md b/CHANGELOG.md index d59095b..e992713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.1] - 2026-09-06 + +### Fixed + +- Windows setup exe now ships bundled Scripts next to the worker and runs `--prepare-data` after files are installed, so example scripts are copied to `C:\MaksIT\Scripts` when those folders do not already exist. + ## [1.1.0] - 2026-09-06 ### Added diff --git a/README.md b/README.md index 3f6eb38..e04d750 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, commit format, and ## Scripts Examples -> **Note:** These examples are **bundled with the release** and copied to `C:\MaksIT\Scripts` on first install **only if that folder or script is not already present**. Existing files and script folders are never overwritten or merged. They are listed in the default configuration but **disabled by default**. To enable an example, set `"Disabled": false` in `%ProgramData%\MaksIT\UScheduler\settings.json` (or use the UI). +> **Note:** These examples are **bundled with the release** and copied to `C:\MaksIT\Scripts` by the Windows setup exe (and by `--install` / `--prepare-data`) **only if that folder or script is not already present**. Existing files and script folders are never overwritten or merged. They are listed in the default configuration but **disabled by default**. To enable an example, set `"Disabled": false` in `%ProgramData%\MaksIT\UScheduler\settings.json` (or use the UI). - [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 @@ -91,12 +91,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, commit format, and | Location | Purpose | Who can write | |----------|---------|----------------| | `C:\Program Files\MaksIT\UScheduler` | Worker (`MaksIT.UScheduler.exe`) and UI (`MaksIT.UScheduler.UI.exe`) | Administrators | -| `C:\MaksIT\Scripts` | Scheduled scripts (all users). Install copies bundled examples only into missing folders; existing scripts are never overwritten. | Users (after install) | +| `C:\MaksIT\Scripts` | Scheduled scripts (all users). The Windows setup exe (and `--install` / `--prepare-data`) copies bundled examples only into missing folders; existing scripts are never overwritten. | Users (after install) | | `C:\MaksIT\Logs` | Service and script logs | Users (after install) | | `%ProgramData%\MaksIT\UScheduler\settings.json` | Shared schedule configuration | Users (after install) | | `%AppData%\MaksIT\UScheduler\settings.json` | Per-user UI prefs (service bin path override) | Current user | -Registering the service (or `MaksIT.UScheduler --prepare-data`) creates the `C:\MaksIT` and ProgramData folders and grants the Users group modify rights, so the UI can stay unelevated. +The Windows setup exe, registering the service, or `MaksIT.UScheduler --prepare-data` creates the `C:\MaksIT` and ProgramData folders, copies bundled example scripts when missing, and grants the Users group modify rights so the UI can stay unelevated. ### Using CLI Commands diff --git a/src/MaksIT.UScheduler.Shared/HostDataDirectories.cs b/src/MaksIT.UScheduler.Shared/HostDataDirectories.cs index 86b102a..a0ef38e 100644 --- a/src/MaksIT.UScheduler.Shared/HostDataDirectories.cs +++ b/src/MaksIT.UScheduler.Shared/HostDataDirectories.cs @@ -41,13 +41,23 @@ public static class HostDataDirectories { GrantUnixGroupWrite(settingsDir, messages); } - var copy = CopySeedScripts(installDir, HostPaths.DefaultScriptsDirectory); - if (copy.CopiedFiles > 0) - messages.Add($"Copied {copy.CopiedFiles} new seed script file(s) to {HostPaths.DefaultScriptsDirectory}."); - if (copy.SkippedItems > 0) - messages.Add($"Left {copy.SkippedItems} existing script folder(s)/file(s) unchanged in {HostPaths.DefaultScriptsDirectory}."); - if (copy.CopiedFiles == 0 && copy.SkippedItems == 0) + var source = HostPaths.FindBundledScriptsDirectory(installDir); + if (source is null || !Directory.Exists(source)) { + messages.Add( + $"Bundled scripts were not found next to '{installDir}'; left {HostPaths.DefaultScriptsDirectory} unchanged."); + } + else if (PathsEqual(source, HostPaths.DefaultScriptsDirectory)) { messages.Add($"Scripts directory: {HostPaths.DefaultScriptsDirectory}"); + } + else { + var copy = CopySeedScriptsIfMissing(source, HostPaths.DefaultScriptsDirectory); + if (copy.CopiedFiles > 0) + messages.Add($"Copied {copy.CopiedFiles} new seed script file(s) to {HostPaths.DefaultScriptsDirectory}."); + if (copy.SkippedItems > 0) + messages.Add($"Left {copy.SkippedItems} existing script folder(s)/file(s) unchanged in {HostPaths.DefaultScriptsDirectory}."); + if (copy.CopiedFiles == 0 && copy.SkippedItems == 0) + messages.Add($"Scripts directory: {HostPaths.DefaultScriptsDirectory}"); + } messages.Add($"Logs directory: {HostPaths.DefaultLogDirectory}"); messages.Add($"Shared settings: {HostPaths.SharedSettingsFile}"); @@ -58,14 +68,6 @@ public static class HostDataDirectories { } } - private static SeedCopyResult CopySeedScripts(string installDirectory, string destination) { - var source = HostPaths.FindBundledScriptsDirectory(installDirectory); - if (source is null || !Directory.Exists(source) || PathsEqual(source, destination)) - return default; - - return CopySeedScriptsIfMissing(source, destination); - } - /// /// Copies bundled example scripts only when the destination does not already /// have that file or script folder. Existing folders are left as-is (no merge), diff --git a/src/MaksIT.UScheduler.Shared/HostPaths.cs b/src/MaksIT.UScheduler.Shared/HostPaths.cs index 954a79d..800c171 100644 --- a/src/MaksIT.UScheduler.Shared/HostPaths.cs +++ b/src/MaksIT.UScheduler.Shared/HostPaths.cs @@ -141,6 +141,10 @@ public static class HostPaths { } private static bool LooksLikeScriptsRoot(string path) => - Directory.Exists(Path.Combine(path, "File-Sync")) - || File.Exists(Path.Combine(path, "SchedulerTemplate.psm1")); + Directory.Exists(path) + && (Directory.Exists(Path.Combine(path, "File-Sync")) + || Directory.Exists(Path.Combine(path, "Native-Sync")) + || Directory.Exists(Path.Combine(path, "HyperV-Backup")) + || Directory.Exists(Path.Combine(path, "Windows-Update")) + || File.Exists(Path.Combine(path, "SchedulerTemplate.psm1"))); } diff --git a/src/MaksIT.UScheduler.Tests/ConfigurationFileServiceTests.cs b/src/MaksIT.UScheduler.Tests/ConfigurationFileServiceTests.cs index 140f876..10c677c 100644 --- a/src/MaksIT.UScheduler.Tests/ConfigurationFileServiceTests.cs +++ b/src/MaksIT.UScheduler.Tests/ConfigurationFileServiceTests.cs @@ -173,6 +173,40 @@ public class HostPathsTests { path); } + [Fact] + public void FindBundledScriptsDirectory_finds_scripts_next_to_start() { + var root = Path.Combine(Path.GetTempPath(), $"uscheduler-bundled-{Guid.NewGuid():N}"); + var start = Path.Combine(root, "install"); + var scripts = Path.Combine(start, "Scripts"); + Directory.CreateDirectory(Path.Combine(scripts, "Native-Sync")); + File.WriteAllText(Path.Combine(scripts, "Native-Sync", "native-sync.ps1"), "seed"); + + try { + var found = HostPaths.FindBundledScriptsDirectory(start); + Assert.Equal(Path.GetFullPath(scripts), found is null ? null : Path.GetFullPath(found)); + } + finally { + Directory.Delete(root, true); + } + } + + [Fact] + public void FindBundledScriptsDirectory_finds_sibling_scripts_folder() { + var root = Path.Combine(Path.GetTempPath(), $"uscheduler-sibling-{Guid.NewGuid():N}"); + var start = Path.Combine(root, "MaksIT.UScheduler"); + var scripts = Path.Combine(root, "Scripts"); + Directory.CreateDirectory(start); + Directory.CreateDirectory(Path.Combine(scripts, "File-Sync")); + + try { + var found = HostPaths.FindBundledScriptsDirectory(start); + Assert.Equal(Path.GetFullPath(scripts), found is null ? null : Path.GetFullPath(found)); + } + finally { + Directory.Delete(root, true); + } + } + [Fact] public void ResolveScriptPath_uses_scripts_dir() { var dir = Path.Combine(Path.GetTempPath(), $"uscheduler-scripts-{Guid.NewGuid():N}"); diff --git a/src/MaksIT.UScheduler.Tests/HostDataDirectoriesTests.cs b/src/MaksIT.UScheduler.Tests/HostDataDirectoriesTests.cs index 22a3a40..f4aeeb7 100644 --- a/src/MaksIT.UScheduler.Tests/HostDataDirectoriesTests.cs +++ b/src/MaksIT.UScheduler.Tests/HostDataDirectoriesTests.cs @@ -76,4 +76,27 @@ public class HostDataDirectoriesTests { Directory.Delete(root, true); } } + + [Fact] + public void CopySeedScriptsIfMissing_copies_into_empty_destination() { + var root = Path.Combine(Path.GetTempPath(), $"uscheduler-seed-{Guid.NewGuid():N}"); + var source = Path.Combine(root, "seed"); + var dest = Path.Combine(root, "scripts"); + Directory.CreateDirectory(Path.Combine(source, "File-Sync")); + Directory.CreateDirectory(dest); + File.WriteAllText(Path.Combine(source, "File-Sync", "file-sync.ps1"), "seed"); + File.WriteAllText(Path.Combine(source, "SchedulerTemplate.psm1"), "module"); + + try { + var result = HostDataDirectories.CopySeedScriptsIfMissing(source, dest); + + Assert.Equal(2, result.CopiedFiles); + Assert.Equal(0, result.SkippedItems); + Assert.Equal("seed", File.ReadAllText(Path.Combine(dest, "File-Sync", "file-sync.ps1"))); + Assert.Equal("module", File.ReadAllText(Path.Combine(dest, "SchedulerTemplate.psm1"))); + } + finally { + Directory.Delete(root, true); + } + } } diff --git a/src/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI.csproj b/src/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI.csproj index 05b9dbe..43e22d3 100644 --- a/src/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI.csproj +++ b/src/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI.csproj @@ -4,7 +4,7 @@ WinExe net10.0 MaksIT.UScheduler.UI - 1.1.0 + 1.1.1 enable enable true diff --git a/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj b/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj index 39e1ba3..adc2e14 100644 --- a/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj +++ b/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj @@ -3,7 +3,7 @@ net10.0 $(MSBuildProjectName.Replace(" ", "_")) - 1.1.0 + 1.1.1 enable enable dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66 @@ -41,5 +41,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest + diff --git a/utils/engines/release/custom/BundleCustomization.psm1 b/utils/engines/release/custom/BundleCustomization.psm1 index dd587f5..6be446b 100644 --- a/utils/engines/release/custom/BundleCustomization.psm1 +++ b/utils/engines/release/custom/BundleCustomization.psm1 @@ -9,7 +9,7 @@ Does not publish. Copies src/Scripts into each MaksIT.UScheduler RID output, rewrites worker seed appsettings for the bundled layout, and writes Windows/Linux launchers. The portable zip is the win-x64 bundle folder - (unique names). A flat installer payload (worker + UI) is staged for WiX. + A flat installer payload (worker + UI + Scripts) is staged for WiX. #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -254,7 +254,7 @@ exec "$DIR/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI" "$@" Join-Path $sharedSettings.artifactsDirectory "installer-payload" } - Write-Log -Level "STEP" -Message "Preparing per-machine installer payload (worker + UI)..." + Write-Log -Level "STEP" -Message "Preparing per-machine installer payload (worker + UI + Scripts)..." if (Test-Path $installerPayload) { Remove-Item -Path $installerPayload -Recurse -Force } @@ -262,7 +262,13 @@ exec "$DIR/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI" "$@" New-Item -ItemType Directory -Path $installerPayload | Out-Null Copy-Item -Path (Join-Path ([string]$uiWin.directory) '*') -Destination $installerPayload -Recurse -Force Copy-Item -Path (Join-Path ([string]$workerWin.directory) '*') -Destination $installerPayload -Recurse -Force + $payloadScripts = Join-Path $installerPayload "Scripts" + if (Test-Path -LiteralPath $payloadScripts) { + Remove-Item -LiteralPath $payloadScripts -Recurse -Force + } + Copy-Item -Path $scriptsSourcePath -Destination $payloadScripts -Recurse Write-Log -Level "OK" -Message " Installer payload: $installerPayload" + Write-Log -Level "OK" -Message " Installer Scripts: $payloadScripts" Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($bundleDirectory) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'publishOutputs' -Value @() -Overwrite Replace diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json index 7cfaa19..aac89d1 100644 --- a/utils/engines/release/scriptSettings.json +++ b/utils/engines/release/scriptSettings.json @@ -80,7 +80,9 @@ "installScope": "perMachine", "installFolderName": "UScheduler", "iconPath": "..\\..\\..\\src\\MaksIT.UScheduler.UI\\Assets\\icon.ico", - "exeNamePattern": "maksit-uscheduler-{version}.exe" + "exeNamePattern": "maksit-uscheduler-{version}.exe", + "postInstallExecutableName": "MaksIT.UScheduler.exe", + "postInstallArguments": "--prepare-data" }, { "name": "FlatpakPack", diff --git a/utils/plugins/Desktop/DesktopPackSupport.psm1 b/utils/plugins/Desktop/DesktopPackSupport.psm1 index 490cebb..9375ebf 100644 --- a/utils/plugins/Desktop/DesktopPackSupport.psm1 +++ b/utils/plugins/Desktop/DesktopPackSupport.psm1 @@ -283,7 +283,13 @@ function New-WixPackageXml { [string]$Architecture = 'x64', [Parameter(Mandatory = $false)] - [string]$IconPath + [string]$IconPath, + + [Parameter(Mandatory = $false)] + [string]$PostInstallFileName, + + [Parameter(Mandatory = $false)] + [string]$PostInstallArguments ) $ns = 'http://wixtoolset.org/schemas/v4/wxs' @@ -371,6 +377,7 @@ function New-WixPackageXml { '' = $installFolder } $componentIds = [System.Collections.Generic.List[string]]::new() + $postInstallFileId = $null $publishRoot = [System.IO.Path]::GetFullPath($PublishDirectory) $files = @(Get-ChildItem -LiteralPath $publishRoot -Recurse -File) @@ -415,6 +422,12 @@ function New-WixPackageXml { $null = $fileNode.SetAttribute('KeyPath', 'yes') $null = $component.AppendChild($fileNode) $null = $parent.AppendChild($component) + + if (-not [string]::IsNullOrWhiteSpace($PostInstallFileName) ` + -and [string]::IsNullOrWhiteSpace($relativeDir) ` + -and [string]::Equals($file.Name, $PostInstallFileName, [System.StringComparison]::OrdinalIgnoreCase)) { + $postInstallFileId = $fileNode.GetAttribute('Id') + } } $exeName = [System.IO.Path]::GetFileName($ExecutablePath) @@ -489,6 +502,34 @@ function New-WixPackageXml { $null = $package.AppendChild($feature) + if (-not [string]::IsNullOrWhiteSpace($PostInstallFileName)) { + if ([string]::IsNullOrWhiteSpace($postInstallFileId)) { + throw "WindowsInstaller postInstallExecutableName '$PostInstallFileName' was not harvested from $publishRoot." + } + + if ([string]::IsNullOrWhiteSpace($PostInstallArguments)) { + throw "WindowsInstaller postInstallArguments is required when postInstallExecutableName is set." + } + + $ca = $xml.CreateElement('CustomAction', $ns) + $null = $ca.SetAttribute('Id', 'PostInstallPrepareData') + $null = $ca.SetAttribute('FileRef', $postInstallFileId) + $arguments = if ($null -eq $PostInstallArguments) { '' } else { $PostInstallArguments } + $null = $ca.SetAttribute('ExeCommand', $arguments) + $null = $ca.SetAttribute('Execute', 'deferred') + $null = $ca.SetAttribute('Impersonate', 'no') + $null = $ca.SetAttribute('Return', 'check') + $null = $package.AppendChild($ca) + + $seq = $xml.CreateElement('InstallExecuteSequence', $ns) + $custom = $xml.CreateElement('Custom', $ns) + $null = $custom.SetAttribute('Action', 'PostInstallPrepareData') + $null = $custom.SetAttribute('After', 'InstallFiles') + $null = $custom.SetAttribute('Condition', 'NOT REMOVE') + $null = $seq.AppendChild($custom) + $null = $package.AppendChild($seq) + } + return $xml } diff --git a/utils/plugins/Desktop/WindowsInstaller.psm1 b/utils/plugins/Desktop/WindowsInstaller.psm1 index ce27788..ec9e558 100644 --- a/utils/plugins/Desktop/WindowsInstaller.psm1 +++ b/utils/plugins/Desktop/WindowsInstaller.psm1 @@ -9,6 +9,8 @@ Harvests a win-* (or sole) DotNetPublish folder into a WiX MSI, then wraps it in a Burn bootstrapper .exe. The .exe is the GitHub asset; the MSI/WXS stay in a staging folder and are not added to the portable zip. + Optional postInstallExecutableName + postInstallArguments run a deferred + elevated custom action after InstallFiles (used to seed C:\MaksIT\Scripts). `wix build -arch` follows `runtimeIdentifier` (default win-x64 → x64) so per-machine installs go to `C:\Program Files`, not Program Files (x86). Requires the WiX CLI (`dotnet tool install -g wix`). WiX v7: accept the @@ -213,6 +215,9 @@ function Invoke-Plugin { $msiPath = Join-Path $stageDir ($safeName + '-' + $version + '.msi') $bundleWxsPath = Join-Path $stageDir ($safeName + '-' + $version + '-bundle.wxs') + $postInstallFileName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'postInstallExecutableName' -Default '') + $postInstallArguments = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'postInstallArguments' -Default '') + Write-Log -Level "STEP" -Message "Generating WiX source for '$appName' from $publishDirectory ($wixArch)" $xml = New-WixPackageXml ` -AppName $appName ` @@ -224,7 +229,9 @@ function Invoke-Plugin { -InstallScope $installScope ` -InstallFolderName $installFolderName ` -Architecture $wixArch ` - -IconPath $iconPath + -IconPath $iconPath ` + -PostInstallFileName $postInstallFileName ` + -PostInstallArguments $postInstallArguments $xml.Save($wxsPath) Write-Log -Level "OK" -Message " WiX source: $wxsPath"