From be377ae8899b19a049056fc852388409bdfa3208 Mon Sep 17 00:00:00 2001 From: Juster Zhu Date: Tue, 10 Feb 2026 01:01:20 +0800 Subject: [PATCH] add extension func --- src/Common/ZipUtility.cs | 244 ++++++++++++ src/Models/CustomPropertyModel.cs | 27 ++ src/Models/ExtensionConfigModel.cs | 290 ++++++++++++++ .../ExtensionDependencySelectionModel.cs | 46 +++ src/Models/TargetPlatform.cs | 32 ++ src/ViewModels/ExtensionViewModel.cs | 336 ++++++++++++++++ src/Views/ExtensionView.axaml | 371 ++++++++++++++++++ src/Views/ExtensionView.axaml.cs | 13 + src/Views/MainWindow.axaml | 7 +- 9 files changed, 1364 insertions(+), 2 deletions(-) create mode 100644 src/Common/ZipUtility.cs create mode 100644 src/Models/CustomPropertyModel.cs create mode 100644 src/Models/ExtensionConfigModel.cs create mode 100644 src/Models/ExtensionDependencySelectionModel.cs create mode 100644 src/Models/TargetPlatform.cs create mode 100644 src/ViewModels/ExtensionViewModel.cs create mode 100644 src/Views/ExtensionView.axaml create mode 100644 src/Views/ExtensionView.axaml.cs diff --git a/src/Common/ZipUtility.cs b/src/Common/ZipUtility.cs new file mode 100644 index 0000000..1f08392 --- /dev/null +++ b/src/Common/ZipUtility.cs @@ -0,0 +1,244 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading.Tasks; + +namespace GeneralUpdate.Tool.Avalonia.Common; + +/// +/// Utility class for zip file compression operations +/// +public static class ZipUtility +{ + /// + /// Characters that are invalid in file names across all platforms + /// Includes platform-specific invalid chars and common problematic characters + /// + private static readonly char[] InvalidFileNameChars = + Path.GetInvalidFileNameChars() + .Concat(new[] { '<', '>', ':', '"', '/', '\\', '|', '?', '*' }) + .Distinct() + .ToArray(); + + /// + /// Sanitizes a string to be used as a filename by replacing invalid characters + /// + /// The filename to sanitize + /// The replacement character for invalid characters (default: '_') + /// Sanitized filename + public static string SanitizeFileName(string fileName, char replacement = '_') + { + if (string.IsNullOrWhiteSpace(fileName)) + return fileName; + + var sanitized = fileName; + foreach (var invalidChar in InvalidFileNameChars) + { + sanitized = sanitized.Replace(invalidChar, replacement); + } + + return sanitized; + } + /// + /// Compresses a directory into a zip file + /// + /// Source directory to compress + /// Destination zip file path + /// Compression level (default: Optimal) + /// Whether to include the base directory in the archive + /// Thrown when sourceDirectory or destinationZipFile is null or empty + /// Thrown when sourceDirectory does not exist + public static void CompressDirectory( + string sourceDirectory, + string destinationZipFile, + CompressionLevel compressionLevel = CompressionLevel.Optimal, + bool includeBaseDirectory = false) + { + if (string.IsNullOrWhiteSpace(sourceDirectory)) + throw new ArgumentNullException(nameof(sourceDirectory)); + + if (string.IsNullOrWhiteSpace(destinationZipFile)) + throw new ArgumentNullException(nameof(destinationZipFile)); + + if (!Directory.Exists(sourceDirectory)) + throw new DirectoryNotFoundException($"Source directory not found: {sourceDirectory}"); + + // Ensure the destination directory exists + var destinationDir = Path.GetDirectoryName(destinationZipFile); + if (!string.IsNullOrEmpty(destinationDir) && !Directory.Exists(destinationDir)) + { + Directory.CreateDirectory(destinationDir); + } + + // Delete existing zip file if it exists + if (File.Exists(destinationZipFile)) + { + File.Delete(destinationZipFile); + } + + // Create the zip archive + ZipFile.CreateFromDirectory(sourceDirectory, destinationZipFile, compressionLevel, includeBaseDirectory); + } + + /// + /// Compresses a directory into a zip file asynchronously + /// + /// Source directory to compress + /// Destination zip file path + /// Compression level (default: Optimal) + /// Whether to include the base directory in the archive + /// Task representing the asynchronous operation + /// Thrown when sourceDirectory or destinationZipFile is null or empty + /// Thrown when sourceDirectory does not exist + public static Task CompressDirectoryAsync( + string sourceDirectory, + string destinationZipFile, + CompressionLevel compressionLevel = CompressionLevel.Optimal, + bool includeBaseDirectory = false) + { + return Task.Run(() => CompressDirectory(sourceDirectory, destinationZipFile, compressionLevel, includeBaseDirectory)); + } + + /// + /// Extracts a zip file to a directory + /// + /// Source zip file to extract + /// Destination directory for extraction + /// Whether to overwrite existing files + /// Thrown when sourceZipFile or destinationDirectory is null or empty + /// Thrown when sourceZipFile does not exist + /// Thrown when a zip entry attempts to extract outside the destination directory (zip slip attack) + public static void ExtractZipFile( + string sourceZipFile, + string destinationDirectory, + bool overwriteFiles = true) + { + if (string.IsNullOrWhiteSpace(sourceZipFile)) + throw new ArgumentNullException(nameof(sourceZipFile)); + + if (string.IsNullOrWhiteSpace(destinationDirectory)) + throw new ArgumentNullException(nameof(destinationDirectory)); + + if (!File.Exists(sourceZipFile)) + throw new FileNotFoundException($"Source zip file not found: {sourceZipFile}"); + + // Ensure the destination directory exists + if (!Directory.Exists(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + + // Get the normalized full path of the destination directory + var normalizedDestination = Path.GetFullPath(destinationDirectory); + + // Extract the zip archive with zip slip protection + using (var archive = System.IO.Compression.ZipFile.OpenRead(sourceZipFile)) + { + foreach (var entry in archive.Entries) + { + // Get the full path where the entry will be extracted + var entryPath = Path.Combine(destinationDirectory, entry.FullName); + var normalizedEntryPath = Path.GetFullPath(entryPath); + + // Validate that the entry path is within the destination directory (zip slip protection) + if (!normalizedEntryPath.StartsWith(normalizedDestination, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Zip entry '{entry.FullName}' attempts to extract outside the destination directory. " + + "This may indicate a zip slip attack."); + } + + // Create directory for the entry if needed + if (string.IsNullOrEmpty(entry.Name)) + { + // This is a directory entry + Directory.CreateDirectory(normalizedEntryPath); + } + else + { + // This is a file entry + var entryDirectory = Path.GetDirectoryName(normalizedEntryPath); + if (!string.IsNullOrEmpty(entryDirectory) && !Directory.Exists(entryDirectory)) + { + Directory.CreateDirectory(entryDirectory); + } + + // Extract the file + entry.ExtractToFile(normalizedEntryPath, overwriteFiles); + } + } + } + } + + /// + /// Extracts a zip file to a directory asynchronously + /// + /// Source zip file to extract + /// Destination directory for extraction + /// Whether to overwrite existing files + /// Task representing the asynchronous operation + /// Thrown when sourceZipFile or destinationDirectory is null or empty + /// Thrown when sourceZipFile does not exist + /// Thrown when a zip entry attempts to extract outside the destination directory (zip slip attack) + public static Task ExtractZipFileAsync( + string sourceZipFile, + string destinationDirectory, + bool overwriteFiles = true) + { + return Task.Run(() => ExtractZipFile(sourceZipFile, destinationDirectory, overwriteFiles)); + } + + /// + /// Adds a file to an existing zip archive + /// + /// Path to the zip file + /// Entry name in the archive + /// Content to add + /// Thrown when parameters are null or empty + /// Thrown when parameters are empty or whitespace + /// Thrown when zipFilePath does not exist + public static void AddFileToZip(string zipFilePath, string entryName, string content) + { + if (string.IsNullOrWhiteSpace(zipFilePath)) + throw new ArgumentException("Zip file path cannot be null or empty", nameof(zipFilePath)); + + if (string.IsNullOrWhiteSpace(entryName)) + throw new ArgumentException("Entry name cannot be null or empty", nameof(entryName)); + + if (content == null) + throw new ArgumentNullException(nameof(content)); + + if (!File.Exists(zipFilePath)) + throw new FileNotFoundException($"Zip file not found: {zipFilePath}"); + + using (var archive = System.IO.Compression.ZipFile.Open(zipFilePath, ZipArchiveMode.Update)) + { + // Remove existing entry if it exists + var existingEntry = archive.GetEntry(entryName); + existingEntry?.Delete(); + + // Create new entry + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using (var writer = new StreamWriter(entry.Open())) + { + writer.Write(content); + } + } + } + + /// + /// Adds a file to an existing zip archive asynchronously + /// + /// Path to the zip file + /// Entry name in the archive + /// Content to add + /// Task representing the asynchronous operation + /// Thrown when parameters are null + /// Thrown when parameters are empty or whitespace + /// Thrown when zipFilePath does not exist + public static Task AddFileToZipAsync(string zipFilePath, string entryName, string content) + { + return Task.Run(() => AddFileToZip(zipFilePath, entryName, content)); + } +} \ No newline at end of file diff --git a/src/Models/CustomPropertyModel.cs b/src/Models/CustomPropertyModel.cs new file mode 100644 index 0000000..a87526a --- /dev/null +++ b/src/Models/CustomPropertyModel.cs @@ -0,0 +1,27 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace GeneralUpdate.Tool.Avalonia.Models; + +public class CustomPropertyModel : ObservableObject +{ + private string _key; + private string _value; + + /// + /// Property key + /// + public string Key + { + get => _key; + set => SetProperty(ref _key, value); + } + + /// + /// Property value + /// + public string Value + { + get => _value; + set => SetProperty(ref _value, value); + } +} \ No newline at end of file diff --git a/src/Models/ExtensionConfigModel.cs b/src/Models/ExtensionConfigModel.cs new file mode 100644 index 0000000..4b049cc --- /dev/null +++ b/src/Models/ExtensionConfigModel.cs @@ -0,0 +1,290 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace GeneralUpdate.Tool.Avalonia.Models; + +public class ExtensionConfigModel : ObservableObject +{ + private string _name; + private string _version; + private string _description; + private string _path; + private string _extensionDirectory; + private bool _isUploadToServer; + private PlatformModel _platform; + private string _dependencies; + private string _displayName; + private string _publisher; + private string _license; + private List _categories; + private string _minHostVersion; + private string _maxHostVersion; + private DateTime? _releaseDate; + private bool _isPreRelease; + private string _format; + private string _hash; + private Dictionary _customProperties; + private bool _showCustomProperties; + + /// + /// Extension name + /// + public string Name + { + get => _name; + set + { + SetProperty(ref _name, value); + } + } + + /// + /// Extension version + /// + public string Version + { + get => _version; + set + { + SetProperty(ref _version, value); + } + } + + /// + /// Extension description + /// + public string Description + { + get => _description; + set + { + SetProperty(ref _description, value); + } + } + + /// + /// Extension export path + /// + public string Path + { + get => _path; + set + { + SetProperty(ref _path, value); + } + } + + /// + /// Extension directory + /// + public string ExtensionDirectory + { + get => _extensionDirectory; + set + { + SetProperty(ref _extensionDirectory, value); + } + } + + /// + /// Whether to upload directly to server + /// + public bool IsUploadToServer + { + get => _isUploadToServer; + set + { + SetProperty(ref _isUploadToServer, value); + } + } + + /// + /// Platform + /// + public PlatformModel Platform + { + get => _platform ??= new PlatformModel(); + set => SetProperty(ref _platform, value); + } + + /// + /// Dependencies (comma-separated) + /// + public string Dependencies + { + get => _dependencies; + set + { + SetProperty(ref _dependencies, value); + } + } + + /// + /// Display name for UI + /// + public string DisplayName + { + get => _displayName; + set + { + SetProperty(ref _displayName, value); + } + } + + /// + /// Publisher name + /// + public string Publisher + { + get => _publisher; + set + { + SetProperty(ref _publisher, value); + } + } + + /// + /// License identifier (e.g., MIT, Apache-2.0) + /// + public string License + { + get => _license; + set + { + SetProperty(ref _license, value); + } + } + + /// + /// Categories list (comma-separated) + /// + public List Categories + { + get => _categories ??= new List(); + set + { + SetProperty(ref _categories, value); + } + } + + /// + /// Categories as a comma-separated string for UI binding + /// + public string CategoriesText + { + get => Categories != null && Categories.Count > 0 ? string.Join(", ", Categories) : string.Empty; + set + { + if (!string.IsNullOrWhiteSpace(value)) + { + Categories = value.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(c => c.Trim()) + .Where(c => !string.IsNullOrWhiteSpace(c)) + .ToList(); + } + else + { + Categories = new List(); + } + OnPropertyChanged(new PropertyChangedEventArgs(nameof(CategoriesText))); + } + } + + /// + /// Minimum host version required + /// + public string MinHostVersion + { + get => _minHostVersion; + set + { + SetProperty(ref _minHostVersion, value); + } + } + + /// + /// Maximum host version supported + /// + public string MaxHostVersion + { + get => _maxHostVersion; + set + { + SetProperty(ref _maxHostVersion, value); + } + } + + /// + /// Release date + /// + public DateTime? ReleaseDate + { + get => _releaseDate; + set + { + SetProperty(ref _releaseDate, value); + } + } + + /// + /// Is this a pre-release version + /// + public bool IsPreRelease + { + get => _isPreRelease; + set + { + SetProperty(ref _isPreRelease, value); + } + } + + /// + /// File format (.dll, .zip, .so, .dylib, .exe) + /// + public string Format + { + get => _format; + set + { + SetProperty(ref _format, value); + } + } + + /// + /// File hash for integrity verification + /// + public string Hash + { + get => _hash; + set + { + SetProperty(ref _hash, value); + } + } + + /// + /// Custom properties (key-value pairs) + /// + public Dictionary CustomProperties + { + get => _customProperties ??= new Dictionary(); + set => SetProperty(ref _customProperties, value); + } + + /// + /// Controls visibility of CustomProperties input area + /// + public bool ShowCustomProperties + { + get => _showCustomProperties; + set => SetProperty(ref _showCustomProperties, value); + } + + /// + /// File size in bytes + /// + public long? FileSize { get; set; } +} \ No newline at end of file diff --git a/src/Models/ExtensionDependencySelectionModel.cs b/src/Models/ExtensionDependencySelectionModel.cs new file mode 100644 index 0000000..8e9e4d1 --- /dev/null +++ b/src/Models/ExtensionDependencySelectionModel.cs @@ -0,0 +1,46 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace GeneralUpdate.Tool.Avalonia.Models; + +/// +/// Model for extension dependency selection +/// +public class ExtensionDependencySelectionModel : ObservableObject +{ + private bool _isSelected; + + /// + /// Extension ID (GUID) + /// + public string Id { get; set; } = string.Empty; + + /// + /// Extension Name + /// + public string Name { get; set; } = string.Empty; + + /// + /// Extension Version + /// + public string Version { get; set; } = string.Empty; + + /// + /// Extension Description + /// + public string Description { get; set; } = string.Empty; + + /// + /// Release Date + /// + public DateTime? ReleaseDate { get; set; } + + /// + /// Whether this extension is selected as a dependency + /// + public bool IsSelected + { + get => _isSelected; + set => SetProperty(ref _isSelected, value); + } +} \ No newline at end of file diff --git a/src/Models/TargetPlatform.cs b/src/Models/TargetPlatform.cs new file mode 100644 index 0000000..e468468 --- /dev/null +++ b/src/Models/TargetPlatform.cs @@ -0,0 +1,32 @@ +using System; + +namespace GeneralUpdate.Tool.Avalonia.Models; + +[Flags] +public enum TargetPlatform +{ + /// + /// No platform specified. + /// + None = 0, + + /// + /// Windows operating system. + /// + Windows = 1, + + /// + /// Linux operating system. + /// + Linux = 2, + + /// + /// macOS operating system. + /// + MacOS = 4, + + /// + /// All supported platforms (Windows, Linux, and macOS). + /// + All = Windows | Linux | MacOS +} diff --git a/src/ViewModels/ExtensionViewModel.cs b/src/ViewModels/ExtensionViewModel.cs new file mode 100644 index 0000000..faea085 --- /dev/null +++ b/src/ViewModels/ExtensionViewModel.cs @@ -0,0 +1,336 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GeneralUpdate.Tool.Avalonia.Common; +using GeneralUpdate.Tool.Avalonia.Models; +using Newtonsoft.Json; + +namespace GeneralUpdate.Tool.Avalonia.ViewModels; + +public class ExtensionViewModel : ObservableObject +{ + #region Private Members + + + private ExtensionConfigModel? _configModel; + private AsyncRelayCommand? _generateCommand; + private AsyncRelayCommand? _selectFolderCommand; + private RelayCommand? _loadedCommand; + private RelayCommand? _clearCommand; + private AsyncRelayCommand? _selectDependenciesCommand; + private ExtensionDependencySelectionModel? _selectedDependency; + private RelayCommand? _removeCustomPropertyCommand; + private RelayCommand? _addCustomPropertyCommand; + private string? _newCustomPropertyKey; + private string? _newCustomPropertyValue; + + #endregion + + #region Public Properties + + public RelayCommand LoadedCommand + { + get { return _loadedCommand ??= new RelayCommand(LoadedAction); } + } + + public AsyncRelayCommand SelectFolderCommand + { + get => _selectFolderCommand ??= new AsyncRelayCommand(SelectFolderAction); + } + + public AsyncRelayCommand GenerateCommand + { + get => _generateCommand ??= new AsyncRelayCommand(GeneratePackageAction); + } + + public RelayCommand ClearCommand + { + get => _clearCommand ??= new RelayCommand(ClearAction); + } + + public ObservableCollection Platforms { get; set; } = + [ + new PlatformModel { DisplayName = "Windows", Value = 1 }, + new PlatformModel { DisplayName = "Linux", Value = 2 }, + new PlatformModel { DisplayName = "MacOS", Value = 3 } + ]; + + public ExtensionConfigModel ConfigModel + { + get => _configModel ??= new ExtensionConfigModel(); + set + { + _configModel = value; + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ConfigModel))); + } + } + + public ExtensionDependencySelectionModel? SelectedDependency + { + get => _selectedDependency; + set => SetProperty(ref _selectedDependency, value); + } + + public RelayCommand RemoveCustomPropertyCommand + { + get => _removeCustomPropertyCommand ??= new RelayCommand(RemoveCustomPropertyAction); + } + + public RelayCommand AddCustomPropertyCommand + { + get => _addCustomPropertyCommand ??= new RelayCommand(AddCustomPropertyAction, CanAddCustomProperty); + } + + public string? NewCustomPropertyKey + { + get => _newCustomPropertyKey; + set + { + SetProperty(ref _newCustomPropertyKey, value); + } + } + + public string? NewCustomPropertyValue + { + get => _newCustomPropertyValue; + set + { + SetProperty(ref _newCustomPropertyValue, value); + } + } + + public ObservableCollection CustomPropertiesCollection { get; set; } = new(); + + #endregion + + #region Private Methods + + /// + /// Maps legacy platform model value to TargetPlatform enum + /// + /// Platform model value (0=All, 1=Windows, 2=Linux, 3=MacOS) + /// Corresponding TargetPlatform enum value + private static TargetPlatform MapPlatformValue(int platformValue) + { + return platformValue switch + { + 1 => TargetPlatform.Windows, + 2 => TargetPlatform.Linux, + 3 => TargetPlatform.MacOS, + _ => TargetPlatform.All + }; + } + + private void LoadedAction() + { + ResetAction(); + } + + private void ResetAction() + { + ConfigModel.Name = string.Empty; + ConfigModel.Version = "1.0.0.0"; + ConfigModel.Description = string.Empty; + ConfigModel.ExtensionDirectory = string.Empty; + ConfigModel.Path = string.Empty; + ConfigModel.Dependencies = string.Empty; + ConfigModel.IsUploadToServer = false; + ConfigModel.Platform = Platforms.First(); + ConfigModel.DisplayName = string.Empty; + ConfigModel.Publisher = string.Empty; + ConfigModel.License = string.Empty; + ConfigModel.CategoriesText = string.Empty; + ConfigModel.MinHostVersion = string.Empty; + ConfigModel.MaxHostVersion = string.Empty; + ConfigModel.ReleaseDate = DateTime.Now; + ConfigModel.IsPreRelease = false; + ConfigModel.Format = ".zip"; + ConfigModel.Hash = string.Empty; + SelectedDependency = null; + ConfigModel.CustomProperties.Clear(); + ConfigModel.ShowCustomProperties = false; + CustomPropertiesCollection.Clear(); + NewCustomPropertyKey = string.Empty; + NewCustomPropertyValue = string.Empty; + } + + private async Task SelectFolderAction(string value) + { + try + { + var folders = await Storage.Instance.SelectFolderDialog(); + if (!folders.Any()) return; + + var folder = folders.First(); + switch (value) + { + case "ExtensionDirectory": + ConfigModel.ExtensionDirectory = folder!.Path.LocalPath; + break; + case "ExportPath": + ConfigModel.Path = folder!.Path.LocalPath; + break; + } + } + catch (Exception e) + { + } + } + + /// + /// Generate update package (compress extension directory and optionally upload) + /// + private async Task GeneratePackageAction() + { + try + { + // Validate input + if (string.IsNullOrWhiteSpace(ConfigModel.Name)) + { + //eventAggregator.PublishWarning("Extension name is required"); + return; + } + + if (string.IsNullOrWhiteSpace(ConfigModel.Version)) + { + //eventAggregator.PublishWarning("Extension version is required"); + return; + } + + if (string.IsNullOrWhiteSpace(ConfigModel.ExtensionDirectory) || + !Directory.Exists(ConfigModel.ExtensionDirectory)) + { + //eventAggregator.PublishWarning("Extension directory is invalid"); + return; + } + + if (string.IsNullOrWhiteSpace(ConfigModel.Path)) + { + //eventAggregator.PublishWarning("Export path is required"); + return; + } + + // ConfigModel.Path is the export directory (not the final zip file path) + var exportDirectory = ConfigModel.Path; + + // Ensure export directory exists + if (!Directory.Exists(exportDirectory)) + { + Directory.CreateDirectory(exportDirectory); + } + + // Sanitize extension name and version to create a valid filename + var sanitizedName = ZipUtility.SanitizeFileName(ConfigModel.Name); + var sanitizedVersion = ZipUtility.SanitizeFileName(ConfigModel.Version); + + // Create zip file name: ExtensionName_Version.zip + var zipFileName = $"{sanitizedName}_{sanitizedVersion}.zip"; + var zipFilePath = Path.Combine(exportDirectory, zipFileName); + + //eventAggregator.PublishSuccess("Starting extension compression..."); + + // Compress the extension directory into a zip file + await ZipUtility.CompressDirectoryAsync( + ConfigModel.ExtensionDirectory, + zipFilePath, + System.IO.Compression.CompressionLevel.Optimal, + includeBaseDirectory: false); + + // Update the Path field to point to the compressed zip file for upload + ConfigModel.Path = zipFilePath; + + // Create manifest.json with all ExtensionDTO fields + var platformValue = ConfigModel.Platform?.Value ?? 0; + var targetPlatform = MapPlatformValue(platformValue); + ConfigModel.Platform = new PlatformModel{ DisplayName = targetPlatform.ToString(), Value = platformValue }; + // Get file info for the zip + var fileInfo = new FileInfo(zipFilePath); + ConfigModel.FileSize = fileInfo.Length; + // Serialize manifest to JSON + var manifestJson = JsonConvert.SerializeObject(ConfigModel); + if (!string.IsNullOrEmpty(manifestJson)) + { + // Add manifest.json to the zip file + await ZipUtility.AddFileToZipAsync(zipFilePath, "manifest.json", manifestJson); + } + } + catch (Exception ex) + { + } + } + + private void ClearAction() => ResetAction(); + + private bool CanAddCustomProperty() + { + return !string.IsNullOrWhiteSpace(NewCustomPropertyKey) && + !string.IsNullOrWhiteSpace(NewCustomPropertyValue); + } + + private void AddCustomPropertyAction() + { + try + { + if (string.IsNullOrWhiteSpace(NewCustomPropertyKey) || + string.IsNullOrWhiteSpace(NewCustomPropertyValue)) + { + return; + } + + // Check if key already exists + if (ConfigModel.CustomProperties.ContainsKey(NewCustomPropertyKey)) + { + return; + } + + // Add to dictionary + ConfigModel.CustomProperties[NewCustomPropertyKey] = NewCustomPropertyValue; + + // Add to observable collection for UI binding + CustomPropertiesCollection.Add(new CustomPropertyModel + { + Key = NewCustomPropertyKey, + Value = NewCustomPropertyValue + }); + + // Clear input fields + NewCustomPropertyKey = string.Empty; + NewCustomPropertyValue = string.Empty; + + } + catch (Exception ex) + { + } + } + + private void RemoveCustomPropertyAction(CustomPropertyModel? property) + { + try + { + if (property == null) + { + return; + } + + // Remove from dictionary - use TryGetValue for safety + if (ConfigModel.CustomProperties.ContainsKey(property.Key)) + { + ConfigModel.CustomProperties.Remove(property.Key); + } + + // Remove from observable collection + CustomPropertiesCollection.Remove(property); + + } + catch (Exception ex) + { + } + } + + #endregion +} \ No newline at end of file diff --git a/src/Views/ExtensionView.axaml b/src/Views/ExtensionView.axaml new file mode 100644 index 0000000..f14d53b --- /dev/null +++ b/src/Views/ExtensionView.axaml @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +