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 string? _shortcutDescriptionOverride; 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 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(); RefreshPreview(); } public event PropertyChangedEventHandler? PropertyChanged; public string AppName { get => _appName; set => SetProperty(ref _appName, value); } public string ExpectedManifestFileName => ManifestFileName(AppName); public string ManifestSubtitle => "Create " + ExpectedManifestFileName + " for covenant-setup"; 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 ShortcutDescription { get => EffectiveShortcutDescription(); set { var normalized = string.IsNullOrWhiteSpace(value) ? null : value; var defaultDescription = DefaultShortcutDescription(AppName.Trim()); var overrideValue = string.Equals(normalized, defaultDescription, StringComparison.Ordinal) ? null : normalized; if (string.Equals(_shortcutDescriptionOverride, overrideValue, StringComparison.Ordinal)) { return; } _shortcutDescriptionOverride = overrideValue; OnPropertyChanged(); NotifyGeneratedManifestFieldsChanged(); RefreshPreview(); } } 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(CovenantSetupPath))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanPackage))); } } } public bool HasCovenantSetupTool => CovenantSetupTool is not null; public string CovenantSetupPath => CovenantSetupTool?.Path ?? string.Empty; 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 string ShortcutPath => GeneratedShortcut()?.Path ?? string.Empty; public string ShortcutTarget => GeneratedShortcut()?.Target ?? string.Empty; public string ShortcutWorkingDirectory => GeneratedShortcut()?.WorkingDirectory ?? string.Empty; public string PurgePathsPreview => JoinLines(BuildDocument().Purge.Paths); public string PurgeRegistryBranchesPreview => JoinLines(BuildDocument().Purge.RegistryBranches); 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 RefreshCovenantSetupTool() { SetCovenantSetupTool(_locateCovenantSetupTool()); } public void SetCovenantSetupTool(CovenantSetupTool? tool) { CovenantSetupTool = tool; CovenantSetupStatus = CovenantSetupTool is null ? "covenant-setup.exe was not found. Packaging is disabled." : "Packaging enabled: " + CovenantSetupTool.Path; } public void RejectCovenantSetupTool(string message) { CovenantSetupTool = null; CovenantSetupStatus = message; } public void AddDirectory(string path) { AddTrimmed(Directories, path, value => 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() { var template = BuildTemplateEntries(); var directories = CombineUnique(template.Directories, Directories); var files = CombineUnique(template.Files, Files); var registry = CombineUnique(template.Registry, Registry); var shortcuts = CombineUnique(BuildShortcutEntries(files), Shortcuts); var purgePaths = CombineUnique(BuildPurgePaths(files, registry), PurgePaths); return new ManifestDocument { AppName = AppName.Trim(), Directories = directories, Files = files, Registry = registry, Shortcuts = shortcuts, Scripts = Scripts.ToArray(), Purge = new PurgeSpec { Paths = purgePaths, RegistryBranches = CombineUnique(registry.Select(entry => entry.Key), PurgeRegistryBranches) } }; } public ValidationResult Validate() { var errors = new List(); var warnings = new List(); if (string.IsNullOrWhiteSpace(AppName)) { errors.Add("App name is required."); } else if (AppName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) { errors.Add("App name cannot contain characters that are invalid in Windows file names."); } var document = BuildDocument(); if (document.Directories.Count + document.Files.Count + document.Registry.Count + document.Shortcuts.Count + document.Scripts.Count == 0) { errors.Add("Add at least one install action."); } ValidateManifestSpacing(document, errors); ValidateNoWhitespace(errors, "Application target installation folder", ApplicationFolder); foreach (var file in document.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 document.Registry) { if (!IsSupportedRegistryRoot(registry.Key)) { errors.Add($"Registry key '{registry.Key}' must start with HKCU\\ or HKLM\\."); } } foreach (var branch in document.Purge.RegistryBranches) { if (!IsSupportedRegistryRoot(branch)) { errors.Add($"Purge registry branch '{branch}' must start with HKCU\\ or HKLM\\."); } } if (document.Registry.Any(entry => entry.Key.StartsWith(@"HKLM\", StringComparison.OrdinalIgnoreCase)) || document.Purge.RegistryBranches.Any(branch => branch.StartsWith(@"HKLM\", StringComparison.OrdinalIgnoreCase)) || document.Directories.Any(RequiresAdminPath) || document.Files.Any(file => RequiresAdminPath(file.Destination)) || document.Shortcuts.Any(shortcut => RequiresAdminPath(shortcut.Path)) || document.Purge.Paths.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; OnPropertyChanged(propertyName); if (propertyName == nameof(AppName)) { OnPropertyChanged(nameof(ExpectedManifestFileName)); OnPropertyChanged(nameof(ManifestSubtitle)); } if (propertyName == nameof(OutputDirectory)) { OnPropertyChanged(nameof(CanPackage)); } if (propertyName is nameof(AppName) or nameof(InstallRootToken) or nameof(ApplicationFolder) or nameof(PrimaryPayload)) { NotifyGeneratedManifestFieldsChanged(); } if (refreshPreview) { RefreshPreview(); } return true; } private void CollectionChanged(object? sender, NotifyCollectionChangedEventArgs args) { if (ReferenceEquals(sender, Files)) { OnPropertyChanged(nameof(ShortcutPath)); OnPropertyChanged(nameof(ShortcutTarget)); OnPropertyChanged(nameof(ShortcutWorkingDirectory)); OnPropertyChanged(nameof(PurgePathsPreview)); } if (ReferenceEquals(sender, Registry) || ReferenceEquals(sender, PurgePaths)) { OnPropertyChanged(nameof(PurgePathsPreview)); } if (ReferenceEquals(sender, Registry) || ReferenceEquals(sender, PurgeRegistryBranches)) { OnPropertyChanged(nameof(PurgeRegistryBranchesPreview)); } RefreshPreview(); } private void NotifyGeneratedManifestFieldsChanged() { OnPropertyChanged(nameof(ShortcutDescription)); OnPropertyChanged(nameof(ShortcutPath)); OnPropertyChanged(nameof(ShortcutTarget)); OnPropertyChanged(nameof(ShortcutWorkingDirectory)); OnPropertyChanged(nameof(PurgePathsPreview)); OnPropertyChanged(nameof(PurgeRegistryBranchesPreview)); } private void OnPropertyChanged([CallerMemberName] string? propertyName = null) { if (propertyName is not null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } 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 string? NullIfWhiteSpace(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private string EffectiveShortcutDescription() => _shortcutDescriptionOverride ?? DefaultShortcutDescription(AppName.Trim()); private string ManifestShortcutDescription() => NullIfWhiteSpace(EffectiveShortcutDescription()) ?? DefaultShortcutDescription(AppName.Trim()); private static string DefaultShortcutDescription(string appName) => string.IsNullOrWhiteSpace(appName) ? "Launch application" : "Launch " + appName; private ShortcutSpec? GeneratedShortcut() { var template = BuildTemplateEntries(); var files = CombineUnique(template.Files, Files); return BuildShortcutEntries(files).FirstOrDefault(); } private static string JoinLines(IEnumerable values) => string.Join(Environment.NewLine, values); private IReadOnlyList BuildShortcutEntries(IReadOnlyList files) { var target = files .Select(file => file.Destination.Trim()) .FirstOrDefault(destination => !string.IsNullOrWhiteSpace(destination)); if (string.IsNullOrWhiteSpace(target)) { return []; } return [ new ShortcutSpec( @"{Desktop}\" + ShortcutFileName(target) + ".lnk", target, null, ParentPath(target), ManifestShortcutDescription()) ]; } private static IReadOnlyList BuildPurgePaths( IReadOnlyList files, IReadOnlyList registry) { var candidates = new List(); foreach (var entry in registry) { var value = entry.Value.Trim(); if (IsManifestPath(value) && !IsUnsafePurgeRoot(value)) { candidates.Add(value); } } foreach (var file in files) { var parent = ParentPath(file.Destination); if (!string.IsNullOrWhiteSpace(parent) && !IsUnsafePurgeRoot(parent)) { candidates.Add(parent); } } return RemoveCoveredChildPaths(CombineUnique(candidates, Array.Empty())); } private string ShortcutFileName(string target) { var fileName = FileNameWithoutExtension(target); var sanitized = SanitizeIdentifier(fileName); if (!string.IsNullOrWhiteSpace(sanitized)) { return sanitized; } var fallbackName = string.IsNullOrWhiteSpace(ApplicationFolder) ? AppName : ApplicationFolder; return SanitizeIdentifier(fallbackName); } private static string FileNameWithoutExtension(string path) { var fileName = path.Trim().TrimEnd('\\'); var separatorIndex = fileName.LastIndexOf('\\'); if (separatorIndex >= 0 && separatorIndex < fileName.Length - 1) { fileName = fileName[(separatorIndex + 1)..]; } var extensionIndex = fileName.LastIndexOf('.'); return extensionIndex > 0 ? fileName[..extensionIndex] : fileName; } private static string? ParentPath(string path) { var trimmed = path.Trim().TrimEnd('\\'); var separatorIndex = trimmed.LastIndexOf('\\'); if (separatorIndex < 0) { return null; } if (separatorIndex == 2 && trimmed[1] == ':') { return trimmed[..(separatorIndex + 1)]; } return separatorIndex == 0 ? null : trimmed[..separatorIndex]; } private static bool IsManifestPath(string value) => ManifestTokens.KnownPathTokens.Any(token => value.StartsWith(token + @"\", StringComparison.OrdinalIgnoreCase)) || Path.IsPathRooted(value); private static bool IsUnsafePurgeRoot(string value) { var normalized = value.Trim().TrimEnd('\\'); if (ManifestTokens.KnownPathTokens.Any(token => string.Equals(normalized, token, StringComparison.OrdinalIgnoreCase))) { return true; } return normalized.Length == 2 && normalized[1] == ':' && IsAsciiLetter(normalized[0]); } private static bool IsAsciiLetter(char value) => value is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; private static IReadOnlyList RemoveCoveredChildPaths(IReadOnlyList paths) => paths .Where(path => !paths.Any(parent => !string.Equals(parent, path, StringComparison.OrdinalIgnoreCase) && IsParentPath(parent, path))) .ToArray(); private static bool IsParentPath(string parent, string child) { var normalizedParent = parent.TrimEnd('\\'); return child.Length > normalizedParent.Length && child.StartsWith(normalizedParent + "\\", StringComparison.OrdinalIgnoreCase); } 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 void ValidateManifestSpacing(ManifestDocument document, List errors) { foreach (var directory in document.Directories) { ValidateNoWhitespace(errors, "Directory path", directory); } foreach (var file in document.Files) { ValidateNoWhitespace(errors, "File source", file.Source); ValidateNoWhitespace(errors, "File destination", file.Destination); } foreach (var registry in document.Registry) { ValidateNoWhitespace(errors, "Registry key", registry.Key); ValidateNoWhitespace(errors, "Registry name", registry.Name); ValidateNoWhitespace(errors, "Registry value", registry.Value); } foreach (var shortcut in document.Shortcuts) { ValidateNoWhitespace(errors, "Shortcut path", shortcut.Path); ValidateNoWhitespace(errors, "Shortcut target", shortcut.Target); ValidateOptionalNoWhitespace(errors, "Shortcut arguments", shortcut.Arguments); ValidateOptionalNoWhitespace(errors, "Shortcut working directory", shortcut.WorkingDirectory); } foreach (var script in document.Scripts) { ValidateNoWhitespace(errors, "Script command", script.Command); for (var index = 0; index < script.Args.Count; index++) { ValidateNoWhitespace(errors, $"Script argument {index + 1}", script.Args[index]); } ValidateOptionalNoWhitespace(errors, "Script working directory", script.WorkingDirectory); } foreach (var branch in document.Purge.RegistryBranches) { ValidateNoWhitespace(errors, "Purge registry branch", branch); } foreach (var path in document.Purge.Paths) { ValidateNoWhitespace(errors, "Purge path", path); } } private static void ValidateOptionalNoWhitespace(List errors, string fieldName, string? value) { if (!string.IsNullOrWhiteSpace(value)) { ValidateNoWhitespace(errors, fieldName, value); } } private static void ValidateNoWhitespace(List errors, string fieldName, string value) { if (ContainsWhitespace(value)) { errors.Add($"{fieldName} cannot contain spaces or other whitespace."); } } private static string SanitizeIdentifier(string value) { var sanitized = IdentifierRegex().Replace(value.Trim(), "_").Trim('_'); return string.IsNullOrWhiteSpace(sanitized) ? "covenant_setup" : sanitized; } internal static string ManifestFileName(string appName) { var name = new string(appName.Trim().Where(ch => !char.IsWhiteSpace(ch)).ToArray()); return string.IsNullOrWhiteSpace(name) ? "install.toml" : name + "-install.toml"; } internal bool IsExpectedManifestPath(string path) => string.Equals( Path.GetFileName(path), ExpectedManifestFileName, StringComparison.OrdinalIgnoreCase) && !ContainsWhitespace(path); internal static bool ContainsWhitespace(string value) => value.Any(char.IsWhiteSpace); [GeneratedRegex(@"[^A-Za-z0-9_-]+")] private static partial Regex IdentifierRegex(); private TemplateManifestEntries BuildTemplateEntries() { var appName = AppName.Trim(); var folder = string.IsNullOrWhiteSpace(ApplicationFolder) ? SanitizeIdentifier(appName) : SanitizeIdentifier(ApplicationFolder); var root = InstallRootToken.TrimEnd('\\') + "\\" + folder; var registryBranch = @"HKCU\Software\" + folder; var files = new List(); if (!string.IsNullOrWhiteSpace(PrimaryPayload)) { var source = PrimaryPayload.Trim(); var fileName = Path.GetFileName(source); if (!string.IsNullOrWhiteSpace(fileName)) { var destination = root + @"\bin\" + fileName; files.Add(new FileSpec(source, destination)); } } return new TemplateManifestEntries( [root, root + @"\bin"], files, [new RegistrySpec(registryBranch, "InstallRoot", root)]); } private static IReadOnlyList CombineUnique(IEnumerable first, IEnumerable second) { var values = new List(); foreach (var item in first.Concat(second)) { if (!values.Contains(item)) { values.Add(item); } } return values; } private sealed record TemplateManifestEntries( IReadOnlyList Directories, IReadOnlyList Files, IReadOnlyList Registry); }