using System.IO;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using XFEToolBox.Client.Model;
using XFEToolBox.Client.Models;
using XFEToolBox.Client.Utilities;
using XFEToolBox.Client.Utilities.Server;
using XFEToolBox.Client.ViewModel.Pages;
using XFEToolBox.Client.Profiles.CacheProfiles;
using XFEToolBox.Client.Views.Pages.Popups;
using XFEToolBox.Core.Model;
using XFEToolBox.Core.Tools;
namespace XFEToolBox.Client.Views.Pages;
public partial class ToolBoxPage : Page
{
private static readonly ImageSource DefaultToolIcon = CreateDefaultIcon();
private static readonly JsonSerializerOptions CacheJsonOptions = new(JsonSerializerDefaults.Web);
private readonly List<ToolCardViewModel> _tools = [];
private ToolCatalogRowViewModel[] _toolRows = [];
private int _visibleCategoryCount;
private ToolPackageSummary[] _cachedCatalog = [];
private bool _updatingCategories;
private bool _cacheLoaded;
private bool _hasCachedCatalog;
private int _refreshGeneration;
public static ToolBoxPage Current { get; private set; } = new();
public ToolBoxPage()
{
Current = this;
InitializeComponent();
CategoryFilter.Items.Add("全部分类");
CategoryFilter.SelectedIndex = 0;
}
private async void Page_Loaded(object sender, RoutedEventArgs e) => await RefreshAsync(refreshFullCatalog: true);
private async void RefreshButton_Click(object sender, RoutedEventArgs e) => await RefreshAsync(refreshFullCatalog: true);
private async void SearchButton_Click(object sender, RoutedEventArgs e) => await RefreshAsync(updateCategories: false);
private async void SearchTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter) await RefreshAsync(updateCategories: false);
}
private async void CategoryFilter_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!_updatingCategories && IsLoaded) await RefreshAsync(updateCategories: false);
}
private async Task RefreshAsync(bool updateCategories = true, bool refreshFullCatalog = false)
{
EnsureCacheLoaded();
var query = SearchTextBox.Text.Trim();
var category = GetSelectedCategory();
if (_hasCachedCatalog)
{
if (updateCategories) UpdateCategories(GetCatalogCategories(_cachedCatalog), category);
category = GetSelectedCategory();
ApplyTools(FilterCachedTools(query, category));
StatusText.Text = _tools.Count == 0
? "缓存中没有符合条件的工具,正在后台刷新…"
: "已显示本地缓存,正在后台刷新…";
}
else
{
StatusText.Text = "正在连接工具服务器…";
}
var refreshGeneration = ++_refreshGeneration;
SearchButton.IsEnabled = RefreshButton.IsEnabled = false;
CategoryFilter.IsEnabled = false;
try
{
var requestQuery = refreshFullCatalog ? string.Empty : query;
var requestCategory = refreshFullCatalog ? string.Empty : category;
var response = await ClientSession.Requester.Request<ToolPackageSummary[]>(
"catalogTools", requestQuery, requestCategory);
if (refreshGeneration != _refreshGeneration) return;
if (response.StatusCode != HttpStatusCode.OK)
{
ShowRefreshFailure(response.Message);
return;
}
var tools = response.Result ?? [];
if (refreshFullCatalog || string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(category))
{
_cachedCatalog = tools;
_hasCachedCatalog = true;
TrySaveCatalogCache(tools);
if (updateCategories)
{
UpdateCategories(GetCatalogCategories(_cachedCatalog), category);
category = GetSelectedCategory();
}
tools = FilterCachedTools(query, category);
}
ApplyTools(tools);
StatusText.Text = _tools.Count == 0
? "没有符合条件的工具。"
: "点击卡片打开工具;右键卡片可打开工具菜单。";
}
catch (Exception exception)
{
if (refreshGeneration == _refreshGeneration)
ShowRefreshFailure($"读取失败:{exception.Message}");
}
finally
{
if (refreshGeneration == _refreshGeneration)
{
SearchButton.IsEnabled = RefreshButton.IsEnabled = true;
CategoryFilter.IsEnabled = true;
}
}
}
private void EnsureCacheLoaded()
{
if (_cacheLoaded) return;
_cacheLoaded = true;
try
{
var json = AppCacheProfile.ToolCatalogJson;
if (string.IsNullOrWhiteSpace(json)) return;
_cachedCatalog = JsonSerializer.Deserialize<ToolPackageSummary[]>(json, CacheJsonOptions) ?? [];
_hasCachedCatalog = true;
}
catch
{
_cachedCatalog = [];
_hasCachedCatalog = false;
}
}
private ToolPackageSummary[] FilterCachedTools(string query, string category) =>
_cachedCatalog.Where(tool =>
(string.IsNullOrWhiteSpace(category)
|| string.Equals(NormalizeCategory(tool.Category), category, StringComparison.CurrentCultureIgnoreCase))
&& (string.IsNullOrWhiteSpace(query)
|| Contains(tool.Name, query)
|| Contains(tool.Description, query)
|| Contains(tool.Author, query)
|| Contains(tool.Category, query)
|| tool.Tags?.Any(tag => Contains(tag, query)) == true))
.ToArray();
private void ApplyTools(IReadOnlyList<ToolPackageSummary> tools)
{
var desiredCards = tools.Select(tool =>
{
var existing = _tools.FirstOrDefault(card =>
string.Equals(card.Id, tool.Id, StringComparison.OrdinalIgnoreCase)
&& ToolSummariesEquivalent(card.Package, tool));
if (existing is not null) return existing;
return CreateToolCard(tool);
}).ToArray();
_tools.Clear();
_tools.AddRange(desiredCards);
RebuildCatalogRows();
EmptyState.Visibility = _tools.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
ToolCountText.Text = FormatToolCount();
}
private void RebuildCatalogRows()
{
var categoryOrder = CategoryFilter.Items.Cast<object>()
.Skip(1)
.Select((item, index) => (Name: item?.ToString() ?? string.Empty, Index: index))
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
.GroupBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase)
.ToDictionary(group => group.Key, group => group.Min(item => item.Index), StringComparer.CurrentCultureIgnoreCase);
var groups = _tools
.GroupBy(card => NormalizeCategory(card.Category), StringComparer.CurrentCultureIgnoreCase)
.Select(group => (Name: group.Key, Items: (IReadOnlyList<ToolCardViewModel>)group.ToArray()))
.OrderBy(group => categoryOrder.TryGetValue(group.Name, out var index) ? index : int.MaxValue)
.ThenBy(group => group.Name, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
var rows = new List<ToolCatalogRowViewModel>(_tools.Count / 2 + groups.Length * 2);
foreach (var group in groups)
{
rows.Add(ToolCatalogRowViewModel.Header(
new ToolCategoryGroupViewModel(group.Name, group.Items.Count)));
for (var index = 0; index < group.Items.Count; index += 2)
rows.Add(ToolCatalogRowViewModel.Cards(
group.Items[index],
index + 1 < group.Items.Count ? group.Items[index + 1] : null));
}
_visibleCategoryCount = groups.Length;
_toolRows = rows.ToArray();
ToolCards.ItemsSource = _toolRows;
}
private string FormatToolCount() => _tools.Count == 0
? "0 个工具"
: $"{_tools.Count} 个工具 · {_visibleCategoryCount} 个分类";
private static string NormalizeCategory(string? category) =>
string.IsNullOrWhiteSpace(category) ? "其他工具" : category.Trim();
private void ShowRefreshFailure(string message)
{
if (_hasCachedCatalog)
{
EmptyState.Visibility = _tools.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
ToolCountText.Text = FormatToolCount();
StatusText.Text = $"后台刷新失败,当前显示本地缓存:{message}";
return;
}
ShowEmptyResult(message);
}
private static void TrySaveCatalogCache(ToolPackageSummary[] tools)
{
try
{
AppCacheProfile.ToolCatalogJson = JsonSerializer.Serialize(tools, CacheJsonOptions);
}
catch
{
// 缓存写入失败不应影响已成功获取的在线目录。
}
}
private static bool Contains(string? value, string query) =>
value?.Contains(query, StringComparison.CurrentCultureIgnoreCase) == true;
private static bool ToolSummariesEquivalent(ToolPackageSummary left, ToolPackageSummary right) =>
JsonSerializer.Serialize(left, CacheJsonOptions) == JsonSerializer.Serialize(right, CacheJsonOptions);
private static string[] GetCatalogCategories(IEnumerable<ToolPackageSummary> tools) =>
tools.Select(tool => NormalizeCategory(tool.Category))
.Distinct(StringComparer.CurrentCultureIgnoreCase)
.OrderBy(category => category, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
private void UpdateCategories(IEnumerable<string> categories, string selectedCategory)
{
_updatingCategories = true;
CategoryFilter.Items.Clear();
CategoryFilter.Items.Add("全部分类");
foreach (var category in categories) CategoryFilter.Items.Add(category);
CategoryFilter.SelectedItem = CategoryFilter.Items.Cast<object>()
.FirstOrDefault(item => string.Equals(item.ToString(), selectedCategory, StringComparison.CurrentCultureIgnoreCase));
if (CategoryFilter.SelectedIndex < 0) CategoryFilter.SelectedIndex = 0;
_updatingCategories = false;
}
private string GetSelectedCategory() =>
CategoryFilter.SelectedIndex > 0 ? CategoryFilter.SelectedItem?.ToString() ?? string.Empty : string.Empty;
private void ShowEmptyResult(string message)
{
_tools.Clear();
_toolRows = [];
_visibleCategoryCount = 0;
ToolCards.ItemsSource = _toolRows;
EmptyState.Visibility = Visibility.Visible;
ToolCountText.Text = string.Empty;
StatusText.Text = message;
}
private async void ToolCard_Click(object sender, RoutedEventArgs e)
{
if (sender is Button { CommandParameter: ToolCardViewModel card })
await OpenToolAsync(card);
}
private async void OpenToolMenuItem_Click(object sender, RoutedEventArgs e)
{
e.Handled = true;
if (sender is MenuItem { CommandParameter: ToolCardViewModel card })
await OpenToolAsync(card);
}
/// <summary>
/// 供主页最近使用卡片调用。优先使用本地目录快照,缺失时再刷新完整服务器目录。
/// </summary>
public async Task<bool> OpenToolByIdAsync(string toolId)
{
if (string.IsNullOrWhiteSpace(toolId)) return false;
EnsureCacheLoaded();
var card = _tools.FirstOrDefault(item => string.Equals(item.Id, toolId, StringComparison.OrdinalIgnoreCase));
var summary = card?.Package ?? _cachedCatalog.FirstOrDefault(item =>
string.Equals(item.Id, toolId, StringComparison.OrdinalIgnoreCase));
if (summary is null)
{
try
{
StatusText.Text = "正在更新工具目录…";
var response = await ClientSession.Requester.Request<ToolPackageSummary[]>(
"catalogTools", string.Empty, string.Empty);
if (response.StatusCode == HttpStatusCode.OK)
{
_cachedCatalog = response.Result ?? [];
_hasCachedCatalog = true;
TrySaveCatalogCache(_cachedCatalog);
summary = _cachedCatalog.FirstOrDefault(item =>
string.Equals(item.Id, toolId, StringComparison.OrdinalIgnoreCase));
}
else
{
StatusText.Text = string.IsNullOrWhiteSpace(response.Message)
? "无法更新工具目录。"
: response.Message;
}
}
catch (Exception exception)
{
StatusText.Text = $"更新工具目录失败:{exception.Message}";
}
}
if (summary is null)
{
StatusText.Text = "该工具已下架或当前服务器不再提供。";
return false;
}
card ??= CreateToolCard(summary);
return await OpenToolAsync(card);
}
private static ToolCardViewModel CreateToolCard(ToolPackageSummary summary) => new(
summary,
CreateIconSource(summary.IconDataUrl),
ElevationShieldIcon.Source,
File.Exists(GetCachePath(summary)),
ToolLaunchPreferenceService.GetRunAsAdministrator(summary.Id));
private void ToolConfigurationMenuItem_Click(object sender, RoutedEventArgs e)
{
e.Handled = true;
if (sender is not MenuItem { CommandParameter: ToolCardViewModel card }) return;
var configurationPage = new ToolConfigurationPopupPage(
card.Name,
card.UserRunAsAdministrator,
card.RequiresAdministrator,
card.UacIconSource);
var result = PopupHelper.ShowDialog(configurationPage, new PopupWindowOptions
{
Title = "工具配置",
Subtitle = card.Name,
Width = 470,
Height = 330
});
if (result != MessageBoxResult.OK) return;
ToolLaunchPreferenceService.SetRunAsAdministrator(card.Id, configurationPage.UserRunAsAdministrator);
card.UserRunAsAdministrator = configurationPage.UserRunAsAdministrator;
StatusText.Text = card.RequiresAdministrator
? $"{card.Name} 的项目清单强制要求管理员权限。"
: card.RunAsAdministrator
? $"{card.Name} 已配置为以管理员身份打开。"
: $"{card.Name} 已配置为以普通权限打开。";
}
private void TogglePinnedToolMenuItem_Click(object sender, RoutedEventArgs e)
{
e.Handled = true;
if (sender is not MenuItem { CommandParameter: ToolCardViewModel card }) return;
var wasPinned = PinnedItemService.IsPinned(LauncherItemKind.Tool, card.Id);
var success = wasPinned
? PinnedItemService.Unpin(LauncherItemKind.Tool, card.Id)
: PinnedItemService.TryPin(LauncherItemKind.Tool, card.Id);
StatusText.Text = !success
? $"最多只能固定 {PinnedItemService.MaximumPinnedItems} 项。"
: wasPinned ? $"已取消固定 {card.Name}。" : $"已将 {card.Name} 固定到主页。";
}
private void ClearToolDataMenuItem_Click(object sender, RoutedEventArgs e)
{
e.Handled = true;
if (sender is not MenuItem { CommandParameter: ToolCardViewModel card }) return;
try
{
var size = ToolDataManager.GetToolDataSize(card.Id);
if (size == 0 && !ToolDataManager.HasToolData(card.Id))
{
StatusText.Text = $"{card.Name} 当前没有已保存的数据。";
return;
}
var sizeText = FormatDataSize(size);
var result = PopupHelper.ShowConfirmDialog(
$"确定清除“{card.Name}”的全部工具数据吗?\n\n当前占用:{sizeText}\n包括工具设置与上次窗口状态。此操作无法撤销;若工具仍在运行,关闭时可能重新写入窗口状态。",
showCancelButton: true,
confirmText: "清除数据");
if (result != MessageBoxResult.OK) return;
ToolDataManager.ClearToolData(card.Id);
StatusText.Text = $"已清除 {card.Name} 的工具数据。";
}
catch (Exception exception)
{
StatusText.Text = $"清除 {card.Name} 的数据失败:{exception.Message}";
}
}
private async Task<bool> OpenToolAsync(ToolCardViewModel card)
{
using var activity = ActivityCenterService.Start(
$"准备工具 {card.Name}", XFEToolBox.Client.Models.ActivityKind.ToolPackage, canCancel: true);
var cancellationToken = activity.CancellationToken;
string? temporaryPath = null;
string? cachePath = null;
card.IsEnabled = false;
card.CacheState = "正在校验…";
StatusText.Text = $"正在准备 {card.Name}…";
activity.Report(null, StatusText.Text);
try
{
var detailsResponse = await ClientSession.Requester.Request<ToolPackageDetails>("catalogToolDetails", card.Id);
var package = detailsResponse.Result?.Versions.FirstOrDefault(item => item.Version == card.LatestVersion);
if (detailsResponse.StatusCode != HttpStatusCode.OK || package is null)
throw new InvalidOperationException(string.IsNullOrWhiteSpace(detailsResponse.Message) ? "服务器没有返回对应版本。" : detailsResponse.Message);
var runAsAdministrator = card.RunAsAdministrator || detailsResponse.Result!.Manifest.RequiresAdministrator;
cachePath = GetCachePath(card.Package);
if (!await IsCachedPackageValidAsync(cachePath, package.Sha256))
{
card.IsDownloading = true;
card.IsDownloadIndeterminate = package.PackageSize <= 0;
card.DownloadProgress = 0;
card.DownloadProgressText = package.PackageSize > 0
? $"0 B / {FormatDataSize(package.PackageSize)}"
: "正在连接下载服务器…";
card.CacheState = "下载 0%";
StatusText.Text = $"正在获取 {card.Name} {card.LatestVersion}…";
var cacheDirectory = Path.GetDirectoryName(cachePath)!;
Directory.CreateDirectory(cacheDirectory);
temporaryPath = Path.Combine(cacheDirectory, $".{Guid.NewGuid():N}.download");
using var client = new HttpClient
{
BaseAddress = new Uri(ClientSession.ApiAddress + "/"),
Timeout = TimeSpan.FromMinutes(10)
};
var catalogClient = new ToolCatalogClient(client);
var downloadProgress = new Progress<ToolPackageDownloadProgress>(item =>
{
card.IsDownloadIndeterminate = item.TotalBytes is null or <= 0;
card.DownloadProgress = item.Percentage ?? 0;
card.DownloadProgressText = item.TotalBytes is > 0
? $"{FormatDataSize(item.BytesReceived)} / {FormatDataSize(item.TotalBytes.Value)}"
: $"已下载 {FormatDataSize(item.BytesReceived)}";
card.CacheState = item.Percentage is { } percentage
? $"下载 {percentage:0}%"
: "正在下载…";
StatusText.Text = $"正在下载 {card.Name} · {card.DownloadProgressText}";
activity.Report(item.Percentage, StatusText.Text);
});
await using (var output = new FileStream(
temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920,
FileOptions.Asynchronous | FileOptions.SequentialScan))
{
await catalogClient.DownloadPackageAsync(package, output, downloadProgress, cancellationToken);
await output.FlushAsync(cancellationToken);
}
card.CacheState = "正在校验…";
card.DownloadProgress = 100;
card.IsDownloadIndeterminate = false;
card.DownloadProgressText = "下载完成,校验通过";
File.Move(temporaryPath, cachePath, overwrite: true);
temporaryPath = null;
card.IsDownloading = false;
}
card.CacheState = "正在打开…";
StatusText.Text = runAsAdministrator
? $"正在准备 {card.Name},随后将向 Windows 请求管理员权限…"
: $"正在编译并打开 {card.Name}…";
var runResult = await ToolProjectRunService.BuildPackageAndRunAsync(
cachePath,
card.Id,
package.Version,
package.Sha256,
runAsAdministrator,
cancellationToken);
if (!runResult.Success)
throw new InvalidOperationException(runResult.Message);
card.CacheState = "已打开";
StatusText.Text = runAsAdministrator
? $"{card.Name} {card.LatestVersion} 已以管理员身份打开。"
: $"{card.Name} {card.LatestVersion} 已在独立窗口中打开。";
RecentUsageIconCache.Remember(RecentUsageKind.Tool, card.Id, card.IconSource);
RecentUsageService.RecordTool(card.Package);
activity.Succeed(StatusText.Text);
return true;
}
catch (OperationCanceledException)
{
card.CacheState = "已取消";
StatusText.Text = $"已取消打开 {card.Name}。";
activity.Cancel(StatusText.Text);
return false;
}
catch (Exception exception)
{
card.CacheState = cachePath is not null && File.Exists(cachePath) ? "重试打开" : "重试获取";
StatusText.Text = $"打开失败:{exception.Message}";
activity.Fail(StatusText.Text);
return false;
}
finally
{
card.IsDownloading = false;
card.IsEnabled = true;
if (temporaryPath is not null && File.Exists(temporaryPath))
File.Delete(temporaryPath);
}
}
private static string GetCachePath(ToolPackageSummary tool) => Path.Combine(
AppPath.CacheProfile,
"ToolPackages",
tool.Id,
$"{tool.LatestVersion}.xfetool");
private static async Task<bool> IsCachedPackageValidAsync(string path, string expectedSha256)
{
if (!File.Exists(path)) return false;
await using var stream = new FileStream(
path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920,
FileOptions.Asynchronous | FileOptions.SequentialScan);
using var sha256 = SHA256.Create();
var actual = Convert.ToHexString(await sha256.ComputeHashAsync(stream)).ToLowerInvariant();
return string.Equals(actual, expectedSha256, StringComparison.OrdinalIgnoreCase);
}
private static string FormatDataSize(long bytes)
{
if (bytes < 1024) return $"{bytes} B";
if (bytes < 1024 * 1024) return $"{bytes / 1024d:F1} KB";
if (bytes < 1024L * 1024 * 1024) return $"{bytes / 1024d / 1024:F1} MB";
return $"{bytes / 1024d / 1024 / 1024:F1} GB";
}
private static ImageSource CreateIconSource(string? dataUrl)
{
if (string.IsNullOrWhiteSpace(dataUrl) || !dataUrl.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase))
return DefaultToolIcon;
try
{
var separator = dataUrl.IndexOf(',');
if (separator < 0) return DefaultToolIcon;
var header = dataUrl[5..separator];
var mediaType = header.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();
var bytes = Convert.FromBase64String(dataUrl[(separator + 1)..]);
return WebImageSourceLoader.Decode(bytes, mediaType, "tool-icon");
}
catch
{
return DefaultToolIcon;
}
}
private static ImageSource CreateDefaultIcon()
{
var assemblyName = typeof(ToolBoxPage).Assembly.GetName().Name;
var image = new BitmapImage(new Uri(
$"pack://application:,,,/{assemblyName};component/Resources/Image/default_tool_icon.png",
UriKind.Absolute));
image.Freeze();
return image;
}
}
using System.IO;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using XFEToolBox.Client.Model;
using XFEToolBox.Client.Models;
using XFEToolBox.Client.Utilities;
using XFEToolBox.Client.Utilities.Server;
using XFEToolBox.Client.ViewModel.Pages;
using XFEToolBox.Client.Profiles.CacheProfiles;
using XFEToolBox.Client.Views.Pages.Popups;
using XFEToolBox.Core.Model;
using XFEToolBox.Core.Tools;
namespace XFEToolBox.Client.Views.Pages;
public partial class ToolBoxPage : Page
{
private static readonly ImageSource DefaultToolIcon = CreateDefaultIcon();
private static readonly JsonSerializerOptions CacheJsonOptions = new(JsonSerializerDefaults.Web);
private readonly List<ToolCardViewModel> _tools = [];
private ToolCatalogRowViewModel[] _toolRows = [];
private int _visibleCategoryCount;
private ToolPackageSummary[] _cachedCatalog = [];
private bool _updatingCategories;
private bool _cacheLoaded;
private bool _hasCachedCatalog;
private int _refreshGeneration;
public static ToolBoxPage Current { get; private set; } = new();
public ToolBoxPage()
{
Current = this;
InitializeComponent();
CategoryFilter.Items.Add("全部分类");
CategoryFilter.SelectedIndex = 0;
}
private async void Page_Loaded(object sender, RoutedEventArgs e) => await RefreshAsync(refreshFullCatalog: true);
private async void RefreshButton_Click(object sender, RoutedEventArgs e) => await RefreshAsync(refreshFullCatalog: true);
private async void SearchButton_Click(object sender, RoutedEventArgs e) => await RefreshAsync(updateCategories: false);
private async void SearchTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter) await RefreshAsync(updateCategories: false);
}
private async void CategoryFilter_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!_updatingCategories && IsLoaded) await RefreshAsync(updateCategories: false);
}
private async Task RefreshAsync(bool updateCategories = true, bool refreshFullCatalog = false)
{
EnsureCacheLoaded();
var query = SearchTextBox.Text.Trim();
var category = GetSelectedCategory();
if (_hasCachedCatalog)
{
if (updateCategories) UpdateCategories(GetCatalogCategories(_cachedCatalog), category);
category = GetSelectedCategory();
ApplyTools(FilterCachedTools(query, category));
StatusText.Text = _tools.Count == 0
? "缓存中没有符合条件的工具,正在后台刷新…"
: "已显示本地缓存,正在后台刷新…";
}
else
{
StatusText.Text = "正在连接工具服务器…";
}
var refreshGeneration = ++_refreshGeneration;
SearchButton.IsEnabled = RefreshButton.IsEnabled = false;
CategoryFilter.IsEnabled = false;
try
{
var requestQuery = refreshFullCatalog ? string.Empty : query;
var requestCategory = refreshFullCatalog ? string.Empty : category;
var response = await ClientSession.Requester.Request<ToolPackageSummary[]>(
"catalogTools", requestQuery, requestCategory);
if (refreshGeneration != _refreshGeneration) return;
if (response.StatusCode != HttpStatusCode.OK)
{
ShowRefreshFailure(response.Message);
return;
}
var tools = response.Result ?? [];
if (refreshFullCatalog || string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(category))
{
_cachedCatalog = tools;
_hasCachedCatalog = true;
TrySaveCatalogCache(tools);
if (updateCategories)
{
UpdateCategories(GetCatalogCategories(_cachedCatalog), category);
category = GetSelectedCategory();
}
tools = FilterCachedTools(query, category);
}
ApplyTools(tools);
StatusText.Text = _tools.Count == 0
? "没有符合条件的工具。"
: "点击卡片打开工具;右键卡片可打开工具菜单。";
}
catch (Exception exception)
{
if (refreshGeneration == _refreshGeneration)
ShowRefreshFailure($"读取失败:{exception.Message}");
}
finally
{
if (refreshGeneration == _refreshGeneration)
{
SearchButton.IsEnabled = RefreshButton.IsEnabled = true;
CategoryFilter.IsEnabled = true;
}
}
}
private void EnsureCacheLoaded()
{
if (_cacheLoaded) return;
_cacheLoaded = true;
try
{
var json = AppCacheProfile.ToolCatalogJson;
if (string.IsNullOrWhiteSpace(json)) return;
_cachedCatalog = JsonSerializer.Deserialize<ToolPackageSummary[]>(json, CacheJsonOptions) ?? [];
_hasCachedCatalog = true;
}
catch
{
_cachedCatalog = [];
_hasCachedCatalog = false;
}
}
private ToolPackageSummary[] FilterCachedTools(string query, string category) =>
_cachedCatalog.Where(tool =>
(string.IsNullOrWhiteSpace(category)
|| string.Equals(NormalizeCategory(tool.Category), category, StringComparison.CurrentCultureIgnoreCase))
&& (string.IsNullOrWhiteSpace(query)
|| Contains(tool.Name, query)
|| Contains(tool.Description, query)
|| Contains(tool.Author, query)
|| Contains(tool.Category, query)
|| tool.Tags?.Any(tag => Contains(tag, query)) == true))
.ToArray();
private void ApplyTools(IReadOnlyList<ToolPackageSummary> tools)
{
var desiredCards = tools.Select(tool =>
{
var existing = _tools.FirstOrDefault(card =>
string.Equals(card.Id, tool.Id, StringComparison.OrdinalIgnoreCase)
&& ToolSummariesEquivalent(card.Package, tool));
if (existing is not null) return existing;
return CreateToolCard(tool);
}).ToArray();
_tools.Clear();
_tools.AddRange(desiredCards);
RebuildCatalogRows();
EmptyState.Visibility = _tools.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
ToolCountText.Text = FormatToolCount();
}
private void RebuildCatalogRows()
{
var categoryOrder = CategoryFilter.Items.Cast<object>()
.Skip(1)
.Select((item, index) => (Name: item?.ToString() ?? string.Empty, Index: index))
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
.GroupBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase)
.ToDictionary(group => group.Key, group => group.Min(item => item.Index), StringComparer.CurrentCultureIgnoreCase);
var groups = _tools
.GroupBy(card => NormalizeCategory(card.Category), StringComparer.CurrentCultureIgnoreCase)
.Select(group => (Name: group.Key, Items: (IReadOnlyList<ToolCardViewModel>)group.ToArray()))
.OrderBy(group => categoryOrder.TryGetValue(group.Name, out var index) ? index : int.MaxValue)
.ThenBy(group => group.Name, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
var rows = new List<ToolCatalogRowViewModel>(_tools.Count / 2 + groups.Length * 2);
foreach (var group in groups)
{
rows.Add(ToolCatalogRowViewModel.Header(
new ToolCategoryGroupViewModel(group.Name, group.Items.Count)));
for (var index = 0; index < group.Items.Count; index += 2)
rows.Add(ToolCatalogRowViewModel.Cards(
group.Items[index],
index + 1 < group.Items.Count ? group.Items[index + 1] : null));
}
_visibleCategoryCount = groups.Length;
_toolRows = rows.ToArray();
ToolCards.ItemsSource = _toolRows;
}
private string FormatToolCount() => _tools.Count == 0
? "0 个工具"
: $"{_tools.Count} 个工具 · {_visibleCategoryCount} 个分类";
private static string NormalizeCategory(string? category) =>
string.IsNullOrWhiteSpace(category) ? "其他工具" : category.Trim();
private void ShowRefreshFailure(string message)
{
if (_hasCachedCatalog)
{
EmptyState.Visibility = _tools.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
ToolCountText.Text = FormatToolCount();
StatusText.Text = $"后台刷新失败,当前显示本地缓存:{message}";
return;
}
ShowEmptyResult(message);
}
private static void TrySaveCatalogCache(ToolPackageSummary[] tools)
{
try
{
AppCacheProfile.ToolCatalogJson = JsonSerializer.Serialize(tools, CacheJsonOptions);
}
catch
{
// 缓存写入失败不应影响已成功获取的在线目录。
}
}
private static bool Contains(string? value, string query) =>
value?.Contains(query, StringComparison.CurrentCultureIgnoreCase) == true;
private static bool ToolSummariesEquivalent(ToolPackageSummary left, ToolPackageSummary right) =>
JsonSerializer.Serialize(left, CacheJsonOptions) == JsonSerializer.Serialize(right, CacheJsonOptions);
private static string[] GetCatalogCategories(IEnumerable<ToolPackageSummary> tools) =>
tools.Select(tool => NormalizeCategory(tool.Category))
.Distinct(StringComparer.CurrentCultureIgnoreCase)
.OrderBy(category => category, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
private void UpdateCategories(IEnumerable<string> categories, string selectedCategory)
{
_updatingCategories = true;
CategoryFilter.Items.Clear();
CategoryFilter.Items.Add("全部分类");
foreach (var category in categories) CategoryFilter.Items.Add(category);
CategoryFilter.SelectedItem = CategoryFilter.Items.Cast<object>()
.FirstOrDefault(item => string.Equals(item.ToString(), selectedCategory, StringComparison.CurrentCultureIgnoreCase));
if (CategoryFilter.SelectedIndex < 0) CategoryFilter.SelectedIndex = 0;
_updatingCategories = false;
}
private string GetSelectedCategory() =>
CategoryFilter.SelectedIndex > 0 ? CategoryFilter.SelectedItem?.ToString() ?? string.Empty : string.Empty;
private void ShowEmptyResult(string message)
{
_tools.Clear();
_toolRows = [];
_visibleCategoryCount = 0;
ToolCards.ItemsSource = _toolRows;
EmptyState.Visibility = Visibility.Visible;
ToolCountText.Text = string.Empty;
StatusText.Text = message;
}
private async void ToolCard_Click(object sender, RoutedEventArgs e)
{
if (sender is Button { CommandParameter: ToolCardViewModel card })
await OpenToolAsync(card);
}
private async void OpenToolMenuItem_Click(object sender, RoutedEventArgs e)
{
e.Handled = true;
if (sender is MenuItem { CommandParameter: ToolCardViewModel card })
await OpenToolAsync(card);
}
/// <summary>
/// 供主页最近使用卡片调用。优先使用本地目录快照,缺失时再刷新完整服务器目录。
/// </summary>
public async Task<bool> OpenToolByIdAsync(string toolId)
{
if (string.IsNullOrWhiteSpace(toolId)) return false;
EnsureCacheLoaded();
var card = _tools.FirstOrDefault(item => string.Equals(item.Id, toolId, StringComparison.OrdinalIgnoreCase));
var summary = card?.Package ?? _cachedCatalog.FirstOrDefault(item =>
string.Equals(item.Id, toolId, StringComparison.OrdinalIgnoreCase));
if (summary is null)
{
try
{
StatusText.Text = "正在更新工具目录…";
var response = await ClientSession.Requester.Request<ToolPackageSummary[]>(
"catalogTools", string.Empty, string.Empty);
if (response.StatusCode == HttpStatusCode.OK)
{
_cachedCatalog = response.Result ?? [];
_hasCachedCatalog = true;
TrySaveCatalogCache(_cachedCatalog);
summary = _cachedCatalog.FirstOrDefault(item =>
string.Equals(item.Id, toolId, StringComparison.OrdinalIgnoreCase));
}
else
{
StatusText.Text = string.IsNullOrWhiteSpace(response.Message)
? "无法更新工具目录。"
: response.Message;
}
}
catch (Exception exception)
{
StatusText.Text = $"更新工具目录失败:{exception.Message}";
}
}
if (summary is null)
{
StatusText.Text = "该工具已下架或当前服务器不再提供。";
return false;
}
card ??= CreateToolCard(summary);
return await OpenToolAsync(card);
}
private static ToolCardViewModel CreateToolCard(ToolPackageSummary summary) => new(
summary,
CreateIconSource(summary.IconDataUrl),
ElevationShieldIcon.Source,
File.Exists(GetCachePath(summary)),
ToolLaunchPreferenceService.GetRunAsAdministrator(summary.Id));
private void ToolConfigurationMenuItem_Click(object sender, RoutedEventArgs e)
{
e.Handled = true;
if (sender is not MenuItem { CommandParameter: ToolCardViewModel card }) return;
var configurationPage = new ToolConfigurationPopupPage(
card.Name,
card.UserRunAsAdministrator,
card.RequiresAdministrator,
card.UacIconSource);
var result = PopupHelper.ShowDialog(configurationPage, new PopupWindowOptions
{
Title = "工具配置",
Subtitle = card.Name,
Width = 470,
Height = 330
});
if (result != MessageBoxResult.OK) return;
ToolLaunchPreferenceService.SetRunAsAdministrator(card.Id, configurationPage.UserRunAsAdministrator);
card.UserRunAsAdministrator = configurationPage.UserRunAsAdministrator;
StatusText.Text = card.RequiresAdministrator
? $"{card.Name} 的项目清单强制要求管理员权限。"
: card.RunAsAdministrator
? $"{card.Name} 已配置为以管理员身份打开。"
: $"{card.Name} 已配置为以普通权限打开。";
}
private void TogglePinnedToolMenuItem_Click(object sender, RoutedEventArgs e)
{
e.Handled = true;
if (sender is not MenuItem { CommandParameter: ToolCardViewModel card }) return;
var wasPinned = PinnedItemService.IsPinned(LauncherItemKind.Tool, card.Id);
var success = wasPinned
? PinnedItemService.Unpin(LauncherItemKind.Tool, card.Id)
: PinnedItemService.TryPin(LauncherItemKind.Tool, card.Id);
StatusText.Text = !success
? $"最多只能固定 {PinnedItemService.MaximumPinnedItems} 项。"
: wasPinned ? $"已取消固定 {card.Name}。" : $"已将 {card.Name} 固定到主页。";
}
private void ClearToolDataMenuItem_Click(object sender, RoutedEventArgs e)
{
e.Handled = true;
if (sender is not MenuItem { CommandParameter: ToolCardViewModel card }) return;
try
{
var size = ToolDataManager.GetToolDataSize(card.Id);
if (size == 0 && !ToolDataManager.HasToolData(card.Id))
{
StatusText.Text = $"{card.Name} 当前没有已保存的数据。";
return;
}
var sizeText = FormatDataSize(size);
var result = PopupHelper.ShowConfirmDialog(
$"确定清除“{card.Name}”的全部工具数据吗?\n\n当前占用:{sizeText}\n包括工具设置与上次窗口状态。此操作无法撤销;若工具仍在运行,关闭时可能重新写入窗口状态。",
showCancelButton: true,
confirmText: "清除数据");
if (result != MessageBoxResult.OK) return;
ToolDataManager.ClearToolData(card.Id);
StatusText.Text = $"已清除 {card.Name} 的工具数据。";
}
catch (Exception exception)
{
StatusText.Text = $"清除 {card.Name} 的数据失败:{exception.Message}";
}
}
private async Task<bool> OpenToolAsync(ToolCardViewModel card)
{
using var activity = ActivityCenterService.Start(
$"准备工具 {card.Name}", XFEToolBox.Client.Models.ActivityKind.ToolPackage, canCancel: true);
var cancellationToken = activity.CancellationToken;
string? temporaryPath = null;
string? cachePath = null;
card.IsEnabled = false;
card.CacheState = "正在校验…";
StatusText.Text = $"正在准备 {card.Name}…";
activity.Report(null, StatusText.Text);
try
{
var detailsResponse = await ClientSession.Requester.Request<ToolPackageDetails>("catalogToolDetails", card.Id);
var package = detailsResponse.Result?.Versions.FirstOrDefault(item => item.Version == card.LatestVersion);
if (detailsResponse.StatusCode != HttpStatusCode.OK || package is null)
throw new InvalidOperationException(string.IsNullOrWhiteSpace(detailsResponse.Message) ? "服务器没有返回对应版本。" : detailsResponse.Message);
var runAsAdministrator = card.RunAsAdministrator || detailsResponse.Result!.Manifest.RequiresAdministrator;
cachePath = GetCachePath(card.Package);
if (!await IsCachedPackageValidAsync(cachePath, package.Sha256))
{
card.IsDownloading = true;
card.IsDownloadIndeterminate = package.PackageSize <= 0;
card.DownloadProgress = 0;
card.DownloadProgressText = package.PackageSize > 0
? $"0 B / {FormatDataSize(package.PackageSize)}"
: "正在连接下载服务器…";
card.CacheState = "下载 0%";
StatusText.Text = $"正在获取 {card.Name} {card.LatestVersion}…";
var cacheDirectory = Path.GetDirectoryName(cachePath)!;
Directory.CreateDirectory(cacheDirectory);
temporaryPath = Path.Combine(cacheDirectory, $".{Guid.NewGuid():N}.download");
using var client = new HttpClient
{
BaseAddress = new Uri(ClientSession.ApiAddress + "/"),
Timeout = TimeSpan.FromMinutes(10)
};
var catalogClient = new ToolCatalogClient(client);
var downloadProgress = new Progress<ToolPackageDownloadProgress>(item =>
{
card.IsDownloadIndeterminate = item.TotalBytes is null or <= 0;
card.DownloadProgress = item.Percentage ?? 0;
card.DownloadProgressText = item.TotalBytes is > 0
? $"{FormatDataSize(item.BytesReceived)} / {FormatDataSize(item.TotalBytes.Value)}"
: $"已下载 {FormatDataSize(item.BytesReceived)}";
card.CacheState = item.Percentage is { } percentage
? $"下载 {percentage:0}%"
: "正在下载…";
StatusText.Text = $"正在下载 {card.Name} · {card.DownloadProgressText}";
activity.Report(item.Percentage, StatusText.Text);
});
await using (var output = new FileStream(
temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920,
FileOptions.Asynchronous | FileOptions.SequentialScan))
{
await catalogClient.DownloadPackageAsync(package, output, downloadProgress, cancellationToken);
await output.FlushAsync(cancellationToken);
}
card.CacheState = "正在校验…";
card.DownloadProgress = 100;
card.IsDownloadIndeterminate = false;
card.DownloadProgressText = "下载完成,校验通过";
File.Move(temporaryPath, cachePath, overwrite: true);
temporaryPath = null;
card.IsDownloading = false;
}
card.CacheState = "正在打开…";
StatusText.Text = runAsAdministrator
? $"正在准备 {card.Name},随后将向 Windows 请求管理员权限…"
: $"正在编译并打开 {card.Name}…";
var runResult = await ToolProjectRunService.BuildPackageAndRunAsync(
cachePath,
card.Id,
package.Version,
package.Sha256,
runAsAdministrator,
cancellationToken);
if (!runResult.Success)
throw new InvalidOperationException(runResult.Message);
card.CacheState = "已打开";
StatusText.Text = runAsAdministrator
? $"{card.Name} {card.LatestVersion} 已以管理员身份打开。"
: $"{card.Name} {card.LatestVersion} 已在独立窗口中打开。";
RecentUsageIconCache.Remember(RecentUsageKind.Tool, card.Id, card.IconSource);
RecentUsageService.RecordTool(card.Package);
activity.Succeed(StatusText.Text);
return true;
}
catch (OperationCanceledException)
{
card.CacheState = "已取消";
StatusText.Text = $"已取消打开 {card.Name}。";
activity.Cancel(StatusText.Text);
return false;
}
catch (Exception exception)
{
card.CacheState = cachePath is not null && File.Exists(cachePath) ? "重试打开" : "重试获取";
StatusText.Text = $"打开失败:{exception.Message}";
activity.Fail(StatusText.Text);
return false;
}
finally
{
card.IsDownloading = false;
card.IsEnabled = true;
if (temporaryPath is not null && File.Exists(temporaryPath))
File.Delete(temporaryPath);
}
}
private static string GetCachePath(ToolPackageSummary tool) => Path.Combine(
AppPath.CacheProfile,
"ToolPackages",
tool.Id,
$"{tool.LatestVersion}.xfetool");
private static async Task<bool> IsCachedPackageValidAsync(string path, string expectedSha256)
{
if (!File.Exists(path)) return false;
await using var stream = new FileStream(
path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920,
FileOptions.Asynchronous | FileOptions.SequentialScan);
using var sha256 = SHA256.Create();
var actual = Convert.ToHexString(await sha256.ComputeHashAsync(stream)).ToLowerInvariant();
return string.Equals(actual, expectedSha256, StringComparison.OrdinalIgnoreCase);
}
private static string FormatDataSize(long bytes)
{
if (bytes < 1024) return $"{bytes} B";
if (bytes < 1024 * 1024) return $"{bytes / 1024d:F1} KB";
if (bytes < 1024L * 1024 * 1024) return $"{bytes / 1024d / 1024:F1} MB";
return $"{bytes / 1024d / 1024 / 1024:F1} GB";
}
private static ImageSource CreateIconSource(string? dataUrl)
{
if (string.IsNullOrWhiteSpace(dataUrl) || !dataUrl.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase))
return DefaultToolIcon;
try
{
var separator = dataUrl.IndexOf(',');
if (separator < 0) return DefaultToolIcon;
var header = dataUrl[5..separator];
var mediaType = header.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();
var bytes = Convert.FromBase64String(dataUrl[(separator + 1)..]);
return WebImageSourceLoader.Decode(bytes, mediaType, "tool-icon");
}
catch
{
return DefaultToolIcon;
}
}
private static ImageSource CreateDefaultIcon()
{
var assemblyName = typeof(ToolBoxPage).Assembly.GetName().Name;
var image = new BitmapImage(new Uri(
$"pack://application:,,,/{assemblyName};component/Resources/Image/default_tool_icon.png",
UriKind.Absolute));
image.Freeze();
return image;
}
}