using System.IO; using System.Security.Cryptography; using System.Security; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Windows; using XFEToolBox.Core.Model; using XFEToolBox.Core.Tools; namespace XFEToolBox.Client.Utilities; internal static class ToolProjectWorkspaceService { private const string DefaultToolIconRelativePath = "Assets/icon.png"; private const string DefaultToolIconResourcePath = "Resources/Image/default_tool_icon.png"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; public static string DefaultProjectsRoot => Path.Combine(AppPath.AppLocalData, "EditorWorkspaces"); private static string HistoryPath => Path.Combine(DefaultProjectsRoot, "project-history.json"); public static async Task CreateProjectAsync(string projectName, string projectPath) { projectName = projectName.Trim(); if (string.IsNullOrWhiteSpace(projectName)) throw new InvalidOperationException("请输入工具名称。"); if (projectName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) throw new InvalidOperationException("工具名称包含不能用于文件夹名称的字符。"); var root = Path.GetFullPath(projectPath.Trim()); if (Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any()) throw new InvalidOperationException("项目位置不是空文件夹,请选择其他位置。"); var viewsDirectory = Path.Combine(root, "Code", "Views"); var modelsDirectory = Path.Combine(root, "Code", "Models"); var viewModelsDirectory = Path.Combine(root, "Code", "ViewModels"); var assetsDirectory = Path.Combine(root, "Assets"); Directory.CreateDirectory(viewsDirectory); Directory.CreateDirectory(modelsDirectory); Directory.CreateDirectory(viewModelsDirectory); Directory.CreateDirectory(assetsDirectory); var safeId = new string(projectName.ToLowerInvariant() .Select(character => char.IsLetterOrDigit(character) ? character : '-') .ToArray()).Trim('-'); if (string.IsNullOrWhiteSpace(safeId)) safeId = "new-tool-project"; var className = new string(projectName.Where(char.IsLetterOrDigit).ToArray()); if (string.IsNullOrWhiteSpace(className) || !char.IsLetter(className[0])) className = "NewToolProject"; var toolNamespace = $"XFEToolBox.Tools.{className}"; var jsonProjectName = JsonSerializer.Serialize(projectName); var xamlProjectName = SecurityElement.Escape(projectName) ?? projectName; var templates = new Dictionary { ["manifest.json"] = $$""" { "packageFormatVersion": 1, "id": "local.{{safeId}}", "name": {{jsonProjectName}}, "subtitle": "WPF 独立工具", "version": "1.0.0", "description": "请在这里填写工具说明。", "author": "XFEstudio", "icon": "{{DefaultToolIconRelativePath}}", "category": "开发工具", "tags": [ "WPF" ], "nugetPackages": [], "requiresAdministrator": false, "entry": { "viewXaml": "Code/Views/MainPage.xaml", "viewClass": "{{toolNamespace}}.MainPage", "viewCodeBehind": "Code/Views/MainPage.xaml.cs", "viewModel": "Code/ViewModels/MainPageViewModel.cs", "viewModelClass": "{{toolNamespace}}.MainPageViewModel" }, "window": { "width": 760, "height": 560, "minWidth": 420, "minHeight": 300, "allowResize": true, "allowMaximize": true, "showMinimizeButton": true, "showCloseButton": true }, "requestedPermissions": [] } """, [Path.Combine("Code", "Views", "MainPage.xaml")] = $$""" """, [Path.Combine("Code", "Views", "MainPage.xaml.cs")] = $$""" using System.Windows; using System.Windows.Controls; namespace {{toolNamespace}}; public partial class MainPage : UserControl { public MainPage() { InitializeComponent(); DataContext = new MainPageViewModel(); Unloaded += OnUnloaded; } private void OnUnloaded(object sender, RoutedEventArgs e) => ((MainPageViewModel)DataContext).SaveSettings(); } """, [Path.Combine("Code", "ViewModels", "MainPageViewModel.cs")] = $$""" using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using XFEToolBox.Core.Tools; namespace {{toolNamespace}}; public partial class MainPageViewModel : ObservableObject { [ObservableProperty] private string result = string.Empty; public MainPageViewModel() { if (ToolDataStore.IsInitialized) result = ToolDataStore.Read("settings", new ToolSettings(string.Empty)).LastResult; } public void SaveSettings() { if (ToolDataStore.IsInitialized) ToolDataStore.Write("settings", new ToolSettings(Result)); } [RelayCommand] private void Execute() => Result = "工具执行成功"; private sealed record ToolSettings(string LastResult); } """, [Path.Combine("Code", "Models", "ToolModel.cs")] = $$""" namespace {{toolNamespace}}; public sealed class ToolModel { } """, ["README.md"] = $"# {projectName}\n\n使用 XFEToolBox Code Studio 编辑并发布此工具。\n" }; foreach (var (relativePath, content) in templates) { var output = Path.Combine(root, relativePath); Directory.CreateDirectory(Path.GetDirectoryName(output)!); await File.WriteAllTextAsync(output, content, new UTF8Encoding(false)); } await WriteDefaultToolIconAsync(Path.Combine(root, DefaultToolIconRelativePath)); await RememberProjectAsync(root); return root; } public static async Task> LoadHistoryAsync() { try { var hasSavedHistory = File.Exists(HistoryPath); var items = hasSavedHistory ? (JsonSerializer.Deserialize(await File.ReadAllTextAsync(HistoryPath), JsonOptions) ?? []).ToList() : []; if (!hasSavedHistory && Directory.Exists(DefaultProjectsRoot)) { foreach (var directory in Directory.EnumerateDirectories(DefaultProjectsRoot)) { if (!File.Exists(Path.Combine(directory, "manifest.json")) || items.Any(item => item.ProjectPath.Equals(directory, StringComparison.OrdinalIgnoreCase))) continue; items.Add(new ToolProjectHistoryItem( await ReadProjectNameAsync(directory), Path.GetFullPath(directory), Directory.GetLastWriteTimeUtc(directory))); } } return items.OrderByDescending(item => item.LastOpenedUtc).Take(30).ToArray(); } catch { return []; } } public static async Task RememberProjectAsync(string projectPath) { var fullPath = Path.GetFullPath(projectPath); var items = (await LoadHistoryAsync()).ToList(); items.RemoveAll(item => item.ProjectPath.Equals(fullPath, StringComparison.OrdinalIgnoreCase)); items.Insert(0, new ToolProjectHistoryItem(await ReadProjectNameAsync(fullPath), fullPath, DateTime.UtcNow)); await SaveHistoryAsync(items.Take(30)); } public static async Task RemoveProjectFromHistoryAsync(string projectPath) { var items = (await LoadHistoryAsync()) .Where(item => !item.ProjectPath.Equals(projectPath, StringComparison.OrdinalIgnoreCase)); await SaveHistoryAsync(items); } public static async Task> LoadExplorerOrderAsync(string projectPath) { try { var path = GetLayoutPath(projectPath); return File.Exists(path) ? JsonSerializer.Deserialize(await File.ReadAllTextAsync(path), JsonOptions) ?? [] : []; } catch { return []; } } public static async Task SaveExplorerOrderAsync(string projectPath, IEnumerable relativePaths) { var path = GetLayoutPath(projectPath); Directory.CreateDirectory(Path.GetDirectoryName(path)!); await File.WriteAllTextAsync(path, JsonSerializer.Serialize(relativePaths.Distinct(StringComparer.OrdinalIgnoreCase), JsonOptions), new UTF8Encoding(false)); } private static async Task ReadProjectNameAsync(string root) { try { var manifestPath = Path.Combine(root, "manifest.json"); var manifest = JsonSerializer.Deserialize(await File.ReadAllTextAsync(manifestPath), JsonOptions); if (!string.IsNullOrWhiteSpace(manifest?.Name)) return manifest.Name; } catch { // 清单缺失或格式错误时使用目录名作为历史项目名称。 } return Path.GetFileName(Path.TrimEndingDirectorySeparator(root)); } private static async Task SaveHistoryAsync(IEnumerable items) { Directory.CreateDirectory(DefaultProjectsRoot); await File.WriteAllTextAsync(HistoryPath, JsonSerializer.Serialize(items, JsonOptions), new UTF8Encoding(false)); } private static async Task WriteDefaultToolIconAsync(string outputPath) { var assemblyName = typeof(ToolProjectWorkspaceService).Assembly.GetName().Name ?? throw new InvalidOperationException("无法确定客户端程序集名称。"); var resourceUri = new Uri( $"pack://application:,,,/{assemblyName};component/{DefaultToolIconResourcePath}", UriKind.Absolute); var resource = Application.GetResourceStream(resourceUri) ?? throw new InvalidOperationException("无法读取内置的默认工具图标。"); await using var source = resource.Stream; await using var destination = new FileStream( outputPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous); await source.CopyToAsync(destination); } private static string GetLayoutPath(string projectPath) { var normalized = Path.GetFullPath(projectPath).ToUpperInvariant(); var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant(); return Path.Combine(AppPath.AppLocalData, "EditorLayouts", key + ".json"); } } internal sealed record ToolProjectHistoryItem(string Name, string ProjectPath, DateTime LastOpenedUtc) { [JsonIgnore] public bool Exists => Directory.Exists(ProjectPath); [JsonIgnore] public string StateText => Exists ? "可用" : "位置不存在"; [JsonIgnore] public string LastOpenedText => LastOpenedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"); }