using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Runtime.CompilerServices; using System.Text.RegularExpressions; namespace Covenant.Setup.Authoring; internal sealed partial class MainViewModel : INotifyPropertyChanged { private string _appName = "Covenant-Setup Sample App"; private string _installRootToken = "{LocalAppData}"; private string _applicationFolder = "CovenantSetupSample"; private string _primaryPayload = @"payload\sample_app.cmd"; private string _outputDirectory = Path.Combine(Environment.CurrentDirectory, "dist"); private CovenantSetupTool? _covenantSetupTool; private string _covenantSetupStatus = "covenant-setup.exe was not found. Packaging is disabled."; private string _tomlPreview = string.Empty; private string _validationSummary = string.Empty; private bool _hasValidationErrors; private bool _suppressPreviewRefresh; private SuggestedManifestEntries _suggestedEntries = SuggestedManifestEntries.Empty; private readonly Func _locateCovenantSetupTool; public MainViewModel() : this(CovenantSetupToolLocator.Find) { } internal MainViewModel(Func locateCovenantSetupTool) { _locateCovenantSetupTool = locateCovenantSetupTool; Directories.CollectionChanged += CollectionChanged; Files.CollectionChanged += CollectionChanged; Registry.CollectionChanged += CollectionChanged; Shortcuts.CollectionChanged += CollectionChanged; Scripts.CollectionChanged += CollectionChanged; PurgePaths.CollectionChanged += CollectionChanged; PurgeRegistryBranches.CollectionChanged += CollectionChanged; RefreshCovenantSetupTool(); ApplyDefaults(resetCollections: true); } public event PropertyChangedEventHandler? PropertyChanged; public string AppName { get => _appName; set => SetProperty(ref _appName, value); } public string InstallRootToken { get => _installRootToken; set => SetProperty(ref _installRootToken, value); } public string ApplicationFolder { get => _applicationFolder; set => SetProperty(ref _applicationFolder, value); } public string PrimaryPayload { get => _primaryPayload; set => SetProperty(ref _primaryPayload, value); } public string OutputDirectory { get => _outputDirectory; set => SetProperty(ref _outputDirectory, value, refreshPreview: false); } public CovenantSetupTool? CovenantSetupTool { get => _covenantSetupTool; private set { if (SetProperty(ref _covenantSetupTool, value, refreshPreview: false)) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasCovenantSetupTool))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanPackage))); } } } public bool HasCovenantSetupTool => CovenantSetupTool is not null; public string CovenantSetupStatus { get => _covenantSetupStatus; private set => SetProperty(ref _covenantSetupStatus, value, refreshPreview: false); } public bool CanPackage => HasCovenantSetupTool && !HasValidationErrors && !string.IsNullOrWhiteSpace(OutputDirectory); public string TomlPreview { get => _tomlPreview; private set => SetProperty(ref _tomlPreview, value, refreshPreview: false); } public string ValidationSummary { get => _validationSummary; private set => SetProperty(ref _validationSummary, value, refreshPreview: false); } public bool HasValidationErrors { get => _hasValidationErrors; private set { if (SetProperty(ref _hasValidationErrors, value, refreshPreview: false)) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanPackage))); } } } public ObservableCollection Directories { get; } = []; public ObservableCollection Files { get; } = []; public ObservableCollection Registry { get; } = []; public ObservableCollection Shortcuts { get; } = []; public ObservableCollection Scripts { get; } = []; public ObservableCollection PurgePaths { get; } = []; public ObservableCollection PurgeRegistryBranches { get; } = []; public void ApplyDefaults(bool resetCollections) { _suppressPreviewRefresh = true; try { if (resetCollections) { Directories.Clear(); Files.Clear(); Registry.Clear(); Shortcuts.Clear(); Scripts.Clear(); PurgePaths.Clear(); PurgeRegistryBranches.Clear(); } else { RemoveSuggestedEntries(_suggestedEntries); } var suggestedEntries = BuildSuggestedEntries(); AddSuggestedEntries(suggestedEntries); _suggestedEntries = suggestedEntries; } finally { _suppressPreviewRefresh = false; } RefreshPreview(); } private SuggestedManifestEntries BuildSuggestedEntries() { var folder = string.IsNullOrWhiteSpace(ApplicationFolder) ? SanitizeIdentifier(AppName) : SanitizeIdentifier(ApplicationFolder); SetProperty(ref _applicationFolder, folder, nameof(ApplicationFolder), refreshPreview: false); var root = InstallRootToken.TrimEnd('\\') + "\\" + folder; var registryBranch = @"HKCU\Software\" + folder; var directories = new List { new(root), new(root + @"\bin") }; var files = new List(); var shortcuts = new List(); if (!string.IsNullOrWhiteSpace(PrimaryPayload)) { var fileName = Path.GetFileName(PrimaryPayload.Trim()); if (!string.IsNullOrWhiteSpace(fileName)) { var destination = root + @"\bin\" + fileName; files.Add(new FileSpec(PrimaryPayload.Trim(), destination)); shortcuts.Add(new ShortcutSpec( @"{Desktop}\" + AppName.Trim() + ".lnk", destination, null, root, "Launch " + AppName.Trim())); } } return new SuggestedManifestEntries( directories, files, [new RegistrySpec(registryBranch, "InstallRoot", root)], shortcuts, [root], [registryBranch]); } public void RefreshCovenantSetupTool() { CovenantSetupTool = _locateCovenantSetupTool(); CovenantSetupStatus = CovenantSetupTool is null ? "covenant-setup.exe was not found. Packaging is disabled." : "Packaging enabled: " + CovenantSetupTool.Path; } public void AddDirectory(string path) { AddTrimmed(Directories, path, value => new DirectorySpec(value)); } public void AddFile(string source, string destination) { if (!string.IsNullOrWhiteSpace(source) && !string.IsNullOrWhiteSpace(destination)) { AddUnique(Files, new FileSpec(source.Trim(), destination.Trim())); } } public void AddRegistry(string key, string name, string value) { if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(value)) { AddUnique(Registry, new RegistrySpec(key.Trim(), name.Trim(), value.Trim())); } } public void AddShortcut( string path, string target, string? arguments, string? workingDirectory, string? description) { if (!string.IsNullOrWhiteSpace(path) && !string.IsNullOrWhiteSpace(target)) { AddUnique(Shortcuts, new ShortcutSpec( path.Trim(), target.Trim(), NullIfWhiteSpace(arguments), NullIfWhiteSpace(workingDirectory), NullIfWhiteSpace(description))); } } public void AddScript(string command, IReadOnlyList args, string? workingDirectory) { if (!string.IsNullOrWhiteSpace(command)) { AddUnique(Scripts, new ScriptSpec( command.Trim(), args.Where(arg => !string.IsNullOrWhiteSpace(arg)).Select(arg => arg.Trim()).ToArray(), NullIfWhiteSpace(workingDirectory))); } } public void AddPurgePath(string path) { AddTrimmed(PurgePaths, path, value => value); } public void AddPurgeRegistryBranch(string branch) { AddTrimmed(PurgeRegistryBranches, branch, value => value); } public ManifestDocument BuildDocument() => new() { AppName = AppName.Trim(), Directories = Directories.ToArray(), Files = Files.ToArray(), Registry = Registry.ToArray(), Shortcuts = Shortcuts.ToArray(), Scripts = Scripts.ToArray(), Purge = new PurgeSpec { Paths = PurgePaths.ToArray(), RegistryBranches = PurgeRegistryBranches.ToArray() } }; public ValidationResult Validate() { var errors = new List(); var warnings = new List(); if (string.IsNullOrWhiteSpace(AppName)) { errors.Add("App name is required."); } if (Directories.Count + Files.Count + Registry.Count + Shortcuts.Count + Scripts.Count == 0) { errors.Add("Add at least one install action."); } foreach (var file in Files) { if (Path.IsPathRooted(file.Source)) { warnings.Add($"File source '{file.Source}' is rooted; package sources are usually relative to the manifest folder."); } } foreach (var registry in Registry) { if (!IsSupportedRegistryRoot(registry.Key)) { errors.Add($"Registry key '{registry.Key}' must start with HKCU\\ or HKLM\\."); } } foreach (var branch in PurgeRegistryBranches) { if (!IsSupportedRegistryRoot(branch)) { errors.Add($"Purge registry branch '{branch}' must start with HKCU\\ or HKLM\\."); } } if (Registry.Any(entry => entry.Key.StartsWith(@"HKLM\", StringComparison.OrdinalIgnoreCase)) || PurgeRegistryBranches.Any(branch => branch.StartsWith(@"HKLM\", StringComparison.OrdinalIgnoreCase)) || Directories.Any(directory => RequiresAdminPath(directory.Path)) || Files.Any(file => RequiresAdminPath(file.Destination)) || Shortcuts.Any(shortcut => RequiresAdminPath(shortcut.Path)) || PurgePaths.Any(RequiresAdminPath)) { warnings.Add("This manifest appears to require elevation because it targets Program Files or HKLM."); } return new ValidationResult(errors, warnings); } private void RefreshPreview() { TomlPreview = ManifestTomlWriter.Write(BuildDocument()); var validation = Validate(); HasValidationErrors = !validation.IsValid; ValidationSummary = validation.Errors.Count switch { > 0 => string.Join(Environment.NewLine, validation.Errors), _ when validation.Warnings.Count > 0 => string.Join(Environment.NewLine, validation.Warnings), _ => "Manifest is ready to save." }; } private bool SetProperty( ref T field, T value, [CallerMemberName] string? propertyName = null, bool refreshPreview = true) { if (EqualityComparer.Default.Equals(field, value)) { return false; } field = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); if (propertyName == nameof(OutputDirectory)) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanPackage))); } if (refreshPreview) { RefreshPreview(); } return true; } private void CollectionChanged(object? sender, NotifyCollectionChangedEventArgs args) { if (!_suppressPreviewRefresh) { RefreshPreview(); } } private void AddSuggestedEntries(SuggestedManifestEntries entries) { AddRangeUnique(Directories, entries.Directories); AddRangeUnique(Files, entries.Files); AddRangeUnique(Registry, entries.Registry); AddRangeUnique(Shortcuts, entries.Shortcuts); AddRangeUnique(PurgePaths, entries.PurgePaths); AddRangeUnique(PurgeRegistryBranches, entries.PurgeRegistryBranches); } private void RemoveSuggestedEntries(SuggestedManifestEntries entries) { RemoveRange(Directories, entries.Directories); RemoveRange(Files, entries.Files); RemoveRange(Registry, entries.Registry); RemoveRange(Shortcuts, entries.Shortcuts); RemoveRange(PurgePaths, entries.PurgePaths); RemoveRange(PurgeRegistryBranches, entries.PurgeRegistryBranches); } private static void AddTrimmed( ObservableCollection collection, string value, Func factory) { if (!string.IsNullOrWhiteSpace(value)) { AddUnique(collection, factory(value.Trim())); } } private static void AddUnique(ObservableCollection collection, T item) { if (!collection.Contains(item)) { collection.Add(item); } } private static void AddRangeUnique(ObservableCollection collection, IEnumerable items) { foreach (var item in items) { AddUnique(collection, item); } } private static void RemoveRange(ICollection collection, IEnumerable items) { foreach (var item in items) { collection.Remove(item); } } private static string? NullIfWhiteSpace(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static bool IsSupportedRegistryRoot(string value) => value.StartsWith(@"HKCU\", StringComparison.OrdinalIgnoreCase) || value.StartsWith(@"HKLM\", StringComparison.OrdinalIgnoreCase); private static bool RequiresAdminPath(string value) => value.StartsWith(@"{ProgramFilesX64}\", StringComparison.OrdinalIgnoreCase) || value.StartsWith(@"{ProgramFilesX86}\", StringComparison.OrdinalIgnoreCase) || value.StartsWith(@"C:\Program Files\", StringComparison.OrdinalIgnoreCase) || value.StartsWith(@"C:\Program Files (x86)\", StringComparison.OrdinalIgnoreCase); private static string SanitizeIdentifier(string value) { var sanitized = IdentifierRegex().Replace(value.Trim(), "_").Trim('_'); return string.IsNullOrWhiteSpace(sanitized) ? "covenant_setup" : sanitized; } [GeneratedRegex(@"[^A-Za-z0-9_-]+")] private static partial Regex IdentifierRegex(); private sealed record SuggestedManifestEntries( IReadOnlyList Directories, IReadOnlyList Files, IReadOnlyList Registry, IReadOnlyList Shortcuts, IReadOnlyList PurgePaths, IReadOnlyList PurgeRegistryBranches) { public static SuggestedManifestEntries Empty { get; } = new([], [], [], [], [], []); } }