Update application UI and functionality

This commit is contained in:
2026-07-26 16:20:36 +08:00
parent b9aff58f32
commit 97ea6fb7aa
48 changed files with 2790 additions and 628 deletions
@@ -71,10 +71,12 @@ public sealed class HardwareInfoService(ILogService? logService = null) : IHardw
{
try
{
var videoControllers = QueryVideoControllers();
var primaryGpu = SelectPrimaryGpu(videoControllers);
var summary = new HardwareInfoSummary(
global::System.Runtime.InteropServices.RuntimeInformation.OSDescription,
FirstWmiValue("Win32_Processor", "Name") ?? $"{Environment.ProcessorCount} logical processors",
FirstWmiValue("Win32_VideoController", "Name") ?? "Unknown GPU",
primaryGpu?.Name ?? "Unknown GPU",
FormatMemory(FirstWmiUlong("Win32_ComputerSystem", "TotalPhysicalMemory")),
BuildDiskSummary(),
BuildBoardSummary(),
@@ -107,8 +109,7 @@ public sealed class HardwareInfoService(ILogService? logService = null) : IHardw
var devices = new List<HardwareInventoryDevice>();
devices.AddRange(TryQueryDevices("Processor", "Win32_Processor", null,
"Name", "Manufacturer", "ProcessorId", "DeviceID", "NumberOfCores", "NumberOfLogicalProcessors", "MaxClockSpeed", "Status"));
devices.AddRange(TryQueryDevices("Graphics", "Win32_VideoController", null,
"Name", "AdapterCompatibility", "PNPDeviceID", "DriverVersion", "AdapterRAM", "VideoModeDescription", "Status"));
devices.AddRange(QueryVideoControllers());
devices.AddRange(TryQueryDevices("Memory", "Win32_PhysicalMemory", null,
"Manufacturer", "PartNumber", "SerialNumber", "Capacity", "Speed", "ConfiguredClockSpeed", "DeviceLocator", "BankLabel", "Status"));
devices.AddRange(TryQueryDevices("Storage", "Win32_DiskDrive", null,
@@ -124,6 +125,8 @@ public sealed class HardwareInfoService(ILogService? logService = null) : IHardw
bool.TryParse(physical, out var isPhysical) && isPhysical)
.GroupBy(device => device.Id, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(device => InventoryCategorySortIndex(device.Category))
.ThenByDescending(device => device.Category == "Graphics" ? GpuScore(device) : 0)
.ToList();
await WriteLogAsync(
@@ -136,6 +139,104 @@ public sealed class HardwareInfoService(ILogService? logService = null) : IHardw
}, cancellationToken).ConfigureAwait(false);
}
public static HardwareInventoryDevice? SelectPrimaryGpu(IEnumerable<HardwareInventoryDevice> videoControllers)
{
return videoControllers
.Where(device => string.Equals(device.Category, "Graphics", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(GpuScore)
.ThenBy(device => device.Name, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
}
[SupportedOSPlatform("windows")]
private static IReadOnlyList<HardwareInventoryDevice> QueryVideoControllers()
{
return TryQueryDevices("Graphics", "Win32_VideoController", null,
"Name", "AdapterCompatibility", "PNPDeviceID", "DriverVersion", "AdapterRAM", "VideoModeDescription", "Status");
}
private static int GpuScore(HardwareInventoryDevice device)
{
var text = string.Join(' ', new[]
{
device.Name,
device.Manufacturer,
device.Model,
Property(device, "AdapterCompatibility"),
Property(device, "PNPDeviceID")
}).ToLowerInvariant();
var score = 0;
if (device.Status.Equals("OK", StringComparison.OrdinalIgnoreCase) ||
device.Status.Equals("Detected", StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
if (text.Contains("microsoft basic") || text.Contains("remote display") ||
text.Contains("indirect display") || text.Contains("virtual display"))
{
score -= 1200;
}
if (text.Contains("nvidia") || text.Contains("geforce") || text.Contains("quadro"))
{
score += 600;
}
else if (text.Contains("intel") && text.Contains("arc"))
{
score += 560;
}
else if (text.Contains("radeon rx") || text.Contains("radeon pro") || text.Contains("firepro"))
{
score += 540;
}
else if (text.Contains("amd") || text.Contains("radeon"))
{
score += 260;
}
if (text.Contains("intel uhd") || text.Contains("intel iris") || text.Contains("intel(r) hd") ||
text.Contains("intel hd graphics") || text.Contains("radeon graphics") || text.Contains("radeon vega"))
{
score -= 300;
}
var pnpId = Property(device, "PNPDeviceID");
if (pnpId.StartsWith("PCI\\", StringComparison.OrdinalIgnoreCase))
{
score += 80;
}
else if (pnpId.StartsWith("ROOT\\", StringComparison.OrdinalIgnoreCase) ||
pnpId.StartsWith("SWD\\", StringComparison.OrdinalIgnoreCase))
{
score -= 180;
}
if (ulong.TryParse(Property(device, "AdapterRAM"), out var adapterRam) && adapterRam > 0)
{
score += Math.Min(160, (int)(adapterRam / (1024UL * 1024 * 1024)) * 20);
}
return score;
}
private static string Property(HardwareInventoryDevice device, string name)
{
return device.Properties.TryGetValue(name, out var value) ? value : string.Empty;
}
private static int InventoryCategorySortIndex(string category) => category switch
{
"Processor" => 0,
"Graphics" => 1,
"Memory" => 2,
"Storage" => 3,
"Display" => 4,
"Network" => 5,
_ => 6
};
[SupportedOSPlatform("windows")]
private static IReadOnlyList<HardwareInventoryDevice> TryQueryDevices(
string category,
+28 -4
View File
@@ -2,6 +2,13 @@ namespace YMhut.Box.Core.Tools;
public sealed class ToolCatalog
{
private static readonly HashSet<string> ToolboxNativeSurfaceIds = new(StringComparer.OrdinalIgnoreCase)
{
"hardware",
"optimization",
"music"
};
private readonly List<IToolModule> _modules;
public ToolCatalog(IEnumerable<IToolModule>? modules = null)
@@ -39,14 +46,25 @@ public sealed class ToolCatalog
public static IEnumerable<IToolModule> DefaultModules()
{
return RawToolData
return SortForDisplay(RawToolData
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(ParseModule)
.Concat([DevEnvironmentConfigModule()])
.OrderBy(module => CategorySortIndex(module.Metadata.Category))
.Concat([DevEnvironmentConfigModule()]));
}
public static IOrderedEnumerable<IToolModule> SortForDisplay(
IEnumerable<IToolModule> modules,
Func<IToolModule, int>? secondaryGroup = null)
{
return modules
.OrderByDescending(module => module.Metadata.AddedOrder)
.ThenBy(module => secondaryGroup?.Invoke(module) ?? 0)
.ThenBy(module => CategorySortIndex(module.Metadata.Category))
.ThenBy(module => module.Metadata.Name, StringComparer.CurrentCulture);
}
public static bool IsToolboxNativeSurface(string id) => ToolboxNativeSurfaceIds.Contains(id);
private static IToolModule DevEnvironmentConfigModule()
{
return new ToolModule(new ToolMetadata(
@@ -71,8 +89,11 @@ public sealed class ToolCatalog
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var addedOrder = int.TryParse(parts.ElementAtOrDefault(6), out var parsedAddedOrder)
? parsedAddedOrder
: 0;
return new ToolModule(new ToolMetadata(id, name, description, category, keywords, offline, IconForCategory(category)));
return new ToolModule(new ToolMetadata(id, name, description, category, keywords, offline, IconForCategory(category), addedOrder));
}
private static ToolCategory ParseCategory(string? value)
@@ -133,6 +154,9 @@ public sealed class ToolCatalog
}
private const string RawToolData = """
hardware system true ;;;;CPU;GPU 300
optimization system true ;;;; 290
music network false ;;;; 280
html_js_playground HTML/JS HTMLCSSJS dev false html;js;webview;plugin;playground
json_formatter JSON JSON dev true json;format;pretty;minify
base64_codec Base64 Base64 UTF-8 dev true base64;encode;decode
+2 -1
View File
@@ -7,4 +7,5 @@ public sealed record ToolMetadata(
ToolCategory Category,
IReadOnlyList<string> Keywords,
bool OfflineCapable,
string IconGlyph);
string IconGlyph,
int AddedOrder = 0);
@@ -9,19 +9,19 @@ public readonly record struct ToolboxGridLayout(
public static class ToolboxLayoutCalculator
{
public const double MinCardWidth = 264;
public const double MaxCardWidth = 360;
public const double ItemGap = 14;
public const double MinCardWidth = 236;
public const double MaxCardWidth = 340;
public const double ItemGap = 12;
public static ToolboxGridLayout Calculate(double availableWidth)
{
if (double.IsNaN(availableWidth) || double.IsInfinity(availableWidth) || availableWidth <= 0)
{
return new ToolboxGridLayout(3, 318, 332, ShowCategoryRail: true, IsCompact: false);
return new ToolboxGridLayout(3, 286, 298, ShowCategoryRail: true, IsCompact: false);
}
var showCategoryRail = availableWidth >= 980;
var usable = Math.Max(MinCardWidth, availableWidth - 28);
var showCategoryRail = availableWidth >= 1180;
var usable = Math.Max(MinCardWidth, availableWidth - 24);
var columns = ColumnsFor(usable);
var cardWidth = Math.Floor((usable - (columns - 1) * ItemGap) / columns);
cardWidth = Math.Clamp(cardWidth, MinCardWidth, MaxCardWidth);
@@ -36,27 +36,27 @@ public static class ToolboxLayoutCalculator
private static int ColumnsFor(double usableWidth)
{
if (usableWidth < 620)
if (usableWidth < 560)
{
return 1;
}
if (usableWidth < 900)
if (usableWidth < 820)
{
return 2;
}
if (usableWidth < 1220)
if (usableWidth < 1120)
{
return 3;
}
if (usableWidth < 1540)
if (usableWidth < 1420)
{
return 4;
}
if (usableWidth < 1880)
if (usableWidth < 1720)
{
return 5;
}
+12
View File
@@ -0,0 +1,12 @@
<Application
x:Class="YMhut.Box.InstallerBootstrap.InstallerApp"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
@@ -2,7 +2,7 @@ using Microsoft.UI.Xaml;
namespace YMhut.Box.InstallerBootstrap;
public sealed class InstallerApp : Application
public sealed partial class InstallerApp : Application
{
private readonly string[] _arguments;
private Window? _window;
@@ -10,11 +10,55 @@ public sealed class InstallerApp : Application
public InstallerApp(string[] arguments)
{
_arguments = arguments;
InitializeComponent();
UnhandledException += (_, args) =>
{
InstallerDiagnostics.Write(args.Exception, "Application.UnhandledException");
args.Handled = true;
};
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
_window = new InstallerWindow(InstallerOptions.Parse(_arguments));
_window.Activate();
if (_arguments.Any(argument => argument.Equals("/WINDOWSELFTEST", StringComparison.OrdinalIgnoreCase)))
{
_ = CompleteWindowSelfTestAsync((InstallerWindow)_window);
}
}
private static async Task CompleteWindowSelfTestAsync(InstallerWindow window)
{
var success = await window.RunWindowNavigationSelfTestAsync();
window.Close();
Environment.Exit(success ? 0 : 4);
}
}
internal static class InstallerDiagnostics
{
private static int _errorCount;
internal static string LogPath { get; } = Path.Combine(
Path.GetTempPath(),
"YMhutBox",
"installer-bootstrap.log");
internal static int ErrorCount => Volatile.Read(ref _errorCount);
internal static void Write(Exception exception, string context)
{
Interlocked.Increment(ref _errorCount);
try
{
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
File.AppendAllText(
LogPath,
$"[{DateTimeOffset.Now:O}] {context}{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}");
}
catch
{
}
}
}
@@ -54,15 +54,16 @@ public static class InstallerEngine
public const long RequiredInstallBytes = 700L * 1024 * 1024;
private static readonly Lazy<ResolvedEngine?> ResolvedEngineCache = new(ResolveEngineCore, LazyThreadSafetyMode.ExecutionAndPublication);
private sealed record ResolvedEngine(string Path, string? Sha256);
private sealed record ResolvedEngine(string Path);
public static bool SelfTest()
{
try
{
var engine = ResolveEngine();
if (engine is null || !IsPortableExecutable(engine.Path)) return false;
return engine.Sha256 is null || FileMatches(engine.Path, new FileInfo(engine.Path).Length, engine.Sha256);
// Embedded engines are hash-verified during atomic extraction. Re-hashing the
// complete installer here doubles preflight time on slower storage.
return engine is not null && IsPortableExecutable(engine.Path);
}
catch
{
@@ -95,10 +96,21 @@ public static class InstallerEngine
{
var engine = ResolveEngine();
if (engine is null) return 3;
var process = Start(engine.Path, arguments, elevate: false);
if (process is null) return 4;
await process.WaitForExitAsync().ConfigureAwait(false);
return process.ExitCode;
try
{
var options = InstallerOptions.Parse(arguments);
var process = Start(engine.Path, arguments, RequiresElevationForInstallation(options.InstallDirectory));
if (process is null) return 4;
using (process)
{
await process.WaitForExitAsync().ConfigureAwait(false);
return process.ExitCode;
}
}
catch (Win32Exception exception) when (exception.NativeErrorCode == 1223)
{
return 1223;
}
}
public static async Task<InstallerResult> InstallAsync(
@@ -106,7 +118,9 @@ public static class InstallerEngine
IProgress<InstallerProgress>? progress = null,
CancellationToken cancellationToken = default)
{
var engine = ResolveEngine();
// Hashing and extracting the embedded engine can take noticeable time on slow disks.
// Keep that work off the WinUI dispatcher before starting the Inno process.
var engine = await Task.Run(ResolveEngine, cancellationToken).ConfigureAwait(false);
if (engine is null) return new(false, 3, string.Empty, "安装引擎 YMhutBox.Engine.exe 不存在。");
var logRoot = Path.Combine(Path.GetTempPath(), "YMhutBoxSetup");
Directory.CreateDirectory(logRoot);
@@ -124,17 +138,27 @@ public static class InstallerEngine
$"/TASKS={string.Join(',', tasks)}",
$"/LOG={logPath}"
};
progress?.Report(new(null, "准备安装", "正在启动成熟的 Inno 安装引擎。"));
var prerequisites = GetPrerequisiteStatus();
var missingPrerequisites = new List<string>();
if (!prerequisites.WebView2Installed) missingPrerequisites.Add("WebView2 Runtime");
if (!prerequisites.VcRuntimeInstalled) missingPrerequisites.Add("Visual C++ x64 Runtime");
progress?.Report(missingPrerequisites.Count == 0
? new(null, "准备安装", "前置组件已就绪,正在启动安装引擎。")
: new(null, "检查前置组件", $"将下载并安装:{string.Join("", missingPrerequisites)}。"));
Process? process = null;
Task monitor = Task.CompletedTask;
try
{
process = Start(engine.Path, arguments, RequiresElevation(options.InstallDirectory));
process = Start(engine.Path, arguments, RequiresElevationForInstallation(options.InstallDirectory, prerequisites));
if (process is null) return new(false, 4, logPath, "无法启动安装引擎。");
monitor = MonitorLogAsync(logPath, process, progress, cancellationToken);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
await monitor.ConfigureAwait(false);
if (process.ExitCode != 0) return new(false, process.ExitCode, logPath, $"安装引擎退出代码 {process.ExitCode}。");
if (process.ExitCode != 0)
{
var failure = ReadInstallerFailure(logPath) ?? $"安装引擎退出代码 {process.ExitCode}。";
return new(false, process.ExitCode, logPath, failure);
}
progress?.Report(new(100, "安装完成", "程序文件、快捷方式和运行库检查已完成。"));
return new(true, 0, logPath);
}
@@ -203,17 +227,43 @@ public static class InstallerEngine
return null;
}
public static InstallerPrerequisiteStatus GetPrerequisiteStatus()
{
var webView2Version = FindWebView2Version();
var (vcRuntimeInstalled, vcRuntimeVersion) = FindVcRuntime();
return new InstallerPrerequisiteStatus(
!string.IsNullOrWhiteSpace(webView2Version),
webView2Version,
vcRuntimeInstalled,
vcRuntimeVersion);
}
public static InstallerPreflightReport BuildPreflightReport(string targetDirectory)
{
var existing = DetectExistingInstallInfo();
var packageVersion = GetPackageVersion();
var plan = InstallerModeResolver.Resolve(packageVersion, existing);
var prerequisites = GetPrerequisiteStatus();
var items = new List<PreflightItem>
{
InstallerModeResolver.EvaluatePlatform(
OperatingSystem.IsWindows(),
Environment.OSVersion.Version,
System.Runtime.InteropServices.RuntimeInformation.OSArchitecture),
new(
"webview2",
"Microsoft Edge WebView2 Runtime",
prerequisites.WebView2Installed ? PreflightStatus.Passed : PreflightStatus.Warning,
prerequisites.WebView2Installed
? $"已安装 {prerequisites.WebView2Version ?? ""}。"
: "未检测到;开始安装时将从微软官方地址下载并静默安装。"),
new(
"vcruntime",
"Microsoft Visual C++ x64 Runtime",
prerequisites.VcRuntimeInstalled ? PreflightStatus.Passed : PreflightStatus.Warning,
prerequisites.VcRuntimeInstalled
? $"已安装 {prerequisites.VcRuntimeVersion ?? ""}。"
: "未检测到;开始安装时将从微软官方地址下载并静默安装。"),
EvaluateDirectory(targetDirectory),
SelfTest()
? new PreflightItem("engine", "安装引擎完整性", PreflightStatus.Passed, "嵌入引擎已通过哈希命名释放和 PE 完整性检查。")
@@ -244,6 +294,72 @@ public static class InstallerEngine
return new InstallerPreflightReport(plan, targetDirectory, RequiredInstallBytes, available, userData, items);
}
private static string? FindWebView2Version()
{
const string clients = @"SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
const string state = @"SOFTWARE\Microsoft\EdgeUpdate\ClientState\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
foreach (var hive in new[] { Microsoft.Win32.RegistryHive.LocalMachine, Microsoft.Win32.RegistryHive.CurrentUser })
{
foreach (var view in new[] { Microsoft.Win32.RegistryView.Registry64, Microsoft.Win32.RegistryView.Registry32 })
{
try
{
using var root = Microsoft.Win32.RegistryKey.OpenBaseKey(hive, view);
foreach (var path in new[] { clients, state })
{
using var key = root.OpenSubKey(path);
var version = key?.GetValue("pv")?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(version) && version != "0.0.0.0") return version;
}
}
catch
{
}
}
}
foreach (var root in new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "EdgeWebView", "Application"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Microsoft", "EdgeWebView", "Application")
})
{
try
{
if (!Directory.Exists(root)) continue;
foreach (var directory in Directory.EnumerateDirectories(root).OrderByDescending(path => path, StringComparer.OrdinalIgnoreCase))
{
var executable = Path.Combine(directory, "msedgewebview2.exe");
if (!File.Exists(executable)) continue;
return FileVersionInfo.GetVersionInfo(executable).FileVersion ?? Path.GetFileName(directory);
}
}
catch
{
}
}
return null;
}
private static (bool Installed, string? Version) FindVcRuntime()
{
const string path = @"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64";
foreach (var view in new[] { Microsoft.Win32.RegistryView.Registry64, Microsoft.Win32.RegistryView.Registry32 })
{
try
{
using var root = Microsoft.Win32.RegistryKey.OpenBaseKey(Microsoft.Win32.RegistryHive.LocalMachine, view);
using var key = root.OpenSubKey(path);
var installed = Convert.ToInt32(key?.GetValue("Installed") ?? 0) == 1;
if (installed) return (true, key?.GetValue("Version")?.ToString()?.Trim());
}
catch
{
}
}
return (false, null);
}
private static PreflightItem EvaluateDirectory(string targetDirectory)
{
try
@@ -307,40 +423,93 @@ public static class InstallerEngine
while (!process.HasExited)
{
cancellationToken.ThrowIfCancellationRequested();
position = await ReadAvailableLogAsync(path, position, progress, cancellationToken).ConfigureAwait(false);
var update = await ReadAvailableLogAsync(path, position, cancellationToken).ConfigureAwait(false);
position = update.Position;
foreach (var item in update.Progress) progress?.Report(item);
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
}
await ReadAvailableLogAsync(path, position, progress, cancellationToken).ConfigureAwait(false);
var finalUpdate = await ReadAvailableLogAsync(path, position, cancellationToken).ConfigureAwait(false);
foreach (var item in finalUpdate.Progress) progress?.Report(item);
}
private static async Task<long> ReadAvailableLogAsync(
private static async Task<(long Position, IReadOnlyList<InstallerProgress> Progress)> ReadAvailableLogAsync(
string path,
long position,
IProgress<InstallerProgress>? progress,
CancellationToken cancellationToken)
{
if (!File.Exists(path)) return position;
if (!File.Exists(path)) return (position, []);
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
if (position > stream.Length) position = 0;
stream.Position = position;
using var reader = new StreamReader(stream);
var updates = new List<InstallerProgress>();
while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line)
{
var phase = Classify(line);
if (phase is not null) progress?.Report(phase);
if (phase is not null && (updates.Count == 0 || updates[^1] != phase)) updates.Add(phase);
}
return stream.Position;
return (stream.Position, updates);
}
private static InstallerProgress? Classify(string line)
{
const string eventMarker = "YMHUT_EVENT:";
var eventIndex = line.IndexOf(eventMarker, StringComparison.Ordinal);
if (eventIndex >= 0)
{
var payload = line[(eventIndex + eventMarker.Length)..].Trim();
var fields = payload.Split('|', 3, StringSplitOptions.TrimEntries);
if (fields.Length >= 2 && fields[0].Equals("STAGE", StringComparison.OrdinalIgnoreCase))
{
return fields[1].ToLowerInvariant() switch
{
"prerequisites" => new(null, "检查前置组件", "正在检查 WebView2 与 Visual C++ 运行库。"),
"files" => new(0, "写入程序文件", "正在准备写入 YMhut Box 程序文件。"),
"shortcuts" => new(null, "创建快捷方式", "正在创建开始菜单、桌面和自启动入口。"),
"finalize" => new(null, "完成配置", "正在迁移用户数据并完成安装配置。"),
_ => null
};
}
if (fields.Length >= 3 && fields[0].Equals("DEPENDENCY", StringComparison.OrdinalIgnoreCase))
{
var dependencyDetail = fields[1].ToLowerInvariant() switch
{
"ready" => $"{fields[2]} 已就绪。",
"download" => $"正在从官方来源下载 {fields[2]}。",
"retry" => $"下载中断,正在重试 {fields[2]}。",
"bundled" => $"正在使用安装包内置的 {fields[2]}。",
"install" => $"正在安装 {fields[2]}。",
"complete" => $"{fields[2]} 安装完成。",
_ => fields[2]
};
return new(null, "检查前置组件", dependencyDetail);
}
if (fields.Length >= 2 && fields[0].Equals("PROGRESS", StringComparison.OrdinalIgnoreCase) &&
double.TryParse(fields[1], out var percent))
{
var fileName = fields.Length >= 3 ? Path.GetFileName(fields[2].Trim('"')) : string.Empty;
var fileDetail = string.IsNullOrWhiteSpace(fileName)
? "正在写入程序文件。"
: $"正在写入 {fileName}";
return new(Math.Clamp(percent, 0, 100), "写入程序文件", fileDetail);
}
if (fields.Length >= 2 && fields[0].Equals("FAILURE", StringComparison.OrdinalIgnoreCase))
{
return new(null, "前置组件安装失败", LimitError(fields[1]));
}
}
var detail = LimitError(line);
if (line.Contains("Preparing to install", StringComparison.OrdinalIgnoreCase) || line.Contains("Prepare system prerequisites", StringComparison.OrdinalIgnoreCase))
return new(null, "检查环境", detail);
if (line.Contains("Extracting", StringComparison.OrdinalIgnoreCase))
return new(null, "写入程序文件", detail);
if (line.Contains("Creating", StringComparison.OrdinalIgnoreCase))
if (line.Contains("Creating shortcut", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Creating shortcuts", StringComparison.OrdinalIgnoreCase))
return new(null, "创建快捷方式", detail);
if (line.Contains("Finalizing installation", StringComparison.OrdinalIgnoreCase))
return new(null, "完成配置", detail);
@@ -351,6 +520,26 @@ public static class InstallerEngine
return null;
}
private static string? ReadInstallerFailure(string path)
{
const string marker = "YMHUT_EVENT:FAILURE|";
try
{
string? failure = null;
foreach (var line in File.ReadLines(path))
{
var index = line.IndexOf(marker, StringComparison.Ordinal);
if (index < 0) continue;
failure = LimitError(line[(index + marker.Length)..].Trim());
}
return failure;
}
catch
{
return null;
}
}
private static Process? Start(string engine, IEnumerable<string> arguments, bool elevate)
{
var startInfo = new ProcessStartInfo
@@ -377,10 +566,10 @@ public static class InstallerEngine
}
var direct = Path.Combine(AppContext.BaseDirectory, "YMhutBox.Engine.exe");
if (File.Exists(direct)) return new ResolvedEngine(direct, null);
if (File.Exists(direct)) return new ResolvedEngine(direct);
var adjacent = Directory.EnumerateFiles(AppContext.BaseDirectory, "YMhut_Box_WinUI_Setup_*.exe")
.FirstOrDefault(path => !string.Equals(path, Environment.ProcessPath, StringComparison.OrdinalIgnoreCase));
return adjacent is null ? null : new ResolvedEngine(adjacent, null);
return adjacent is null ? null : new ResolvedEngine(adjacent);
}
private static ResolvedEngine ExtractEmbeddedEngine(Stream resource)
@@ -419,7 +608,7 @@ public static class InstallerEngine
{
throw new InvalidDataException("嵌入安装引擎的 SHA-256 校验失败。");
}
return new ResolvedEngine(extracted, hash);
return new ResolvedEngine(extracted);
}
private static bool FileMatches(string path, long length, string sha256)
@@ -451,6 +640,17 @@ public static class InstallerEngine
full.StartsWith(Environment.GetFolderPath(Environment.SpecialFolder.Windows), StringComparison.OrdinalIgnoreCase);
}
private static bool RequiresElevationForInstallation(
string directory,
InstallerPrerequisiteStatus? prerequisites = null)
{
if (IsAdministrator()) return false;
prerequisites ??= GetPrerequisiteStatus();
return RequiresElevation(directory) ||
!prerequisites.WebView2Installed ||
!prerequisites.VcRuntimeInstalled;
}
private static bool IsAdministrator()
{
using var identity = WindowsIdentity.GetCurrent();
@@ -41,6 +41,12 @@ public sealed record PreflightItem(
PreflightStatus Status,
string Detail);
public sealed record InstallerPrerequisiteStatus(
bool WebView2Installed,
string? WebView2Version,
bool VcRuntimeInstalled,
string? VcRuntimeVersion);
public sealed record InstallerPreflightReport(
InstallerPlan Plan,
string TargetDirectory,
@@ -88,7 +94,12 @@ public static class InstallerModeResolver
return new("platform", "Windows 版本与架构", PreflightStatus.Failed, "需要 Windows 10 1809(内部版本 17763)或更高版本。");
}
return new("platform", "Windows 版本与架构", PreflightStatus.Passed, $"Windows {osVersion} · x64");
var productName = osVersion.Build >= 22000 ? "Windows 11" : "Windows 10";
return new(
"platform",
"Windows 版本与架构",
PreflightStatus.Passed,
$"{productName} · 内部版本 {osVersion.Build}.{osVersion.Revision} · x64");
}
public static PreflightItem EvaluateDiskSpace(long availableBytes, long requiredBytes)
@@ -1,10 +1,12 @@
using System.Diagnostics;
using Microsoft.UI;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Text;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.UI.Text;
using Windows.Graphics;
using Windows.Storage.Pickers;
@@ -14,15 +16,20 @@ namespace YMhut.Box.InstallerBootstrap;
public sealed class InstallerWindow : Window
{
private static readonly SolidColorBrush BackgroundBrush = Brush("#F5F6F7");
private static readonly SolidColorBrush SurfaceBrush = Brush("#FFFFFF");
private static readonly SolidColorBrush StrokeBrush = Brush("#DDE1E4");
private static readonly SolidColorBrush TextBrush = Brush("#1B1F22");
private static readonly SolidColorBrush SecondaryBrush = Brush("#626A70");
private static readonly SolidColorBrush AccentBrush = Brush("#087F72");
private static readonly SolidColorBrush BackgroundBrush = Brush("#DDF4F5F7");
private static readonly SolidColorBrush SurfaceBrush = Brush("#EBFFFFFF");
private static readonly SolidColorBrush StrokeBrush = Brush("#34747C88");
private static readonly SolidColorBrush TextBrush = Brush("#161A21");
private static readonly SolidColorBrush SecondaryBrush = Brush("#5D6673");
private static readonly SolidColorBrush AccentBrush = Brush("#2563D9");
private static readonly SolidColorBrush WhiteBrush = Brush("#FFFFFF");
private readonly ContentControl _pageHost = new();
private readonly Border _titleDragArea = new();
private readonly StackPanel _stepList = new() { Spacing = 3 };
private readonly StackPanel _preflightPanel = new() { Spacing = 12 };
private readonly StackPanel _completionActions = new() { Spacing = 10 };
private readonly InfoBar _navigationError = new() { IsOpen = false, IsClosable = true, Severity = InfoBarSeverity.Error };
private readonly UIElement?[] _pages = new UIElement?[7];
private readonly Button _backButton;
private readonly Button _nextButton;
private readonly Button _cancelButton;
@@ -33,31 +40,53 @@ public sealed class InstallerWindow : Window
private readonly CheckBox _startMenuShortcut = new() { Content = "创建开始菜单快捷方式", IsChecked = true };
private readonly CheckBox _autoStart = new() { Content = "登录 Windows 后自动启动" };
private readonly CheckBox _launchAfterInstall = new() { Content = "安装完成后启动 YMhut Box", IsChecked = true };
private readonly ProgressBar _progress = new() { Height = 5, IsIndeterminate = true };
private readonly ProgressBar _progress = new() { Height = 7, IsIndeterminate = true };
private readonly TextBlock _phase = Text("等待开始", 18, FontWeights.SemiBold);
private readonly TextBlock _detail = Text("安装尚未开始。", 12, foreground: SecondaryBrush, maxLines: 3);
private readonly TextBox _installLog = new() { IsReadOnly = true, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, MinHeight = 220 };
private readonly TextBlock _progressValue = Text("准备中", 12, FontWeights.SemiBold, AccentBrush);
private readonly TextBlock _latestOutput = Text("尚无安装输出", 11.5, foreground: SecondaryBrush, maxLines: 1);
private readonly TextBox _installLog = new()
{
IsReadOnly = true,
AcceptsReturn = true,
TextWrapping = TextWrapping.NoWrap,
FontFamily = new FontFamily("Cascadia Mono"),
FontSize = 11,
Height = 116
};
private readonly Expander _installLogExpander = new() { IsExpanded = false };
private readonly List<InstallStageView> _installStageViews = [];
private readonly TextBlock _completionTitle = Text("安装完成", 28, FontWeights.SemiBold);
private readonly TextBlock _completionDetail = Text("YMhut Box 已准备就绪。", 13, foreground: SecondaryBrush, maxLines: 4);
private readonly FontIcon _completionIcon = new() { Glyph = "\uE73E", FontSize = 46, Foreground = AccentBrush };
private InstallerOptions _options;
private int _step;
private bool _installing;
private bool _installed;
private string? _logPath;
private CancellationTokenSource? _installCts;
private CancellationTokenSource? _diskCheckCts;
private InstallerPreflightReport? _preflightReport;
private AppWindow? _appWindow;
private int _diskCheckVersion;
private int _preflightCheckVersion;
private bool _preflightChecking;
private int _activeInstallStage;
private int _installLogEntries;
private string? _lastInstallLogDetail;
public InstallerWindow(InstallerOptions options)
{
_options = options;
Title = "YMhut Box 安装程序";
_backButton = CommandButton("上一步", "\uE72B", () => Move(-1));
_nextButton = CommandButton("下一步", "\uE72A", async () => await MoveNextAsync(), primary: true);
_nextButton = CommandButton("下一步", "\uE72A", MoveNextAsync, primary: true);
_cancelButton = CommandButton("取消", "\uE711", CancelOrClose);
Content = BuildShell();
ExtendsContentIntoTitleBar = true;
SetTitleBar(_titleDragArea);
_directory.Text = InstallerEngine.DetectExistingInstall() ?? options.InstallDirectory;
_directory.TextChanged += (_, _) => RefreshDiskSpace();
_directory.TextChanged += (_, _) => ScheduleDiskSpaceRefresh();
_licenseAccepted.Checked += (_, _) => RefreshButtons();
_licenseAccepted.Unchecked += (_, _) => RefreshButtons();
Activated += (_, _) => ConfigureWindow();
@@ -67,9 +96,27 @@ public sealed class InstallerWindow : Window
private void ConfigureWindow()
{
if (_appWindow is not null) return;
try
{
SystemBackdrop = new MicaBackdrop { Kind = MicaKind.BaseAlt };
}
catch
{
SystemBackdrop = null;
}
var hwnd = WindowNative.GetWindowHandle(this);
_appWindow = AppWindow.GetFromWindowId(Win32Interop.GetWindowIdFromWindow(hwnd));
_appWindow.Resize(new SizeInt32(920, 640));
if (AppWindowTitleBar.IsCustomizationSupported())
{
_appWindow.TitleBar.ExtendsContentIntoTitleBar = true;
_appWindow.TitleBar.ButtonBackgroundColor = Colors.Transparent;
_appWindow.TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
_appWindow.TitleBar.ButtonForegroundColor = Colors.White;
_appWindow.TitleBar.ButtonInactiveForegroundColor = ColorHelper.FromArgb(255, 172, 178, 188);
_appWindow.TitleBar.ButtonHoverBackgroundColor = ColorHelper.FromArgb(255, 55, 60, 69);
_appWindow.TitleBar.ButtonPressedBackgroundColor = ColorHelper.FromArgb(255, 70, 76, 88);
}
if (_appWindow.Presenter is OverlappedPresenter presenter)
{
presenter.IsResizable = true;
@@ -87,11 +134,18 @@ public sealed class InstallerWindow : Window
private UIElement BuildShell()
{
var root = new Grid { Background = BackgroundBrush };
root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(224) });
root.ColumnDefinitions.Add(new ColumnDefinition());
root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(42) });
root.RowDefinitions.Add(new RowDefinition());
root.Children.Add(BuildTitleBar());
var body = new Grid();
body.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(224) });
body.ColumnDefinitions.Add(new ColumnDefinition());
var sidebar = new Border
{
Background = Brush("#17201F"),
Background = Brush("#D91D2026"),
BorderBrush = Brush("#385A6270"),
BorderThickness = new Thickness(0, 0, 1, 0),
Padding = new Thickness(20, 28, 16, 22),
Child = new Grid
{
@@ -109,11 +163,19 @@ public sealed class InstallerWindow : Window
}
}
};
root.Children.Add(sidebar);
body.Children.Add(sidebar);
var main = new Grid { Padding = new Thickness(34, 28, 34, 24) };
main.RowDefinitions.Add(new RowDefinition());
main.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
main.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
_pageHost.HorizontalContentAlignment = HorizontalAlignment.Stretch;
_pageHost.VerticalContentAlignment = VerticalAlignment.Stretch;
_pageHost.HorizontalAlignment = HorizontalAlignment.Stretch;
_pageHost.VerticalAlignment = VerticalAlignment.Stretch;
main.Children.Add(_pageHost);
_navigationError.Margin = new Thickness(0, 12, 0, 0);
Grid.SetRow(_navigationError, 1);
main.Children.Add(_navigationError);
var footer = new Grid { Margin = new Thickness(0, 18, 0, 0), ColumnSpacing = 8 };
footer.ColumnDefinitions.Add(new ColumnDefinition());
footer.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
@@ -125,13 +187,35 @@ public sealed class InstallerWindow : Window
footer.Children.Add(_backButton);
Grid.SetColumn(_nextButton, 2);
footer.Children.Add(_nextButton);
Grid.SetRow(footer, 1);
Grid.SetRow(footer, 2);
main.Children.Add(footer);
Grid.SetColumn(main, 1);
root.Children.Add(main);
body.Children.Add(main);
Grid.SetRow(body, 1);
root.Children.Add(body);
return root;
}
private UIElement BuildTitleBar()
{
_titleDragArea.Background = Brush("#E61D2026");
_titleDragArea.BorderBrush = Brush("#385A6270");
_titleDragArea.BorderThickness = new Thickness(0, 0, 0, 1);
_titleDragArea.Padding = new Thickness(16, 0, 148, 0);
_titleDragArea.Child = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 9,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
AppIcon(18),
Text("YMhut Box 安装程序", 13, FontWeights.SemiBold, WhiteBrush)
}
};
return _titleDragArea;
}
private UIElement SidebarBrand()
{
var panel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 11 };
@@ -140,8 +224,11 @@ public sealed class InstallerWindow : Window
Width = 38,
Height = 38,
CornerRadius = new CornerRadius(8),
Background = AccentBrush,
Child = new FontIcon { Glyph = "\uE943", Foreground = WhiteBrush, FontSize = 19 }
Background = Brush("#F2FFFFFF"),
BorderBrush = Brush("#4DFFFFFF"),
BorderThickness = new Thickness(1),
Padding = new Thickness(7),
Child = AppIcon(24)
});
panel.Children.Add(new StackPanel
{
@@ -149,7 +236,7 @@ public sealed class InstallerWindow : Window
Children =
{
Text("YMhut Box", 17, FontWeights.SemiBold, WhiteBrush),
Text("安装引导器", 11, foreground: Brush("#AFC1BE"))
Text("安装引导器", 11, foreground: Brush("#C2C8D1"))
}
});
return panel;
@@ -163,7 +250,7 @@ public sealed class InstallerWindow : Window
{
Padding = new Thickness(10, 8, 10, 8),
CornerRadius = new CornerRadius(6),
Child = Text(title, 13, FontWeights.SemiBold, Brush("#B9C7C5"), maxLines: 1)
Child = Text(title, 13, FontWeights.SemiBold, Brush("#C5CBD4"), maxLines: 1)
});
}
Grid.SetRow(_stepList, 1);
@@ -173,7 +260,7 @@ public sealed class InstallerWindow : Window
private UIElement SidebarFooter()
{
var footer = Text("Windows 10 1809+ · x64\nGPL-3.0", 11, foreground: Brush("#91A6A2"), maxLines: 2);
var footer = Text("YMhut Box\n安全安装向导", 11, foreground: Brush("#9FA7B3"), maxLines: 2);
Grid.SetRow(footer, 2);
return footer;
}
@@ -182,7 +269,7 @@ public sealed class InstallerWindow : Window
{
var existingInfo = InstallerEngine.DetectExistingInstallInfo();
var plan = InstallerModeResolver.Resolve(
InstallerEngine.GetPackageVersion(),
GetBootstrapVersion(),
existingInfo);
var existing = existingInfo?.InstallDirectory;
return PageStack(
@@ -197,20 +284,48 @@ public sealed class InstallerWindow : Window
private UIElement LicensePage()
{
var text = ReadEmbeddedText(
"YMhutBox.LICENSE.txt",
"YMhut Box is licensed under GNU General Public License v3.0.");
var license = new TextBox
"YMhutBox.EULA.txt",
"请在安装和使用 YMhut Box 前阅读并同意软件许可与服务协议。");
var document = Text(text, 12, foreground: TextBrush);
document.IsTextSelectionEnabled = true;
document.TextWrapping = TextWrapping.Wrap;
var license = new Border
{
Text = text,
IsReadOnly = true,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
MinHeight = 360,
FontFamily = new FontFamily("Cascadia Mono"),
FontSize = 11.5
Margin = new Thickness(0, 16, 0, 12),
Padding = new Thickness(18, 14, 6, 14),
CornerRadius = new CornerRadius(8),
Background = SurfaceBrush,
BorderBrush = StrokeBrush,
BorderThickness = new Thickness(1),
Child = new ScrollViewer
{
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
HorizontalContentAlignment = HorizontalAlignment.Stretch,
Padding = new Thickness(0, 0, 10, 0),
Content = document
}
};
ScrollViewer.SetVerticalScrollBarVisibility(license, ScrollBarVisibility.Auto);
return PageStack(Text("许可协议", 28, FontWeights.SemiBold), Text("继续安装前,请阅读开源许可。", 13, foreground: SecondaryBrush), license, _licenseAccepted);
_licenseAccepted.Content = "我已阅读并同意《YMhut Box 软件许可与服务协议》";
_licenseAccepted.HorizontalAlignment = HorizontalAlignment.Left;
_licenseAccepted.VerticalAlignment = VerticalAlignment.Center;
_licenseAccepted.MinHeight = 32;
var page = new Grid { HorizontalAlignment = HorizontalAlignment.Stretch, VerticalAlignment = VerticalAlignment.Stretch };
page.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
page.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
page.RowDefinitions.Add(new RowDefinition());
page.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
page.Children.Add(Text("软件许可与服务协议", 28, FontWeights.SemiBold));
var subtitle = Text("继续安装前,请阅读并确认以下条款。", 13, foreground: SecondaryBrush);
Grid.SetRow(subtitle, 1);
subtitle.Margin = new Thickness(0, 8, 0, 0);
page.Children.Add(subtitle);
Grid.SetRow(license, 2);
page.Children.Add(license);
Grid.SetRow(_licenseAccepted, 3);
page.Children.Add(_licenseAccepted);
return page;
}
private UIElement DirectoryPage()
@@ -225,7 +340,7 @@ public sealed class InstallerWindow : Window
Grid.SetColumn(browse, 1);
browse.VerticalAlignment = VerticalAlignment.Bottom;
row.Children.Add(browse);
RefreshDiskSpace();
ScheduleDiskSpaceRefresh(immediate: true);
return PageStack(
Text("安装位置", 28, FontWeights.SemiBold),
Text("默认安装到当前用户目录;只有受保护目录和前置组件操作才会请求管理员权限。", 13, foreground: SecondaryBrush, maxLines: 3),
@@ -235,62 +350,267 @@ public sealed class InstallerWindow : Window
}
private UIElement OptionsPage()
=> PageStack(
{
var prerequisites = InstallerEngine.GetPrerequisiteStatus();
var prerequisiteDetail = string.Join("\n", new[]
{
$"WebView2 Runtime{(prerequisites.WebView2Installed ? $" {prerequisites.WebView2Version}" : "")}",
$"Visual C++ x64 Runtime{(prerequisites.VcRuntimeInstalled ? $" {prerequisites.VcRuntimeVersion}" : "")}",
prerequisites.WebView2Installed && prerequisites.VcRuntimeInstalled
? "无需下载前置组件。"
: "开始安装后将自动下载;系统级运行库安装时会请求管理员权限。"
});
return PageStack(
Text("安装选项", 28, FontWeights.SemiBold),
Text("这些选项会直接传给 Inno 安装引擎,并支持后续升级和修复。", 13, foreground: SecondaryBrush, maxLines: 2),
OptionBand("快捷方式", _desktopShortcut, _startMenuShortcut),
OptionBand("启动", _autoStart, _launchAfterInstall),
InfoBand("前置组件", "安装引擎将检测 WebView2 Runtime 与 VC++ x64 Runtime;仅缺失时下载安装。"));
InfoBand("前置组件下载与安装", prerequisiteDetail));
}
private UIElement EnvironmentCheckPage()
{
_preflightReport = InstallerEngine.BuildPreflightReport(_directory.Text.Trim());
var panel = PageStack(
Text("环境检查", 28, FontWeights.SemiBold),
Text("开始写入前验证架构、目录、磁盘、安装引擎和数据保留策略。", 13, foreground: SecondaryBrush, maxLines: 2),
InfoBand("安装模式", $"{_preflightReport.Plan.DisplayName} · 目标版本 {_preflightReport.Plan.PackageVersion}"));
foreach (var item in _preflightReport.Items)
{
panel.Children.Add(PreflightBand(item));
}
_preflightPanel.HorizontalAlignment = HorizontalAlignment.Stretch;
return new ScrollViewer
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Stretch,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
Content = panel
Padding = new Thickness(0, 0, 8, 0),
Content = _preflightPanel
};
}
private void BeginEnvironmentCheck()
{
var version = ++_preflightCheckVersion;
_preflightChecking = true;
_preflightReport = null;
_preflightPanel.Children.Clear();
_preflightPanel.Children.Add(Text("环境检查", 28, FontWeights.SemiBold));
_preflightPanel.Children.Add(Text("正在检查安装引擎、目标目录和磁盘空间。", 13, foreground: SecondaryBrush, maxLines: 2));
RefreshButtons();
_ = RefreshEnvironmentCheckAsync(version, _directory.Text.Trim());
}
private async Task RefreshEnvironmentCheckAsync(int version, string targetDirectory)
{
InstallerPreflightReport? report = null;
Exception? failure = null;
try
{
report = await Task.Run(() => InstallerEngine.BuildPreflightReport(targetDirectory));
}
catch (Exception exception)
{
failure = exception;
InstallerDiagnostics.Write(exception, "Environment preflight");
}
if (version != _preflightCheckVersion || _step != 4) return;
_preflightChecking = false;
_preflightPanel.Children.Clear();
_preflightPanel.Children.Add(Text("环境检查", 28, FontWeights.SemiBold));
if (failure is not null || report is null)
{
_preflightPanel.Children.Add(Text("环境检查未完成,请返回后重新进入此步骤。", 13, foreground: SecondaryBrush, maxLines: 2));
_navigationError.Title = "环境检查失败";
_navigationError.Message = LimitText(failure?.Message ?? "未知错误");
_navigationError.IsOpen = true;
RefreshButtons();
return;
}
_preflightReport = report;
_preflightPanel.Children.Add(Text("开始写入前验证架构、目录、磁盘、安装引擎和数据保留策略。", 13, foreground: SecondaryBrush, maxLines: 2));
_preflightPanel.Children.Add(InfoBand("安装模式", $"{report.Plan.DisplayName} · 目标版本 {report.Plan.PackageVersion}"));
foreach (var item in report.Items)
{
_preflightPanel.Children.Add(PreflightBand(item));
}
RefreshButtons();
}
private UIElement InstallingPage()
=> PageStack(
Text("正在安装", 28, FontWeights.SemiBold),
_phase,
_detail,
_progress,
_installLog);
{
var phaseHeader = new Grid { ColumnSpacing = 12 };
phaseHeader.ColumnDefinitions.Add(new ColumnDefinition());
phaseHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
phaseHeader.Children.Add(_phase);
_progressValue.VerticalAlignment = VerticalAlignment.Center;
Grid.SetColumn(_progressValue, 1);
phaseHeader.Children.Add(_progressValue);
var currentStatus = new Border
{
Padding = new Thickness(16, 13, 16, 13),
CornerRadius = new CornerRadius(8),
Background = SurfaceBrush,
BorderBrush = StrokeBrush,
BorderThickness = new Thickness(1),
Child = new StackPanel
{
Spacing = 5,
Children =
{
phaseHeader,
_detail,
_progress
}
}
};
var stages = new StackPanel { Spacing = 3 };
foreach (var title in new[] { "准备安装", "前置组件", "写入文件", "创建入口", "完成配置" })
{
var view = CreateInstallStage(title);
_installStageViews.Add(view);
stages.Children.Add(view.Container);
}
var stagePanel = new Border
{
Padding = new Thickness(12, 9, 12, 9),
CornerRadius = new CornerRadius(8),
Background = SurfaceBrush,
BorderBrush = StrokeBrush,
BorderThickness = new Thickness(1),
Child = stages
};
var logHeader = new Grid { ColumnSpacing = 10 };
logHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
logHeader.ColumnDefinitions.Add(new ColumnDefinition());
logHeader.Children.Add(Text("详细安装输出", 12.5, FontWeights.SemiBold));
_latestOutput.HorizontalAlignment = HorizontalAlignment.Right;
_latestOutput.VerticalAlignment = VerticalAlignment.Center;
Grid.SetColumn(_latestOutput, 1);
logHeader.Children.Add(_latestOutput);
_installLogExpander.Header = logHeader;
_installLogExpander.Content = _installLog;
_installLogExpander.HorizontalAlignment = HorizontalAlignment.Stretch;
ScrollViewer.SetHorizontalScrollBarVisibility(_installLog, ScrollBarVisibility.Auto);
ScrollViewer.SetVerticalScrollBarVisibility(_installLog, ScrollBarVisibility.Auto);
var page = new StackPanel { Spacing = 12 };
page.Children.Add(Text("正在安装", 28, FontWeights.SemiBold));
page.Children.Add(Text("安装期间可查看当前阶段;详细输出会保留到安装日志。", 12.5, foreground: SecondaryBrush, maxLines: 2));
page.Children.Add(currentStatus);
page.Children.Add(stagePanel);
page.Children.Add(_installLogExpander);
ResetInstallProgressView();
return page;
}
private static InstallStageView CreateInstallStage(string title)
{
var icon = new FontIcon
{
Glyph = "\uE915",
FontSize = 14,
Foreground = SecondaryBrush,
Width = 24,
VerticalAlignment = VerticalAlignment.Center
};
var label = Text(title, 12.5, FontWeights.SemiBold);
var status = Text("等待", 11.5, foreground: SecondaryBrush);
status.HorizontalAlignment = HorizontalAlignment.Right;
status.VerticalAlignment = VerticalAlignment.Center;
var row = new Grid { Height = 27, ColumnSpacing = 8 };
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
row.ColumnDefinitions.Add(new ColumnDefinition());
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
row.Children.Add(icon);
Grid.SetColumn(label, 1);
row.Children.Add(label);
Grid.SetColumn(status, 2);
row.Children.Add(status);
return new InstallStageView(row, icon, label, status);
}
private UIElement CompletePage()
{
var panel = PageStack(
new FontIcon { Glyph = _installed ? "\uE73E" : "\uEA39", FontSize = 46, Foreground = _installed ? AccentBrush : Brush("#C42B1C") },
_completionIcon,
_completionTitle,
_completionDetail);
_completionDetail,
_completionActions);
return panel;
}
private void RefreshCompletePage()
{
_completionIcon.Glyph = _installed ? "\uE73E" : "\uEA39";
_completionIcon.Foreground = _installed ? AccentBrush : Brush("#C42B1C");
_completionActions.Children.Clear();
if (!string.IsNullOrWhiteSpace(_logPath))
{
panel.Children.Add(InfoBand("安装日志", _logPath));
panel.Children.Add(CommandButton("打开安装日志", "\uE8A5", OpenInstallLog));
_completionActions.Children.Add(InfoBand("安装日志", _logPath));
_completionActions.Children.Add(CommandButton("打开安装日志", "\uE8A5", OpenInstallLog));
}
if (!_installed)
{
panel.Children.Add(InfoBand("下一步", "可返回环境检查后重新安装;失败日志不会自动删除。"));
_completionActions.Children.Add(InfoBand("下一步", "可返回环境检查后重新安装;失败日志不会自动删除。"));
}
return panel;
}
private void ShowStep()
{
_pageHost.Content = _step switch
_navigationError.IsOpen = false;
_pageHost.Content = GetOrCreatePage(_step);
if (_step == 2)
{
ScheduleDiskSpaceRefresh(immediate: true);
}
else if (_step == 4)
{
BeginEnvironmentCheck();
}
else if (_step == 6)
{
RefreshCompletePage();
}
for (var index = 0; index < _stepList.Children.Count; index++)
{
if (_stepList.Children[index] is not Border border || border.Child is not TextBlock text) continue;
border.Background = index == _step ? Brush("#4D4C78D8") : Brush("#001D2026");
text.Foreground = index == _step ? WhiteBrush : Brush("#C5CBD4");
}
RefreshButtons();
}
internal async Task<bool> RunWindowNavigationSelfTestAsync()
{
var initialErrorCount = InstallerDiagnostics.ErrorCount;
try
{
await Task.Delay(120);
foreach (var step in new[] { 0, 1, 2, 3, 4, 3, 2, 1, 0, 4, 5, 6 })
{
_step = step;
ShowStep();
if (step == 5)
{
ApplyInstallProgress(new InstallerProgress(42, "写入程序文件", "正在写入 YMhutBox.exe"));
_installLogExpander.IsExpanded = true;
}
await Task.Delay(20);
}
await Task.Delay(100);
return InstallerDiagnostics.ErrorCount == initialErrorCount;
}
catch (Exception exception)
{
InstallerDiagnostics.Write(exception, "Window navigation self-test");
return false;
}
}
private UIElement GetOrCreatePage(int step)
{
return _pages[step] ??= step switch
{
0 => WelcomePage(),
1 => LicensePage(),
@@ -300,13 +620,6 @@ public sealed class InstallerWindow : Window
5 => InstallingPage(),
_ => CompletePage()
};
for (var index = 0; index < _stepList.Children.Count; index++)
{
if (_stepList.Children[index] is not Border border || border.Child is not TextBlock text) continue;
border.Background = index == _step ? Brush("#243B37") : Brush("#0017201F");
text.Foreground = index == _step ? WhiteBrush : Brush("#B9C7C5");
}
RefreshButtons();
}
private void RefreshButtons()
@@ -314,7 +627,7 @@ public sealed class InstallerWindow : Window
_backButton.IsEnabled = _step > 0 && !_installing && !_installed;
_nextButton.IsEnabled = !_installing &&
(_step != 1 || _licenseAccepted.IsChecked == true) &&
(_step != 4 || _preflightReport?.CanInstall == true);
(_step != 4 || !_preflightChecking && _preflightReport?.CanInstall == true);
_cancelButton.IsEnabled = true;
SetButtonLabel(_cancelButton, _installing ? "停止安装" : _step == 6 ? "关闭" : "取消");
SetButtonLabel(_nextButton, _step switch
@@ -331,6 +644,7 @@ public sealed class InstallerWindow : Window
{
if (_installing) return;
_step = Math.Clamp(_step + delta, 0, 6);
if (_step != 4) _preflightCheckVersion++;
ShowStep();
}
@@ -376,25 +690,35 @@ public sealed class InstallerWindow : Window
_installing = true;
_installCts = new CancellationTokenSource();
ShowStep();
var progress = new Progress<InstallerProgress>(value =>
ResetInstallProgressView();
var progress = new Progress<InstallerProgress>(ApplyInstallProgress);
InstallerResult result;
try
{
_phase.Text = value.Phase;
_detail.Text = value.Detail;
_progress.IsIndeterminate = value.Percent is null;
if (value.Percent is not null) _progress.Value = value.Percent.Value;
if (!string.IsNullOrWhiteSpace(value.Detail))
{
AppendInstallLog($"[{DateTime.Now:HH:mm:ss}] {value.Detail}\r\n");
_installLog.SelectionStart = _installLog.Text.Length;
_installLog.SelectionLength = 0;
}
});
var result = await InstallerEngine.InstallAsync(_options, progress, _installCts.Token);
_installCts.Dispose();
_installCts = null;
_installing = false;
result = await InstallerEngine.InstallAsync(_options, progress, _installCts.Token);
}
catch (OperationCanceledException)
{
result = new InstallerResult(false, -1, _logPath ?? string.Empty, "安装已由用户取消。");
}
catch (Exception exception)
{
InstallerDiagnostics.Write(exception, "InstallAsync");
result = new InstallerResult(false, -1, _logPath ?? string.Empty, LimitText(exception.Message));
}
finally
{
_installCts?.Dispose();
_installCts = null;
_installing = false;
}
_installed = result.Success;
_logPath = result.LogPath;
if (!result.Success)
{
_installLogExpander.IsExpanded = true;
AppendInstallLog($"[{DateTime.Now:HH:mm:ss}] {result.Error ?? $"退 {result.ExitCode}"}\r\n");
}
_completionTitle.Text = result.Success ? "安装完成" : "安装未完成";
_completionDetail.Text = result.Success
? "程序文件、快捷方式、运行库检查与旧版数据迁移均已交由安装引擎完成。"
@@ -409,6 +733,11 @@ public sealed class InstallerWindow : Window
{
_cancelButton.IsEnabled = false;
SetButtonLabel(_cancelButton, "正在停止");
_phase.Text = "正在停止安装";
_detail.Text = "正在等待安装引擎安全退出,请勿关闭窗口。";
_progressValue.Text = "正在取消";
_latestOutput.Text = "已发送取消请求";
AppendInstallLog($"[{DateTime.Now:HH:mm:ss}] 已请求取消安装。\r\n");
_installCts?.Cancel();
return;
}
@@ -438,6 +767,8 @@ public sealed class InstallerWindow : Window
{
const int maximumCharacters = 32000;
_installLog.Text += value;
_installLogEntries++;
_latestOutput.Text = $"{_installLogEntries} 条事件";
if (_installLog.Text.Length > maximumCharacters)
{
_installLog.Text = "[较早的安装状态已省略,完整内容请查看安装日志。]\r\n" + _installLog.Text[^maximumCharacters..];
@@ -453,22 +784,129 @@ public sealed class InstallerWindow : Window
if (folder is not null) _directory.Text = Path.Combine(folder.Path, "YMhut Box");
}
private void RefreshDiskSpace()
private void ApplyInstallProgress(InstallerProgress value)
{
if (!string.IsNullOrWhiteSpace(value.Phase)) _phase.Text = value.Phase;
if (!string.IsNullOrWhiteSpace(value.Detail)) _detail.Text = value.Detail;
_progress.IsIndeterminate = value.Percent is null;
if (value.Percent is not null)
{
_progress.Value = Math.Clamp(value.Percent.Value, 0, 100);
_progressValue.Text = $"{_progress.Value:0}%";
}
else
{
_progressValue.Text = "进行中";
}
_activeInstallStage = Math.Max(_activeInstallStage, ResolveInstallStage(value.Phase, _activeInstallStage));
UpdateInstallStages(_activeInstallStage);
if (!string.IsNullOrWhiteSpace(value.Detail) && !string.Equals(value.Detail, _lastInstallLogDetail, StringComparison.Ordinal))
{
_lastInstallLogDetail = value.Detail;
AppendInstallLog($"[{DateTime.Now:HH:mm:ss}] {value.Detail}\r\n");
_installLog.SelectionStart = _installLog.Text.Length;
_installLog.SelectionLength = 0;
}
}
private void ResetInstallProgressView()
{
_activeInstallStage = 0;
_installLogEntries = 0;
_lastInstallLogDetail = null;
_installLog.Text = string.Empty;
_phase.Text = "准备安装";
_detail.Text = "正在准备安装引擎和目标目录。";
_progress.IsIndeterminate = true;
_progress.Value = 0;
_progressValue.Text = "准备中";
_latestOutput.Text = "尚无安装输出";
_installLogExpander.IsExpanded = false;
UpdateInstallStages(0);
}
private void UpdateInstallStages(int activeStage)
{
for (var index = 0; index < _installStageViews.Count; index++)
{
var view = _installStageViews[index];
if (index < activeStage)
{
view.Icon.Glyph = "\uE73E";
view.Icon.Foreground = AccentBrush;
view.Status.Text = "已完成";
view.Status.Foreground = AccentBrush;
view.Label.Foreground = TextBrush;
}
else if (index == activeStage)
{
view.Icon.Glyph = "\uE72C";
view.Icon.Foreground = AccentBrush;
view.Status.Text = "进行中";
view.Status.Foreground = AccentBrush;
view.Label.Foreground = TextBrush;
}
else
{
view.Icon.Glyph = "\uE915";
view.Icon.Foreground = SecondaryBrush;
view.Status.Text = "等待";
view.Status.Foreground = SecondaryBrush;
view.Label.Foreground = SecondaryBrush;
}
}
}
private static int ResolveInstallStage(string phase, int currentStage)
{
if (phase.Contains("准备安装", StringComparison.Ordinal) || phase.Contains("检查环境", StringComparison.Ordinal)) return 0;
if (phase.Contains("前置组件", StringComparison.Ordinal) || phase.Contains("运行库", StringComparison.Ordinal)) return 1;
if (phase.Contains("写入程序文件", StringComparison.Ordinal)) return 2;
if (phase.Contains("创建快捷方式", StringComparison.Ordinal)) return 3;
if (phase.Contains("完成配置", StringComparison.Ordinal) || phase.Contains("安装完成", StringComparison.Ordinal)) return 4;
return currentStage;
}
private void ScheduleDiskSpaceRefresh(bool immediate = false)
{
_diskCheckVersion++;
_diskCheckCts?.Cancel();
_diskCheckCts?.Dispose();
_diskCheckCts = new CancellationTokenSource();
_diskSpace.Text = "正在检查磁盘空间...";
_diskSpace.Foreground = SecondaryBrush;
_ = RefreshDiskSpaceAsync(_diskCheckVersion, immediate ? TimeSpan.Zero : TimeSpan.FromMilliseconds(300), _diskCheckCts.Token);
}
private async Task RefreshDiskSpaceAsync(int version, TimeSpan delay, CancellationToken cancellationToken)
{
try
{
var root = Path.GetPathRoot(Path.GetFullPath(string.IsNullOrWhiteSpace(_directory.Text) ? _options.InstallDirectory : _directory.Text));
var drive = new DriveInfo(root!);
_diskSpace.Text = $"可用空间 {InstallerModeResolver.FormatBytes(drive.AvailableFreeSpace)} · 预计需要 {InstallerModeResolver.FormatBytes(InstallerEngine.RequiredInstallBytes)}";
_diskSpace.Foreground = drive.AvailableFreeSpace < InstallerEngine.RequiredInstallBytes ? Brush("#C42B1C") : SecondaryBrush;
if (delay > TimeSpan.Zero) await Task.Delay(delay, cancellationToken);
var path = string.IsNullOrWhiteSpace(_directory.Text) ? _options.InstallDirectory : _directory.Text;
var result = await Task.Run(() => ReadDiskSpace(path), cancellationToken);
if (version != _diskCheckVersion || _step != 2 || cancellationToken.IsCancellationRequested) return;
_diskSpace.Text = $"可用空间 {InstallerModeResolver.FormatBytes(result.AvailableBytes)} · 预计需要 {InstallerModeResolver.FormatBytes(InstallerEngine.RequiredInstallBytes)}";
_diskSpace.Foreground = result.AvailableBytes < InstallerEngine.RequiredInstallBytes ? Brush("#C42B1C") : SecondaryBrush;
}
catch (OperationCanceledException)
{
}
catch
{
if (version != _diskCheckVersion || _step != 2) return;
_diskSpace.Text = "目录不可用,请选择有效路径。";
_diskSpace.Foreground = Brush("#C42B1C");
}
}
private static DiskSpaceResult ReadDiskSpace(string path)
{
var root = Path.GetPathRoot(Path.GetFullPath(path));
return new DiskSpaceResult(new DriveInfo(root!).AvailableFreeSpace);
}
private static StackPanel PageStack(params UIElement[] children)
{
var panel = new StackPanel { Spacing = 16 };
@@ -550,7 +988,14 @@ public sealed class InstallerWindow : Window
};
}
private static Button CommandButton(string title, string glyph, Action action, bool primary = false)
private Button CommandButton(string title, string glyph, Action action, bool primary = false)
=> CommandButton(title, glyph, () =>
{
action();
return Task.CompletedTask;
}, primary);
private Button CommandButton(string title, string glyph, Func<Task> action, bool primary = false)
{
var button = new Button
{
@@ -566,10 +1011,26 @@ public sealed class InstallerWindow : Window
Children = { Text(title, 13, FontWeights.SemiBold, primary ? WhiteBrush : TextBrush), new FontIcon { Glyph = glyph, FontSize = 13 } }
}
};
button.Click += (_, _) => action();
button.Click += async (_, _) => await ExecuteCommandAsync(action);
return button;
}
private async Task ExecuteCommandAsync(Func<Task> action)
{
try
{
await action();
}
catch (Exception exception)
{
InstallerDiagnostics.Write(exception, "Installer command");
_navigationError.Title = "无法继续";
_navigationError.Message = LimitText(exception.Message);
_navigationError.IsOpen = true;
RefreshButtons();
}
}
private static void SetButtonLabel(Button button, string value)
{
if (button.Content is StackPanel panel && panel.Children.FirstOrDefault() is TextBlock text) text.Text = value;
@@ -590,6 +1051,15 @@ public sealed class InstallerWindow : Window
return block;
}
private static Image AppIcon(double size)
=> new()
{
Width = size,
Height = size,
Stretch = Stretch.Uniform,
Source = new BitmapImage(new Uri("ms-appx:///Assets/Square44x44Logo.png"))
};
private static SolidColorBrush Brush(string hex)
{
var value = hex.TrimStart('#');
@@ -604,6 +1074,13 @@ public sealed class InstallerWindow : Window
private static string LimitText(string value)
=> string.IsNullOrWhiteSpace(value) ? "未知错误" : value.Length <= 180 ? value : value[..180];
private static string GetBootstrapVersion()
=> typeof(InstallerWindow).Assembly.GetName().Version?.ToString() ?? "0.0.0";
private sealed record DiskSpaceResult(long AvailableBytes);
private sealed record InstallStageView(Grid Container, FontIcon Icon, TextBlock Label, TextBlock Status);
private static string ReadEmbeddedText(string resourceName, string fallback)
{
using var stream = typeof(InstallerWindow).Assembly.GetManifestResourceStream(resourceName);
@@ -9,6 +9,18 @@ public static class Program
[STAThread]
public static void Main(string[] args)
{
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
{
if (eventArgs.ExceptionObject is Exception exception)
{
InstallerDiagnostics.Write(exception, "AppDomain.UnhandledException");
}
};
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
{
InstallerDiagnostics.Write(eventArgs.Exception, "TaskScheduler.UnobservedTaskException");
eventArgs.SetObserved();
};
Environment.SetEnvironmentVariable("MICROSOFT_WINDOWSAPPRUNTIME_BASE_DIRECTORY", AppContext.BaseDirectory);
if (args.Any(argument => argument.Equals("/SELFTEST", StringComparison.OrdinalIgnoreCase)))
{
@@ -12,7 +12,9 @@
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<PublishReadyToRun>true</PublishReadyToRun>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<PublishReadyToRun>false</PublishReadyToRun>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DefineConstants>$(DefineConstants);DISABLE_XAML_GENERATED_MAIN</DefineConstants>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
@@ -24,10 +26,16 @@
<PackageReference Include="Microsoft.WindowsAppSDK.WinUI" Version="1.8.260415005" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\..\LICENSE" LogicalName="YMhutBox.LICENSE.txt" />
<EmbeddedResource Include="..\..\installer\YMhutBox-EULA.zh-CN.txt" LogicalName="YMhutBox.EULA.txt">
<WithCulture>false</WithCulture>
</EmbeddedResource>
<EmbeddedResource Include="..\..\THIRD_PARTY_NOTICES.md" LogicalName="YMhutBox.THIRD_PARTY_NOTICES.md" />
<EmbeddedResource Include="$(InstallerEnginePath)"
LogicalName="YMhutBox.Engine.exe"
Condition="'$(InstallerEnginePath)' != '' and Exists('$(InstallerEnginePath)')" />
<Content Include="..\box-winUI\Assets\Square44x44Logo.png"
Link="Assets\Square44x44Logo.png"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,64 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using YMhut.Box.Core.System;
namespace YMhut.Box.Tests;
[TestClass]
public sealed class HardwareInfoServiceTests
{
[TestMethod]
public void SelectPrimaryGpuPrefersNvidiaDiscreteOverIntelIntegrated()
{
var selected = HardwareInfoService.SelectPrimaryGpu([
Gpu("Intel(R) Iris(R) Xe Graphics", "Intel", "PCI\\VEN_8086", 2),
Gpu("NVIDIA GeForce RTX 4070", "NVIDIA", "PCI\\VEN_10DE", 8)
]);
Assert.AreEqual("NVIDIA GeForce RTX 4070", selected?.Name);
}
[TestMethod]
public void SelectPrimaryGpuPrefersAmdDiscreteOverIntegratedRadeon()
{
var selected = HardwareInfoService.SelectPrimaryGpu([
Gpu("AMD Radeon Graphics", "AMD", "PCI\\VEN_1002", 1),
Gpu("AMD Radeon RX 7800 XT", "AMD", "PCI\\VEN_1002", 16)
]);
Assert.AreEqual("AMD Radeon RX 7800 XT", selected?.Name);
}
[TestMethod]
public void SelectPrimaryGpuDemotesBasicAndRemoteAdapters()
{
var selected = HardwareInfoService.SelectPrimaryGpu([
Gpu("Microsoft Basic Display Adapter", "Microsoft", "ROOT\\BASICDISPLAY", 0),
Gpu("Remote Display Adapter", "Microsoft", "ROOT\\RDPIDD", 0),
Gpu("Intel(R) Arc(TM) A770 Graphics", "Intel", "PCI\\VEN_8086", 16)
]);
Assert.AreEqual("Intel(R) Arc(TM) A770 Graphics", selected?.Name);
}
private static HardwareInventoryDevice Gpu(
string name,
string manufacturer,
string pnpId,
int memoryGb)
{
return new HardwareInventoryDevice(
pnpId,
"Graphics",
name,
manufacturer,
name,
"1.0",
"OK",
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["AdapterCompatibility"] = manufacturer,
["PNPDeviceID"] = pnpId,
["AdapterRAM"] = ((ulong)memoryGb * 1024 * 1024 * 1024).ToString()
});
}
}
+1 -18
View File
@@ -36,7 +36,7 @@ public sealed class RemoteMediaCatalogTests
[TestMethod]
public void ParsesLegacyMediaTypesSnapshot()
{
var catalog = RemoteMediaCatalogParser.Parse(ReadRepoFile("box-old", "server", "media-types.json"));
var catalog = RemoteMediaCatalogParser.Parse(ReadTestData("media-types.legacy.json"));
Assert.AreEqual("1.0.8", catalog.LayoutVersion);
Assert.IsGreaterThanOrEqualTo(2, catalog.Categories.Count);
@@ -264,23 +264,6 @@ public sealed class RemoteMediaCatalogTests
}
}
private static string ReadRepoFile(params string[] segments)
{
var directory = new DirectoryInfo(Directory.GetCurrentDirectory());
while (directory is not null)
{
var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray());
if (File.Exists(candidate))
{
return File.ReadAllText(candidate);
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Unable to locate repository sample file.");
}
private static string ReadTestData(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, "TestData", fileName);
@@ -0,0 +1,46 @@
{
"layout_version": "1.0.8",
"categories": [
{
"id": "image",
"name": "随机图片",
"enabled": true,
"layout": {
"columns": 1,
"aspect_ratio": "16:9",
"show_preview": true
},
"subcategories": [
{ "id": "xjj", "api_url": "https://example.test/xjj", "supported_formats": ["jpg"] },
{ "id": "baisi", "api_url": "https://example.test/baisi", "supported_formats": ["jpg"] },
{ "id": "heisi", "api_url": "https://example.test/heisi", "supported_formats": ["jpg"] },
{ "id": "acg", "api_url": "https://example.test/acg", "supported_formats": ["webp"] },
{ "id": "miku", "api_url": "https://example.test/miku", "supported_formats": ["png"] },
{ "id": "wallpaper", "api_url": "https://example.test/wallpaper", "supported_formats": ["jpg"] },
{ "id": "comic", "api_url": "https://example.test/comic", "supported_formats": ["jpg"] }
]
},
{
"id": "video",
"name": "随机视频",
"enabled": true,
"layout": {
"columns": 1,
"aspect_ratio": "16:9",
"show_preview": true,
"auto_play": false
},
"subcategories": [
{
"id": "radom_xjj_mv",
"name": "JK视频",
"api_url": "https://example.test/video",
"supported_formats": ["mp4", "webm"]
}
]
}
],
"ui_config": {
"default_view": "grid"
}
}
+15
View File
@@ -21,6 +21,11 @@ public sealed class ToolCatalogTests
Assert.IsNotNull(catalog.GetById("timezone_abbr_lookup"));
Assert.IsNotNull(catalog.GetById("percentage_change_calculator"));
Assert.IsNotNull(catalog.GetById("dev_environment_config"));
foreach (var id in new[] { "hardware", "optimization", "music" })
{
Assert.IsNotNull(catalog.GetById(id), id);
Assert.IsGreaterThan(0, catalog.GetById(id)!.Metadata.AddedOrder, id);
}
Assert.IsNull(catalog.GetById("mmd_model_studio"));
foreach (var id in new[]
{
@@ -49,6 +54,16 @@ public sealed class ToolCatalogTests
Assert.AreEqual("safe_browser", results[0].Id);
}
[TestMethod]
public void SearchFindsIntegratedToolboxSurfaces()
{
var catalog = new ToolCatalog();
Assert.IsTrue(catalog.Search("硬件").Any(module => module.Id == "hardware"));
Assert.IsTrue(catalog.Search("优化").Any(module => module.Id == "optimization"));
Assert.IsTrue(catalog.Search("音乐").Any(module => module.Id == "music"));
}
[TestMethod]
public void EveryCatalogToolHasOneStableDisplayGroup()
{
+27 -5
View File
@@ -9,10 +9,10 @@ public sealed class ToolboxLayoutTests
[TestMethod]
[DataRow(480, 1)]
[DataRow(760, 2)]
[DataRow(1120, 3)]
[DataRow(1440, 4)]
[DataRow(1720, 5)]
[DataRow(2200, 6)]
[DataRow(920, 3)]
[DataRow(1280, 4)]
[DataRow(1580, 5)]
[DataRow(2000, 6)]
public void ToolboxLayoutUsesStableBreakpoints(double width, int expectedColumns)
{
var layout = ToolboxLayoutCalculator.Calculate(width);
@@ -25,6 +25,7 @@ public sealed class ToolboxLayoutTests
[TestMethod]
[DataRow(520, false)]
[DataRow(900, false)]
[DataRow(1120, false)]
[DataRow(1280, true)]
[DataRow(1680, true)]
public void ToolboxLayoutUsesResponsiveCategoryEntry(double width, bool showRail)
@@ -38,7 +39,28 @@ public sealed class ToolboxLayoutTests
var layout = ToolboxLayoutCalculator.Calculate(0);
Assert.AreEqual(3, layout.Columns);
Assert.AreEqual(318, layout.CardWidth);
Assert.AreEqual(286, layout.CardWidth);
Assert.IsTrue(layout.ShowCategoryRail);
}
[TestMethod]
public void ToolboxSortKeepsNewToolsAheadAndUsesSecondaryGroupWithinSameLayer()
{
var normal = Module("normal", "普通", ToolCategory.Dev, addedOrder: 0);
var recentNew = Module("recent-new", "最近新增", ToolCategory.System, addedOrder: 100);
var favoriteNew = Module("favorite-new", "收藏新增", ToolCategory.Network, addedOrder: 100);
var sorted = ToolCatalog.SortForDisplay(
[normal, recentNew, favoriteNew],
module => module.Id == favoriteNew.Id ? 0 : module.Id == recentNew.Id ? 1 : 0)
.Select(module => module.Id)
.ToArray();
CollectionAssert.AreEqual(new[] { favoriteNew.Id, recentNew.Id, normal.Id }, sorted);
}
private static IToolModule Module(string id, string name, ToolCategory category, int addedOrder)
{
return new ToolModule(new ToolMetadata(id, name, string.Empty, category, [], true, string.Empty, addedOrder));
}
}
@@ -122,7 +122,20 @@ public sealed class WeatherCapsuleControl : UserControl
{
var loadVersion = Interlocked.Increment(ref _loadVersion);
DispatcherQueue.TryEnqueue(() => ApplySnapshot(TitleWeatherSnapshot.Loading, loadingOverlay: true));
var snapshot = await _weatherService.GetCurrentAsync(cancellationToken).ConfigureAwait(false);
TitleWeatherSnapshot snapshot;
try
{
snapshot = await _weatherService.GetCurrentAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return;
}
catch (Exception exception)
{
snapshot = TitleWeatherSnapshot.Offline(
AppLocalizer.SanitizeSensitiveText(exception.Message, 180));
}
if (loadVersion != _loadVersion)
{
return;
+42 -1
View File
@@ -1200,6 +1200,13 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
private void ShowToolboxSurface(ToolboxSurfaceDefinition surface)
{
var module = _catalog.GetById(surface.Id);
if (module is not null)
{
ShowToolDetailPage(module);
return;
}
ClearActivePluginHost();
_activeToolboxSurfaceId = surface.Id;
_toolboxPage = null;
@@ -1228,7 +1235,6 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
_toolboxPage = new ToolboxPage(
_catalog,
ShowToolDetailPage,
ShowToolboxSurface,
_toolboxState.SearchQuery,
_toolboxState,
state => _toolboxState = state);
@@ -1248,6 +1254,11 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
_toolboxState = _toolboxPage.CaptureState(module.Id);
}
if (TryShowToolboxNativeSurface(module))
{
return;
}
if (module is PluginToolModule pluginModule)
{
ShowPluginHostPage(pluginModule.Plugin, pluginModule.Surface);
@@ -1304,6 +1315,36 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
_ = _logService.WriteAsync("Information", "tool", $"打开工具:{ToolText.Name(module)}", module.Id);
}
private bool TryShowToolboxNativeSurface(IToolModule module)
{
if (!ToolCatalog.IsToolboxNativeSurface(module.Id))
{
return false;
}
ClearActivePluginHost();
_activeToolboxSurfaceId = module.Id;
_ = _settingsService.RecordRecentToolAsync(module.Id);
Func<Page> pageFactory = module.Id.ToLowerInvariant() switch
{
"hardware" => () => new HardwarePage(),
"optimization" => () => new OptimizationPage(ShowToolDetailPage),
"music" => () => new NetworkMusicPage(),
_ => throw new InvalidOperationException($"Unsupported toolbox surface: {module.Id}")
};
SafeNavigate(
pageFactory,
ShellPage.Toolbox,
"tool",
$"Open toolbox tool: {module.Id}",
module.Id);
_shellNavigationService.NotifyExternalNavigation(module.Id);
_ = _logService.WriteAsync("Information", "tool", $"打开工具:{ToolText.Name(module)}", module.Id);
return true;
}
public void ShowDownloadManagerPage()
{
ClearActivePluginHost();
+43 -61
View File
@@ -92,6 +92,11 @@ public sealed class TitleWeatherService(
{
private const double DistrictSearchMaxDistanceKm = 180;
private const double CitySearchMaxDistanceKm = 500;
private static readonly TimeSpan WeatherOperationTimeout = TimeSpan.FromSeconds(12);
private static readonly HttpRequestPolicy WeatherRequestPolicy = new(
TimeSpan.FromSeconds(7),
MaxRetries: 0,
CacheMode: HttpCacheMode.UseProtocol);
private static readonly Uri IpApiLocationUri = new("https://ipapi.co/json/");
private static readonly Uri ClientLocationZhUri = BuildClientLocationUri("zh-Hans");
@@ -114,12 +119,15 @@ public sealed class TitleWeatherService(
public async Task<TitleWeatherSnapshot> GetCurrentAsync(CancellationToken cancellationToken = default)
{
using var operationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
operationCts.CancelAfter(WeatherOperationTimeout);
var requestToken = operationCts.Token;
try
{
var location = await ResolveLocationAsync(cancellationToken).ConfigureAwait(false);
var weatherPlace = await ResolveWeatherPlaceAsync(location, cancellationToken).ConfigureAwait(false);
var location = await ResolveLocationAsync(requestToken).ConfigureAwait(false);
var weatherPlace = await ResolveWeatherPlaceAsync(location, requestToken).ConfigureAwait(false);
var uri = BuildForecastUri(weatherPlace);
var forecast = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
var forecast = await GetWeatherStringAsync(uri, requestToken).ConfigureAwait(false);
var snapshot = ParseForecast(location, weatherPlace, forecast);
await WriteLogAsync(
"Information",
@@ -128,7 +136,7 @@ public sealed class TitleWeatherService(
$"{snapshot.Location}; {snapshot.Condition}; query={weatherPlace.QueryLevel}").ConfigureAwait(false);
return snapshot;
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
var safe = AppLocalizer.SanitizeSensitiveText(exception.Message, 180);
await WriteLogAsync("Warning", "weather", "Title weather unavailable", safe).ConfigureAwait(false);
@@ -177,10 +185,10 @@ public sealed class TitleWeatherService(
{
try
{
var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
var content = await GetWeatherStringAsync(uri, cancellationToken).ConfigureAwait(false);
return ParseClientLocation(content);
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
return null;
}
@@ -190,7 +198,7 @@ public sealed class TitleWeatherService(
{
try
{
var content = await httpService.GetStringAsync(IpApiLocationUri, cancellationToken).ConfigureAwait(false);
var content = await GetWeatherStringAsync(IpApiLocationUri, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(content);
var root = document.RootElement;
var latitude = GetDouble(root, "latitude");
@@ -212,65 +220,20 @@ public sealed class TitleWeatherService(
latitude.Value,
longitude.Value);
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
return null;
}
}
private async Task<WeatherPlace> ResolveWeatherPlaceAsync(WeatherLocation location, CancellationToken cancellationToken)
private Task<WeatherPlace> ResolveWeatherPlaceAsync(WeatherLocation location, CancellationToken cancellationToken)
{
if (location.DistrictGeoNameId is long districtId &&
await TryGetGeocodedPlaceByIdAsync(districtId, cancellationToken).ConfigureAwait(false) is { } districtPlace &&
IsNearExpectedLocation(districtPlace, location, DistrictSearchMaxDistanceKm))
{
return new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: true),
AppLocalizer.T("区/县", "District"),
districtPlace.Latitude,
districtPlace.Longitude);
}
if (location.CityGeoNameId is long cityId &&
await TryGetGeocodedPlaceByIdAsync(cityId, cancellationToken).ConfigureAwait(false) is { } cityPlace &&
IsNearExpectedLocation(cityPlace, location, CitySearchMaxDistanceKm))
{
return new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: false),
AppLocalizer.T("市级", "City"),
cityPlace.Latitude,
cityPlace.Longitude);
}
foreach (var query in BuildNameSearchQueries(location.QueryDistrict))
{
if (await TrySearchGeocodedPlaceAsync(query, location, DistrictSearchMaxDistanceKm, cancellationToken).ConfigureAwait(false) is { } place)
{
return new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: true),
AppLocalizer.T("区/县", "District"),
place.Latitude,
place.Longitude);
}
}
foreach (var query in BuildNameSearchQueries(location.QueryCity))
{
if (await TrySearchGeocodedPlaceAsync(query, location, CitySearchMaxDistanceKm, cancellationToken).ConfigureAwait(false) is { } place)
{
return new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: false),
AppLocalizer.T("市级", "City"),
place.Latitude,
place.Longitude);
}
}
return new WeatherPlace(
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new WeatherPlace(
FormatDisplayLocation(location, preferDistrict: true),
AppLocalizer.T("经纬度", "Coordinates"),
location.Latitude,
location.Longitude);
location.Longitude));
}
private async Task<GeocodedPlace?> TryGetGeocodedPlaceByIdAsync(long id, CancellationToken cancellationToken)
@@ -278,11 +241,11 @@ public sealed class TitleWeatherService(
try
{
var uri = new Uri($"https://geocoding-api.open-meteo.com/v1/get?id={id}&language=en&format=json");
var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
var content = await GetWeatherStringAsync(uri, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(content);
return TryParseGeocodedPlace(document.RootElement, out var place) ? place : null;
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
return null;
}
@@ -303,7 +266,7 @@ public sealed class TitleWeatherService(
{
var uri = new Uri("https://geocoding-api.open-meteo.com/v1/search" +
$"?name={Uri.EscapeDataString(query)}&count=10&language=en&format=json");
var content = await httpService.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
var content = await GetWeatherStringAsync(uri, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(content);
if (!document.RootElement.TryGetProperty("results", out var results) ||
results.ValueKind != JsonValueKind.Array)
@@ -327,12 +290,31 @@ public sealed class TitleWeatherService(
.Select(item => item.Place)
.FirstOrDefault();
}
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException or FormatException)
catch (Exception exception) when (IsRecoverableWeatherFailure(exception))
{
return null;
}
}
private async Task<string> GetWeatherStringAsync(Uri uri, CancellationToken cancellationToken)
{
var response = await httpService.SendAsync(
uri,
policy: WeatherRequestPolicy,
cancellationToken: cancellationToken).ConfigureAwait(false);
return response.Content;
}
private static bool IsRecoverableWeatherFailure(Exception exception)
=> exception is HttpRequestException or
HttpRequestTimeoutException or
TimeoutException or
OperationCanceledException or
JsonException or
InvalidOperationException or
FormatException or
IOException;
private static Uri BuildForecastUri(WeatherPlace place)
{
var latitude = place.Latitude.ToString("0.####", CultureInfo.InvariantCulture);
+209 -63
View File
@@ -22,14 +22,13 @@ public sealed class HardwarePage : Page
private readonly MetricChartControl _gpuChart = new(AppLocalizer.T("GPU 实时趋势", "GPU live trend"), AppLocalizer.T("等待硬件传感器", "Waiting for a hardware sensor"), "\uE9D5");
private readonly StackPanel _providerRows = new() { Spacing = 0 };
private readonly StackPanel _inventoryRows = new() { Spacing = 0 };
private readonly StackPanel _sensorRows = new() { Spacing = 0 };
private readonly StackPanel _sensorRows = new() { Spacing = 10 };
private readonly TextBlock _deviceTitle = ModernUi.Text("--", 21, FontWeights.SemiBold, maxLines: 1);
private readonly TextBlock _deviceSubtitle = ModernUi.Text("--", 12, foreground: ModernUi.TextSecondary, maxLines: 2);
private readonly TextBlock _captureStatus = ModernUi.Text("--", 12, foreground: ModernUi.TextSecondary, maxLines: 1);
private readonly Button _elevateButton;
private readonly Grid _charts = new() { ColumnSpacing = 12, RowSpacing = 12 };
private StackPanel? _contentStack;
private UIElement? _sensorHeader;
private bool _compactLayout;
private bool _refreshing;
private HardwareSnapshot? _latest;
@@ -73,8 +72,8 @@ public sealed class HardwarePage : Page
_charts.Children.Add(_gpuChart);
root.Children.Add(_charts);
root.Children.Add(BuildProviderSection());
root.Children.Add(BuildInventorySection());
root.Children.Add(BuildSensorSection());
root.Children.Add(BuildInventorySection());
ArrangeCharts();
return new ScrollViewer
@@ -204,10 +203,7 @@ public sealed class HardwarePage : Page
var header = SectionHeader(
AppLocalizer.T("传感器明细", "Sensor details"),
AppLocalizer.T("按硬件和指标排序,NVML 数值优先覆盖重复 GPU 指标。", "Sorted by device and metric; NVML wins duplicate GPU readings."));
var sensorHeader = BuildSensorHeader();
_sensorHeader = sensorHeader;
var table = new StackPanel { Spacing = 0, Children = { sensorHeader, _sensorRows } };
return new StackPanel { Spacing = 8, Children = { header, ModernUi.Card(table, new Thickness(0), radius: 8) } };
return new StackPanel { Spacing = 8, Children = { header, _sensorRows } };
}
private UIElement BuildInventorySection()
@@ -322,8 +318,8 @@ public sealed class HardwarePage : Page
heading,
ModernUi.Text(
provider.SampledAt is DateTimeOffset sampledAt
? $"{provider.Message} · {sampledAt.ToLocalTime():HH:mm:ss}"
: provider.Message,
? $"{ProviderMessage(provider)} · {sampledAt.ToLocalTime():HH:mm:ss}"
: ProviderMessage(provider),
11.5,
foreground: ModernUi.TextSecondary,
maxLines: 2)
@@ -348,9 +344,11 @@ public sealed class HardwarePage : Page
{
device.Manufacturer,
device.Model,
string.IsNullOrWhiteSpace(device.DriverVersion) ? null : $"Driver {device.DriverVersion}"
string.IsNullOrWhiteSpace(device.DriverVersion)
? null
: AppLocalizer.T($"驱动 {device.DriverVersion}", $"Driver {device.DriverVersion}")
}.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase));
var category = ModernUi.Badge(device.Category, ModernUi.TextSecondary, ModernUi.SurfaceAlt, _compactLayout ? 72 : 92);
var category = ModernUi.Badge(InventoryCategoryText(device.Category), ModernUi.TextSecondary, ModernUi.SurfaceAlt, _compactLayout ? 72 : 92);
category.VerticalAlignment = VerticalAlignment.Center;
var heading = new Grid { ColumnSpacing = 8 };
heading.ColumnDefinitions.Add(new ColumnDefinition());
@@ -397,53 +395,51 @@ public sealed class HardwarePage : Page
private void ApplySensors(IReadOnlyList<SensorReading> sensors)
{
_sensorRows.Children.Clear();
foreach (var sensor in sensors.Take(80))
foreach (var group in sensors
.Take(80)
.GroupBy(sensor => sensor.Hardware, StringComparer.OrdinalIgnoreCase)
.OrderBy(group => group.Key, StringComparer.CurrentCulture))
{
if (_compactLayout)
var groupRows = new StackPanel { Spacing = 0 };
var groupHeader = new Grid
{
var compactRow = new Grid { MinHeight = 54, Padding = new Thickness(12, 7, 12, 7), ColumnSpacing = 10 };
compactRow.ColumnDefinitions.Add(new ColumnDefinition());
compactRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
compactRow.Children.Add(new StackPanel
{
Spacing = 1,
Children =
{
ModernUi.Text(sensor.Hardware, 12.5, FontWeights.SemiBold, maxLines: 1),
ModernUi.Text($"{sensor.SensorType} · {sensor.Name}", 11.5, foreground: ModernUi.TextSecondary, maxLines: 1)
}
});
var reading = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
Spacing = 1,
Children =
{
ModernUi.Text($"{sensor.Value:0.##} {sensor.Unit}".Trim(), 12.5, FontWeights.SemiBold, ModernUi.Accent, maxLines: 1),
ModernUi.Text(sensor.Provider, 10.5, foreground: ModernUi.TextSecondary, maxLines: 1)
}
};
Grid.SetColumn(reading, 1);
compactRow.Children.Add(reading);
_sensorRows.Children.Add(compactRow);
continue;
MinHeight = 44,
Padding = new Thickness(12, 8, 12, 8),
ColumnSpacing = 10,
Background = ModernUi.SurfaceAlt
};
groupHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
groupHeader.ColumnDefinitions.Add(new ColumnDefinition());
groupHeader.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
groupHeader.Children.Add(ModernUi.IconTile(
group.Any(sensor => sensor.HardwareType.Contains("Gpu", StringComparison.OrdinalIgnoreCase)) ? "\uE9D5" : "\uE950",
28,
ModernUi.AccentSoft,
ModernUi.Accent,
13));
var groupName = ModernUi.Text(SensorHardwareText(group.Key), 13.5, FontWeights.SemiBold, maxLines: 1);
groupName.VerticalAlignment = VerticalAlignment.Center;
Grid.SetColumn(groupName, 1);
groupHeader.Children.Add(groupName);
var count = ModernUi.Badge(AppLocalizer.T($"{group.Count()} 项", $"{group.Count()} readings"), ModernUi.TextSecondary, ModernUi.Surface, 86);
count.VerticalAlignment = VerticalAlignment.Center;
Grid.SetColumn(count, 2);
groupHeader.Children.Add(count);
groupRows.Children.Add(groupHeader);
if (!_compactLayout)
{
groupRows.Children.Add(BuildSensorHeader());
}
var row = SensorGrid();
row.Children.Add(BodyText(sensor.Hardware));
var metric = BodyText($"{sensor.SensorType} · {sensor.Name}");
Grid.SetColumn(metric, 1);
row.Children.Add(metric);
var value = ModernUi.Text($"{sensor.Value:0.##} {sensor.Unit}".Trim(), 12.5, FontWeights.SemiBold, ModernUi.Accent, maxLines: 1);
value.VerticalAlignment = VerticalAlignment.Center;
Grid.SetColumn(value, 2);
row.Children.Add(value);
var source = BodyText(sensor.Provider);
Grid.SetColumn(source, 3);
row.Children.Add(source);
_sensorRows.Children.Add(row);
foreach (var sensor in group.OrderBy(sensor => sensor.SensorType).ThenBy(sensor => sensor.Name))
{
groupRows.Children.Add(BuildSensorReadingRow(sensor));
}
_sensorRows.Children.Add(ModernUi.Card(groupRows, new Thickness(0), radius: 8));
}
if (sensors.Count == 0)
{
_sensorRows.Children.Add(new Border
@@ -454,6 +450,45 @@ public sealed class HardwarePage : Page
}
}
private FrameworkElement BuildSensorReadingRow(SensorReading sensor)
{
if (_compactLayout)
{
var compactRow = new Grid { MinHeight = 50, Padding = new Thickness(12, 7, 12, 7), ColumnSpacing = 10 };
compactRow.ColumnDefinitions.Add(new ColumnDefinition());
compactRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
compactRow.Children.Add(ModernUi.Text(SensorMetricText(sensor), 11.5, foreground: ModernUi.TextSecondary, maxLines: 1));
var reading = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
Spacing = 1,
Children =
{
ModernUi.Text($"{sensor.Value:0.##} {sensor.Unit}".Trim(), 12.5, FontWeights.SemiBold, ModernUi.Accent, maxLines: 1),
ModernUi.Text(sensor.Provider, 10.5, foreground: ModernUi.TextSecondary, maxLines: 1)
}
};
Grid.SetColumn(reading, 1);
compactRow.Children.Add(reading);
return compactRow;
}
var row = SensorGrid();
row.Children.Add(BodyText(SensorHardwareText(sensor.Hardware)));
var metric = BodyText(SensorMetricText(sensor));
Grid.SetColumn(metric, 1);
row.Children.Add(metric);
var value = ModernUi.Text($"{sensor.Value:0.##} {sensor.Unit}".Trim(), 12.5, FontWeights.SemiBold, ModernUi.Accent, maxLines: 1);
value.VerticalAlignment = VerticalAlignment.Center;
Grid.SetColumn(value, 2);
row.Children.Add(value);
var source = BodyText(sensor.Provider);
Grid.SetColumn(source, 3);
row.Children.Add(source);
return row;
}
private async Task EnableElevatedSensorsAsync()
{
_elevateButton.IsEnabled = false;
@@ -514,10 +549,6 @@ public sealed class HardwarePage : Page
? new Thickness(14, 16, 14, 24)
: new Thickness(28, 22, 36, 32);
}
if (_sensorHeader is not null)
{
_sensorHeader.Visibility = compact ? Visibility.Collapsed : Visibility.Visible;
}
if (_compactLayout == compact)
{
return;
@@ -553,13 +584,41 @@ public sealed class HardwarePage : Page
}
private static SensorReading? FindGpuSensor(HardwareSnapshot snapshot, string sensorType)
=> snapshot.Sensors.FirstOrDefault(sensor =>
sensor.HardwareType.Contains("Gpu", StringComparison.OrdinalIgnoreCase) &&
sensor.SensorType.Equals(sensorType, StringComparison.OrdinalIgnoreCase) &&
sensor.Name.Contains("Core", StringComparison.OrdinalIgnoreCase))
?? snapshot.Sensors.FirstOrDefault(sensor =>
{
return snapshot.Sensors
.Where(sensor =>
sensor.HardwareType.Contains("Gpu", StringComparison.OrdinalIgnoreCase) &&
sensor.SensorType.Equals(sensorType, StringComparison.OrdinalIgnoreCase));
sensor.SensorType.Equals(sensorType, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(sensor => PrimaryGpuSensorScore(sensor, snapshot.Summary.GpuName))
.ThenByDescending(sensor => sensor.Name.Contains("Core", StringComparison.OrdinalIgnoreCase))
.FirstOrDefault();
}
private static int PrimaryGpuSensorScore(SensorReading sensor, string primaryGpuName)
{
if (string.IsNullOrWhiteSpace(primaryGpuName) || primaryGpuName.Equals("Unknown GPU", StringComparison.OrdinalIgnoreCase))
{
return 0;
}
var hardware = sensor.Hardware.ToLowerInvariant();
var primary = primaryGpuName.ToLowerInvariant();
if (hardware.Contains(primary, StringComparison.OrdinalIgnoreCase) || primary.Contains(hardware, StringComparison.OrdinalIgnoreCase))
{
return 1000;
}
var score = primary
.Split([' ', '(', ')', '[', ']', '-', '_'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(token => token.Length >= 3)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count(token => hardware.Contains(token, StringComparison.OrdinalIgnoreCase)) * 25;
if (primary.Contains("nvidia") && hardware.Contains("nvidia")) score += 400;
if ((primary.Contains("amd") || primary.Contains("radeon")) && (hardware.Contains("amd") || hardware.Contains("radeon"))) score += 400;
if (primary.Contains("intel") && primary.Contains("arc") && hardware.Contains("intel") && hardware.Contains("arc")) score += 400;
return score;
}
private static string Percent(double? value) => value is null ? "--" : $"{value:0}%";
@@ -570,4 +629,91 @@ public sealed class HardwarePage : Page
HardwareProviderAvailability.Degraded => AppLocalizer.T("已降级", "Degraded"),
_ => AppLocalizer.T("不可用", "Unavailable")
};
private static string ProviderMessage(HardwareProviderStatus provider)
{
if (AppLocalizer.IsEnglish)
{
return provider.Message;
}
return provider.Id.ToLowerInvariant() switch
{
"windows" => "CPU、内存和磁盘计数器可用。",
"windows-inventory" when provider.Availability == HardwareProviderAvailability.Available => "已通过 SMBIOS、PnP、EDID 和 WMI 识别静态设备。",
"windows-inventory" => "当前无法读取静态设备身份。",
"libre-hardware-monitor" when provider.Availability == HardwareProviderAvailability.Available => "已读取主板及设备传感器。",
"libre-hardware-monitor" when provider.Availability == HardwareProviderAvailability.RequiresElevation => "部分传感器需要管理员权限。",
"libre-hardware-monitor" when provider.Availability == HardwareProviderAvailability.Degraded => "硬件传感器返回的数据不完整。",
"libre-hardware-monitor" => "硬件监控宿主未安装或无法启动。",
"nvidia-nvml" when provider.Availability == HardwareProviderAvailability.Available => "NVIDIA 设备遥测可用。",
"nvidia-nvml" when provider.Availability == HardwareProviderAvailability.Degraded => "NVIDIA 驱动遥测数据不完整。",
"nvidia-nvml" => "当前 NVIDIA 驱动未提供可用的 NVML 遥测。",
_ => AppLocalizer.SanitizeSensitiveText(provider.Message, 120)
};
}
private static string InventoryCategoryText(string category) => category switch
{
"Processor" => AppLocalizer.T("处理器", "Processor"),
"Graphics" => AppLocalizer.T("显卡", "Graphics"),
"Memory" => AppLocalizer.T("内存", "Memory"),
"Storage" => AppLocalizer.T("存储", "Storage"),
"Display" => AppLocalizer.T("显示器", "Display"),
"Network" => AppLocalizer.T("网络", "Network"),
_ => category
};
private static string SensorMetricText(SensorReading sensor)
=> $"{SensorTypeText(sensor.SensorType)} · {SensorNameText(sensor.Name)}";
private static string SensorTypeText(string sensorType) => sensorType switch
{
"Load" => AppLocalizer.T("负载", "Load"),
"Temperature" => AppLocalizer.T("温度", "Temperature"),
"Clock" => AppLocalizer.T("频率", "Clock"),
"Frequency" => AppLocalizer.T("频率", "Frequency"),
"Power" => AppLocalizer.T("功耗", "Power"),
"Fan" => AppLocalizer.T("风扇", "Fan"),
"Flow" => AppLocalizer.T("流量", "Flow"),
"Data" => AppLocalizer.T("数据量", "Data"),
"SmallData" => AppLocalizer.T("数据量", "Data"),
"Throughput" => AppLocalizer.T("吞吐量", "Throughput"),
"Voltage" => AppLocalizer.T("电压", "Voltage"),
"Current" => AppLocalizer.T("电流", "Current"),
"Level" => AppLocalizer.T("水平", "Level"),
"Control" => AppLocalizer.T("控制", "Control"),
"Factor" => AppLocalizer.T("系数", "Factor"),
"TimeSpan" => AppLocalizer.T("时长", "Duration"),
"Energy" => AppLocalizer.T("能量", "Energy"),
"Noise" => AppLocalizer.T("噪声", "Noise"),
_ => sensorType
};
private static string SensorNameText(string name)
{
if (AppLocalizer.IsEnglish)
{
return name;
}
return name
.Replace("GPU Memory Used", "GPU 已用显存", StringComparison.OrdinalIgnoreCase)
.Replace("GPU Memory Total", "GPU 总显存", StringComparison.OrdinalIgnoreCase)
.Replace("GPU Memory", "GPU 显存", StringComparison.OrdinalIgnoreCase)
.Replace("GPU Core", "GPU 核心", StringComparison.OrdinalIgnoreCase)
.Replace("CPU Core", "CPU 核心", StringComparison.OrdinalIgnoreCase)
.Replace("Memory Used", "已用内存", StringComparison.OrdinalIgnoreCase)
.Replace("Memory Total", "总内存", StringComparison.OrdinalIgnoreCase)
.Replace("Total", "总计", StringComparison.OrdinalIgnoreCase)
.Replace("Used", "已用", StringComparison.OrdinalIgnoreCase);
}
private static string SensorHardwareText(string hardware) => hardware switch
{
"System" => AppLocalizer.T("系统", "System"),
"System memory" => AppLocalizer.T("系统内存", "System memory"),
"System drive" => AppLocalizer.T("系统盘", "System drive"),
_ => hardware
};
}
+110 -67
View File
@@ -25,15 +25,15 @@ public sealed class NetworkMusicPage : Page
private readonly IMusicProvider _provider = AppServices.GetRequiredService<IMusicProvider>();
private readonly IMusicPlaybackService _playback = AppServices.GetRequiredService<IMusicPlaybackService>();
private readonly IDesktopOverlayService _overlayService = AppServices.GetRequiredService<IDesktopOverlayService>();
private readonly AutoSuggestBox _search = new() { PlaceholderText = "搜索歌曲、歌手或歌单", MinWidth = 0 };
private readonly AutoSuggestBox _search = new() { PlaceholderText = AppLocalizer.T("搜索歌曲、歌手或歌单", "Search songs, artists, or playlists"), MinWidth = 0 };
private readonly ComboBox _searchKind = new() { Width = 112 };
private readonly ListView _results = new() { SelectionMode = ListViewSelectionMode.None, IsItemClickEnabled = true };
private readonly TextBlock _sectionTitle = Text("发现音乐", 22, FontWeights.SemiBold);
private readonly TextBlock _sectionMeta = Text("网易云音乐", 12, foreground: SecondaryTextBrush);
private readonly TextBlock _sectionTitle = Text(AppLocalizer.T("发现音乐", "Discover"), 22, FontWeights.SemiBold);
private readonly TextBlock _sectionMeta = Text(AppLocalizer.T("网易云音乐", "NetEase Cloud Music"), 12, foreground: SecondaryTextBrush);
private readonly StackPanel _lyrics = new() { Spacing = 12 };
private readonly ScrollViewer _lyricsScroll = new() { VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
private readonly TextBlock _trackTitle = Text("未在播放", 14, FontWeights.SemiBold, maxLines: 1);
private readonly TextBlock _trackArtist = Text("从搜索或歌单中选择歌曲", 11, foreground: SecondaryTextBrush, maxLines: 1);
private readonly TextBlock _trackTitle = Text(AppLocalizer.T("未在播放", "Nothing playing"), 14, FontWeights.SemiBold, maxLines: 1);
private readonly TextBlock _trackArtist = Text(AppLocalizer.T("从搜索或歌单中选择歌曲", "Choose a song from search or a playlist"), 11, foreground: SecondaryTextBrush, maxLines: 1);
private readonly Button _playPauseButton;
private readonly Button _modeButton;
private readonly Button _favoriteButton;
@@ -55,10 +55,10 @@ public sealed class NetworkMusicPage : Page
public NetworkMusicPage()
{
Background = BackgroundBrush;
_playPauseButton = IconButton("\uE768", "播放或暂停", () => _playback.PlayPause());
_modeButton = IconButton("\uE8EE", "顺序播放", ToggleMode);
_favoriteButton = IconButton("\uEB51", "收藏当前歌曲", async () => await FavoriteCurrentAsync());
_desktopLyricsButton = IconButton("\uE8D2", "桌面歌词", async () => await ToggleDesktopLyricsAsync());
_playPauseButton = IconButton("\uE768", AppLocalizer.T("播放或暂停", "Play or pause"), () => _playback.PlayPause());
_modeButton = IconButton("\uE8EE", AppLocalizer.T("顺序播放", "Play in order"), ToggleMode);
_favoriteButton = IconButton("\uEB51", AppLocalizer.T("收藏当前歌曲", "Favorite current song"), async () => await FavoriteCurrentAsync());
_desktopLyricsButton = IconButton("\uE8D2", AppLocalizer.T("桌面歌词", "Desktop lyrics"), async () => await ToggleDesktopLyricsAsync());
Content = BuildContent();
Loaded += NetworkMusicPage_Loaded;
Unloaded += NetworkMusicPage_Unloaded;
@@ -69,11 +69,18 @@ public sealed class NetworkMusicPage : Page
private UIElement BuildContent()
{
_searchKind.Items.Add("歌曲");
_searchKind.Items.Add("歌手");
_searchKind.Items.Add("歌单");
_searchKind.Items.Add(AppLocalizer.T("歌曲", "Songs"));
_searchKind.Items.Add(AppLocalizer.T("歌手", "Artists"));
_searchKind.Items.Add(AppLocalizer.T("歌单", "Playlists"));
_searchKind.SelectedIndex = 0;
foreach (var quality in new[] { "超清母带", "高清臻音", "无损", "极高", "标准" }) _quality.Items.Add(quality);
foreach (var quality in new[]
{
AppLocalizer.T("超清母带", "Hi-Res master"),
AppLocalizer.T("高清臻音", "Spatial audio"),
AppLocalizer.T("无损", "Lossless"),
AppLocalizer.T("极高", "Very high"),
AppLocalizer.T("标准", "Standard")
}) _quality.Items.Add(quality);
_quality.SelectedIndex = 1;
_quality.SelectionChanged += (_, _) => _playback.Quality = QualityFromIndex(_quality.SelectedIndex);
_volume.Value = _playback.Volume;
@@ -158,7 +165,7 @@ public sealed class NetworkMusicPage : Page
_search.Margin = new Thickness(18, 0, 0, 0);
grid.Children.Add(_search);
grid.Children.Add(_searchKind);
var account = IconButton("\uE77B", "网易云账户", async () => await ShowAccountDialogAsync());
var account = IconButton("\uE77B", AppLocalizer.T("网易云账户", "NetEase Cloud Music account"), async () => await ShowAccountDialogAsync());
grid.Children.Add(account);
ArrangeTopBar(grid, brand, account, double.PositiveInfinity);
grid.SizeChanged += (_, args) => ArrangeTopBar(grid, brand, account, args.NewSize.Width);
@@ -210,15 +217,15 @@ public sealed class NetworkMusicPage : Page
private FrameworkElement BuildSidebar()
{
var panel = new StackPanel { Padding = new Thickness(10, 14, 10, 14), Spacing = 5 };
panel.Children.Add(NavButton("\uE8D6", "发现音乐", async () => await LoadRecommendedAsync()));
panel.Children.Add(NavButton("\uE121", "每日推荐", async () => await LoadDailyAsync()));
panel.Children.Add(NavButton("\uE9D9", "官方榜单", async () => await LoadChartsAsync()));
panel.Children.Add(NavButton("\uE8D5", "我的歌单", async () => await LoadUserPlaylistsAsync()));
panel.Children.Add(NavButton("\uE8D6", AppLocalizer.T("发现音乐", "Discover"), async () => await LoadRecommendedAsync()));
panel.Children.Add(NavButton("\uE121", AppLocalizer.T("每日推荐", "Daily recommendations"), async () => await LoadDailyAsync()));
panel.Children.Add(NavButton("\uE9D9", AppLocalizer.T("官方榜单", "Charts"), async () => await LoadChartsAsync()));
panel.Children.Add(NavButton("\uE8D5", AppLocalizer.T("我的歌单", "My playlists"), async () => await LoadUserPlaylistsAsync()));
panel.Children.Add(new Border { Height = 1, Margin = new Thickness(6, 10, 6, 10), Background = StrokeBrush });
panel.Children.Add(NavButton("\uE8FD", "当前队列", () =>
panel.Children.Add(NavButton("\uE8FD", AppLocalizer.T("当前队列", "Queue"), () =>
{
_sectionTitle.Text = "当前队列";
_sectionMeta.Text = $"{_playback.Queue.Items.Count} 首歌曲";
_sectionTitle.Text = AppLocalizer.T("当前队列", "Queue");
_sectionMeta.Text = SongCount(_playback.Queue.Items.Count);
RenderSongs(_playback.Queue.Items);
}));
return new Border
@@ -254,7 +261,7 @@ public sealed class NetworkMusicPage : Page
private FrameworkElement BuildLyricsPanel()
{
_lyrics.Children.Add(Text("播放歌曲后显示逐字与翻译歌词", 13, foreground: SecondaryTextBrush));
_lyrics.Children.Add(Text(AppLocalizer.T("播放歌曲后显示逐字与翻译歌词", "Word-timed and translated lyrics appear during playback"), 13, foreground: SecondaryTextBrush));
_lyricsScroll.Content = _lyrics;
return new Border
{
@@ -271,7 +278,7 @@ public sealed class NetworkMusicPage : Page
},
Children =
{
Text("歌词", 17, FontWeights.SemiBold),
Text(AppLocalizer.T("歌词", "Lyrics"), 17, FontWeights.SemiBold),
LyricScroller()
}
}
@@ -305,9 +312,9 @@ public sealed class NetworkMusicPage : Page
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
primaryTransport.Children.Add(IconButton("\uE892", "上一首", async () => await RunPlayerActionAsync(() => _playback.PreviousAsync())));
primaryTransport.Children.Add(IconButton("\uE892", AppLocalizer.T("上一首", "Previous"), async () => await RunPlayerActionAsync(() => _playback.PreviousAsync())));
primaryTransport.Children.Add(_playPauseButton);
primaryTransport.Children.Add(IconButton("\uE893", "下一首", async () => await RunPlayerActionAsync(() => _playback.NextAsync())));
primaryTransport.Children.Add(IconButton("\uE893", AppLocalizer.T("下一首", "Next"), async () => await RunPlayerActionAsync(() => _playback.NextAsync())));
primaryTransport.Children.Add(_modeButton);
primaryTransport.Children.Add(_favoriteButton);
var transport = new Grid { ColumnSpacing = 8, VerticalAlignment = VerticalAlignment.Center };
@@ -425,20 +432,20 @@ public sealed class NetworkMusicPage : Page
{
var kind = _searchKind.SelectedIndex switch { 1 => MusicSearchKind.Artists, 2 => MusicSearchKind.Playlists, _ => MusicSearchKind.Songs };
var result = await _provider.SearchAsync(query, kind);
_sectionTitle.Text = $"搜索:{query}";
_sectionTitle.Text = AppLocalizer.T($"搜索:{query}", $"Search: {query}");
if (kind == MusicSearchKind.Artists)
{
_sectionMeta.Text = $"{result.Artists.Count} 位歌手";
_sectionMeta.Text = ArtistCount(result.Artists.Count);
RenderArtists(result.Artists);
}
else if (kind == MusicSearchKind.Playlists)
{
_sectionMeta.Text = $"{result.Playlists.Count} 个歌单";
_sectionMeta.Text = PlaylistCount(result.Playlists.Count);
RenderPlaylists(result.Playlists);
}
else
{
_sectionMeta.Text = $"{result.Songs.Count} 首歌曲";
_sectionMeta.Text = SongCount(result.Songs.Count);
RenderSongs(result.Songs);
}
});
@@ -449,33 +456,33 @@ public sealed class NetworkMusicPage : Page
private async Task LoadRecommendedCoreAsync()
{
var playlists = await _provider.GetRecommendedPlaylistsAsync();
_sectionTitle.Text = "发现音乐";
_sectionMeta.Text = "推荐歌单";
_sectionTitle.Text = AppLocalizer.T("发现音乐", "Discover");
_sectionMeta.Text = AppLocalizer.T("推荐歌单", "Recommended playlists");
RenderPlaylists(playlists);
}
private Task LoadDailyAsync() => RunBusyAsync(async () =>
{
if (!_provider.LoginState.LoggedIn) throw new InvalidOperationException("每日推荐需要先登录网易云音乐。");
if (!_provider.LoginState.LoggedIn) throw new InvalidOperationException(AppLocalizer.T("每日推荐需要先登录网易云音乐。", "Sign in to NetEase Cloud Music to view daily recommendations."));
var songs = await _provider.GetDailySongsAsync();
_sectionTitle.Text = "每日推荐";
_sectionMeta.Text = $"{songs.Count} 首歌曲";
_sectionTitle.Text = AppLocalizer.T("每日推荐", "Daily recommendations");
_sectionMeta.Text = SongCount(songs.Count);
RenderSongs(songs);
});
private Task LoadChartsAsync() => RunBusyAsync(async () =>
{
var playlists = await _provider.GetChartsAsync();
_sectionTitle.Text = "官方榜单";
_sectionMeta.Text = "网易云音乐榜单";
_sectionTitle.Text = AppLocalizer.T("官方榜单", "Charts");
_sectionMeta.Text = AppLocalizer.T("网易云音乐榜单", "NetEase Cloud Music charts");
RenderPlaylists(playlists);
});
private Task LoadUserPlaylistsAsync() => RunBusyAsync(async () =>
{
if (!_provider.LoginState.LoggedIn) throw new InvalidOperationException("请先登录网易云音乐。");
if (!_provider.LoginState.LoggedIn) throw new InvalidOperationException(AppLocalizer.T("请先登录网易云音乐。", "Sign in to NetEase Cloud Music first."));
var playlists = await _provider.GetUserPlaylistsAsync();
_sectionTitle.Text = "我的歌单";
_sectionTitle.Text = AppLocalizer.T("我的歌单", "My playlists");
_sectionMeta.Text = _provider.LoginState.Nickname;
RenderPlaylists(playlists);
});
@@ -500,7 +507,7 @@ public sealed class NetworkMusicPage : Page
{
var songs = await _provider.GetPlaylistTracksAsync(playlist.Id, 0, 200);
_sectionTitle.Text = playlist.Name;
_sectionMeta.Text = $"{playlist.Creator} · {playlist.TrackCount}";
_sectionMeta.Text = $"{playlist.Creator} · {SongCount(playlist.TrackCount)}";
RenderSongs(songs);
});
@@ -508,7 +515,7 @@ public sealed class NetworkMusicPage : Page
{
var songs = await _provider.GetArtistSongsAsync(artist.Id);
_sectionTitle.Text = artist.Name;
_sectionMeta.Text = $"{songs.Count} 首热门歌曲";
_sectionMeta.Text = AppLocalizer.T($"{songs.Count} 首热门歌曲", $"{songs.Count} popular songs");
RenderSongs(songs);
});
@@ -566,7 +573,7 @@ public sealed class NetworkMusicPage : Page
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(100) });
grid.Children.Add(Cover(playlist.CoverUrl, 38));
AddColumn(grid, new StackPanel { Spacing = 2, Children = { Text(playlist.Name, 13, FontWeights.SemiBold, maxLines: 1), Text(playlist.Creator, 11, foreground: SecondaryTextBrush, maxLines: 1) } }, 1);
AddColumn(grid, Text($"{playlist.TrackCount} 首", 11, foreground: SecondaryTextBrush, maxLines: 1), 2);
AddColumn(grid, Text(SongCount(playlist.TrackCount), 11, foreground: SecondaryTextBrush, maxLines: 1), 2);
return RowItem(playlist, grid);
}
@@ -578,7 +585,7 @@ public sealed class NetworkMusicPage : Page
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(100) });
grid.Children.Add(Cover(artist.PictureUrl, 38));
AddColumn(grid, Text(artist.Name, 13, FontWeights.SemiBold, maxLines: 1), 1);
AddColumn(grid, Text($"{artist.TrackCount} 首", 11, foreground: SecondaryTextBrush, maxLines: 1), 2);
AddColumn(grid, Text(SongCount(artist.TrackCount), 11, foreground: SecondaryTextBrush, maxLines: 1), 2);
return RowItem(artist, grid);
}
@@ -588,7 +595,7 @@ public sealed class NetworkMusicPage : Page
_activeLyricIndex = -1;
if (lyrics.Lines.Count == 0)
{
_lyrics.Children.Add(Text("当前歌曲没有可用歌词", 13, foreground: SecondaryTextBrush));
_lyrics.Children.Add(Text(AppLocalizer.T("当前歌曲没有可用歌词", "No lyrics are available for this song"), 13, foreground: SecondaryTextBrush));
return;
}
foreach (var line in lyrics.Lines)
@@ -603,8 +610,8 @@ public sealed class NetworkMusicPage : Page
private void RefreshPlaybackUi()
{
var song = _playback.CurrentSong;
_trackTitle.Text = song?.Name ?? "未在播放";
_trackArtist.Text = song is null ? "从搜索或歌单中选择歌曲" : $"{song.Artist} · {song.Album}";
_trackTitle.Text = song?.Name ?? AppLocalizer.T("未在播放", "Nothing playing");
_trackArtist.Text = song is null ? AppLocalizer.T("从搜索或歌单中选择歌曲", "Choose a song from search or a playlist") : $"{song.Artist} · {song.Album}";
if (_playPauseButton.Content is FontIcon icon) icon.Glyph = _playback.IsPlaying ? "\uE769" : "\uE768";
_syncingPosition = true;
_position.Maximum = Math.Max(1, _playback.Duration.TotalSeconds);
@@ -648,10 +655,14 @@ public sealed class NetworkMusicPage : Page
_overlayService.SetDesktopLyrics(
song?.Name ?? "YMhut Music",
song?.Artist ?? string.Empty,
line?.Text ?? "播放歌曲后显示桌面歌词",
line?.Text ?? AppLocalizer.T("播放歌曲后显示桌面歌词", "Desktop lyrics appear during playback"),
line?.Translation);
await _overlayService.ToggleAsync(DesktopOverlayService.DesktopLyrics);
ToolTipService.SetToolTip(_desktopLyricsButton, _overlayService.IsVisible(DesktopOverlayService.DesktopLyrics) ? "关闭桌面歌词" : "桌面歌词");
ToolTipService.SetToolTip(
_desktopLyricsButton,
_overlayService.IsVisible(DesktopOverlayService.DesktopLyrics)
? AppLocalizer.T("关闭桌面歌词", "Close desktop lyrics")
: AppLocalizer.T("桌面歌词", "Desktop lyrics"));
}
private void ToggleMode()
@@ -664,9 +675,9 @@ public sealed class NetworkMusicPage : Page
};
var (glyph, text) = _playback.Queue.Mode switch
{
MusicPlayMode.Shuffle => ("\uE8B1", "随机播放"),
MusicPlayMode.RepeatOne => ("\uE8ED", "单曲循环"),
_ => ("\uE8EE", "顺序播放")
MusicPlayMode.Shuffle => ("\uE8B1", AppLocalizer.T("随机播放", "Shuffle")),
MusicPlayMode.RepeatOne => ("\uE8ED", AppLocalizer.T("单曲循环", "Repeat one")),
_ => ("\uE8EE", AppLocalizer.T("顺序播放", "Play in order"))
};
if (_modeButton.Content is FontIcon icon) icon.Glyph = glyph;
ToolTipService.SetToolTip(_modeButton, text);
@@ -678,7 +689,7 @@ public sealed class NetworkMusicPage : Page
await RunBusyAsync(async () =>
{
await _provider.SetFavoriteAsync(_playback.CurrentSong.Id, true);
ShowStatus("已收藏", "歌曲已加入我喜欢的音乐。", InfoBarSeverity.Success);
ShowStatus(AppLocalizer.T("已收藏", "Favorited"), AppLocalizer.T("歌曲已加入我喜欢的音乐。", "The song was added to your liked music."), InfoBarSeverity.Success);
});
}
@@ -686,35 +697,49 @@ public sealed class NetworkMusicPage : Page
{
await _provider.InitializeAsync();
var qrImage = new Image { Width = 190, Height = 190, Stretch = Stretch.Uniform };
var qrStatus = Text("正在创建登录二维码…", 12, foreground: SecondaryTextBrush, maxLines: 2);
var cookie = new TextBox { Header = "Cookie 登录", PlaceholderText = "粘贴包含 MUSIC_U 的 Cookie", AcceptsReturn = true, MinHeight = 72, TextWrapping = TextWrapping.Wrap };
var qrStatus = Text(AppLocalizer.T("正在创建登录二维码…", "Creating sign-in QR code..."), 12, foreground: SecondaryTextBrush, maxLines: 2);
var cookie = new TextBox
{
Header = AppLocalizer.T("Cookie 登录", "Cookie sign-in"),
PlaceholderText = AppLocalizer.T("粘贴包含 MUSIC_U 的 Cookie", "Paste a cookie containing MUSIC_U"),
AcceptsReturn = true,
MinHeight = 72,
TextWrapping = TextWrapping.Wrap
};
var qrCts = new CancellationTokenSource();
var content = new StackPanel { Spacing = 12 };
var accountState = Text(_provider.LoginState.LoggedIn ? $"已登录:{_provider.LoginState.Nickname}" : "未登录", 14, FontWeights.SemiBold);
var accountState = Text(
_provider.LoginState.LoggedIn
? AppLocalizer.T($"已登录:{_provider.LoginState.Nickname}", $"Signed in: {_provider.LoginState.Nickname}")
: AppLocalizer.T("未登录", "Not signed in"),
14,
FontWeights.SemiBold);
content.Children.Add(accountState);
content.Children.Add(qrImage);
content.Children.Add(qrStatus);
content.Children.Add(cookie);
var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
actions.Children.Add(DarkButton("Cookie 登录", "\uE8D7", async () =>
actions.Children.Add(DarkButton(AppLocalizer.T("Cookie 登录", "Cookie sign-in"), "\uE8D7", async () =>
{
var state = await _provider.LoginWithCookieAsync(cookie.Text);
accountState.Text = state.LoggedIn ? $"已登录:{state.Nickname}" : "Cookie 无效或已过期";
if (state.LoggedIn) qrStatus.Text = "登录成功,凭据已使用 Windows DPAPI 加密保存。";
accountState.Text = state.LoggedIn
? AppLocalizer.T($"已登录:{state.Nickname}", $"Signed in: {state.Nickname}")
: AppLocalizer.T("Cookie 无效或已过期", "The cookie is invalid or expired");
if (state.LoggedIn) qrStatus.Text = AppLocalizer.T("登录成功,凭据已使用 Windows DPAPI 加密保存。", "Signed in. Credentials are encrypted with Windows DPAPI.");
}, primary: true));
actions.Children.Add(DarkButton("退出登录", "\uE8AC", async () =>
actions.Children.Add(DarkButton(AppLocalizer.T("退出登录", "Sign out"), "\uE8AC", async () =>
{
await _provider.LogoutAsync();
accountState.Text = "未登录";
accountState.Text = AppLocalizer.T("未登录", "Not signed in");
}));
content.Children.Add(actions);
content.Children.Add(Text("登录仅用于网易云音乐账户能力;不绕过会员、地区或版权限制。", 11, foreground: SecondaryTextBrush, maxLines: 3));
content.Children.Add(Text(AppLocalizer.T("登录仅用于网易云音乐账户能力;不绕过会员、地区或版权限制。", "Sign-in only enables account features; it does not bypass membership, regional, or copyright restrictions."), 11, foreground: SecondaryTextBrush, maxLines: 3));
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = "网易云音乐账户",
Title = AppLocalizer.T("网易云音乐账户", "NetEase Cloud Music account"),
Content = content,
CloseButtonText = "关闭",
CloseButtonText = AppLocalizer.T("关闭", "Close"),
DefaultButton = ContentDialogButton.Close
};
_ = CreateAndPollQrAsync(dialog, qrImage, qrStatus, accountState, qrCts.Token);
@@ -729,16 +754,16 @@ public sealed class NetworkMusicPage : Page
{
var session = await _provider.CreateQrSessionAsync(cancellationToken);
image.Source = await QrBitmapAsync(session.LoginUrl);
status.Text = "使用网易云音乐 App 扫码并确认登录";
status.Text = AppLocalizer.T("使用网易云音乐 App 扫码并确认登录", "Scan with the NetEase Cloud Music app and confirm sign-in");
while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
var state = await _provider.CheckQrSessionAsync(session, cancellationToken);
status.Text = state.Message;
status.Text = QrStatusText(state);
if (state.Completed)
{
account.Text = $"已登录:{_provider.LoginState.Nickname}";
status.Text = "登录成功,凭据已使用 Windows DPAPI 加密保存。";
account.Text = AppLocalizer.T($"已登录:{_provider.LoginState.Nickname}", $"Signed in: {_provider.LoginState.Nickname}");
status.Text = AppLocalizer.T("登录成功,凭据已使用 Windows DPAPI 加密保存。", "Signed in. Credentials are encrypted with Windows DPAPI.");
await Task.Delay(700, cancellationToken);
dialog.Hide();
break;
@@ -782,7 +807,7 @@ public sealed class NetworkMusicPage : Page
}
catch (Exception exception)
{
ShowStatus("操作未完成", exception.Message, InfoBarSeverity.Error);
ShowStatus(AppLocalizer.T("操作未完成", "Action not completed"), exception.Message, InfoBarSeverity.Error);
}
finally
{
@@ -801,6 +826,24 @@ public sealed class NetworkMusicPage : Page
_status.IsOpen = true;
}
private static string SongCount(int count)
=> AppLocalizer.T($"{count} 首歌曲", count == 1 ? "1 song" : $"{count} songs");
private static string ArtistCount(int count)
=> AppLocalizer.T($"{count} 位歌手", count == 1 ? "1 artist" : $"{count} artists");
private static string PlaylistCount(int count)
=> AppLocalizer.T($"{count} 个歌单", count == 1 ? "1 playlist" : $"{count} playlists");
private static string QrStatusText(MusicQrStatus state) => state.Code switch
{
800 => AppLocalizer.T("二维码已过期", "The QR code has expired"),
801 => AppLocalizer.T("等待扫码", "Waiting to be scanned"),
802 => AppLocalizer.T("已扫码,等待确认", "Scanned; waiting for confirmation"),
803 => AppLocalizer.T("登录成功", "Signed in"),
_ => AppLocalizer.IsEnglish ? state.Message : AppLocalizer.T("等待登录", "Waiting for sign-in")
};
private static Button NavButton(string glyph, string title, Func<Task> action)
=> DarkButton(title, glyph, async () => await action());
+21 -13
View File
@@ -1,5 +1,6 @@
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using YMhut.Box.Core.Tools;
using YMhut.Box.WinUI.Services;
@@ -13,7 +14,7 @@ public sealed class OptimizationPage : Page
private readonly GridView _grid = new()
{
SelectionMode = ListViewSelectionMode.None,
IsItemClickEnabled = true,
IsItemClickEnabled = false,
HorizontalContentAlignment = HorizontalAlignment.Stretch
};
private readonly ComboBox _scope = new() { MinWidth = 160 };
@@ -26,10 +27,6 @@ public sealed class OptimizationPage : Page
.ToArray();
Background = ModernUi.AppBackground;
Content = BuildContent();
_grid.ItemClick += (_, args) =>
{
if (args.ClickedItem is GridViewItem { Tag: NexNativeToolModule module }) _openTool(module);
};
_scope.SelectionChanged += (_, _) => RefreshItems();
_scope.SelectedIndex = 0;
}
@@ -100,16 +97,27 @@ public sealed class OptimizationPage : Page
ModernUi.SmallBadge(RiskText(module.Definition.Risk), RiskBrush(module.Definition.Risk), ModernUi.SurfaceAlt)
}
};
_grid.Items.Add(new GridViewItem
var button = new Button
{
Width = 224,
Height = 118,
Content = ModernUi.Card(content, new Thickness(13), radius: 8),
Tag = module,
Padding = new Thickness(4),
Width = 228,
Height = 112,
Content = content,
Padding = new Thickness(13),
CornerRadius = new CornerRadius(8),
Background = ModernUi.Surface,
BorderBrush = ModernUi.Stroke,
BorderThickness = new Thickness(1),
HorizontalContentAlignment = HorizontalAlignment.Stretch,
VerticalContentAlignment = VerticalAlignment.Stretch
});
VerticalContentAlignment = VerticalAlignment.Stretch,
UseSystemFocusVisuals = true
};
ModernUi.ApplyHoverResources(button, ModernUi.HoverSurface, ModernUi.StrokeStrong);
button.Click += (_, _) => _openTool(module);
var name = module.Definition.CurrentName(IsEnglish);
ToolTipService.SetToolTip(button, name);
AutomationProperties.SetName(button, name);
AutomationProperties.SetHelpText(button, module.Definition.CurrentDescription(IsEnglish));
_grid.Items.Add(button);
}
}
+16 -12
View File
@@ -623,7 +623,7 @@ public sealed class SettingsPage : Page
: FormatBytes(metrics.WorkingSetBytes);
var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64);
var uptimeText = uptime.TotalDays >= 1
? $"{(int)uptime.TotalDays}d {uptime:hh\\:mm}"
? AppLocalizer.T($"{(int)uptime.TotalDays} 天 {uptime:hh\\:mm}", $"{(int)uptime.TotalDays}d {uptime:hh\\:mm}")
: uptime.ToString("hh\\:mm\\:ss");
_controlUptimeStatus.Text = uptimeText;
_uptimeStatus.Text = uptimeText;
@@ -711,7 +711,7 @@ public sealed class SettingsPage : Page
}
return duration.TotalDays >= 1
? $"{(int)duration.TotalDays}d {duration:hh\\:mm}"
? AppLocalizer.T($"{(int)duration.TotalDays} 天 {duration:hh\\:mm}", $"{(int)duration.TotalDays}d {duration:hh\\:mm}")
: duration.ToString("hh\\:mm\\:ss");
}
@@ -2373,13 +2373,13 @@ public sealed class SettingsPage : Page
{
var targets = new[]
{
("国内 · 央视网", new Uri("https://www.cctv.com/")),
("国内 · 中国政府网", new Uri("https://www.gov.cn/")),
("国内 · 百度", new Uri("https://www.baidu.com/")),
("国际 · Microsoft", new Uri("https://www.microsoft.com/")),
("国际 · Cloudflare", new Uri("https://www.cloudflare.com/cdn-cgi/trace")),
("国际 · GitHub", new Uri("https://github.com/")),
("更新 · YMhut", new Uri("https://update.ymhut.cn/update-info.json"))
(AppLocalizer.T("国内 · 央视网", "China · CCTV"), new Uri("https://www.cctv.com/")),
(AppLocalizer.T("国内 · 中国政府网", "China · State Council"), new Uri("https://www.gov.cn/")),
(AppLocalizer.T("国内 · 百度", "China · Baidu"), new Uri("https://www.baidu.com/")),
(AppLocalizer.T("国际 · Microsoft", "Global · Microsoft"), new Uri("https://www.microsoft.com/")),
(AppLocalizer.T("国际 · Cloudflare", "Global · Cloudflare"), new Uri("https://www.cloudflare.com/cdn-cgi/trace")),
(AppLocalizer.T("国际 · GitHub", "Global · GitHub"), new Uri("https://github.com/")),
(AppLocalizer.T("更新 · YMhut", "Updates · YMhut"), new Uri("https://update.ymhut.cn/update-info.json"))
};
var resultPanel = new StackPanel { Spacing = 10 };
@@ -2630,7 +2630,11 @@ public sealed class SettingsPage : Page
await _settingsService.UpdateAsync(settings => settings.PluginsEnabled = _pluginSwitch.IsOn);
}
_settings.PluginsEnabled = _pluginSwitch.IsOn;
ToastService.Show(_pluginSwitch.IsOn ? "插件系统已开启" : "插件系统已关闭", ToastKind.Success);
ToastService.Show(
_pluginSwitch.IsOn
? AppLocalizer.T("插件系统已开启", "Plugin system enabled")
: AppLocalizer.T("插件系统已关闭", "Plugin system disabled"),
ToastKind.Success);
}
private async void ToolboxCompactSwitch_Toggled(object sender, RoutedEventArgs e)
@@ -2763,7 +2767,7 @@ public sealed class SettingsPage : Page
_settings.PluginRootPath = folder.Path;
_pluginRootSummary.Text = CurrentPluginRoot();
Directory.CreateDirectory(CurrentPluginRoot());
ToastService.Show("插件根目录已更新", ToastKind.Success);
ToastService.Show(AppLocalizer.T("插件根目录已更新", "Plugin root updated"), ToastKind.Success);
}
private async Task ResetPluginRootAsync()
@@ -2779,7 +2783,7 @@ public sealed class SettingsPage : Page
_settings.PluginRootPath = string.Empty;
Directory.CreateDirectory(CurrentPluginRoot());
_pluginRootSummary.Text = CurrentPluginRoot();
ToastService.Show("插件根目录已恢复默认", ToastKind.Success);
ToastService.Show(AppLocalizer.T("插件根目录已恢复默认", "Plugin root reset to default"), ToastKind.Success);
}
private async Task OpenPluginRootAsync()
+22 -168
View File
@@ -17,7 +17,6 @@ public sealed class ToolboxPage : Page
private readonly ToolCatalog _catalog;
private readonly Action<IToolModule> _openTool;
private readonly Action<ToolboxSurfaceDefinition> _openSurface;
private readonly Action<ToolboxNavigationState>? _stateChanged;
private readonly ISettingsService _settingsService = AppServices.GetRequiredService<ISettingsService>();
private readonly IUiPerformanceCoordinator _uiPerformanceCoordinator = AppServices.GetRequiredService<IUiPerformanceCoordinator>();
@@ -79,14 +78,12 @@ public sealed class ToolboxPage : Page
internal ToolboxPage(
ToolCatalog catalog,
Action<IToolModule> openTool,
Action<ToolboxSurfaceDefinition> openSurface,
string query = "",
ToolboxNavigationState? state = null,
Action<ToolboxNavigationState>? stateChanged = null)
{
_catalog = catalog.Modules.Count == 0 ? new ToolCatalog() : catalog;
_openTool = openTool;
_openSurface = openSurface;
_stateChanged = stateChanged;
_selectedGroup = state?.SelectedGroup;
_query = !string.IsNullOrWhiteSpace(query) ? query : state?.SearchQuery ?? string.Empty;
@@ -286,7 +283,6 @@ public sealed class ToolboxPage : Page
};
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
root.RowDefinitions.Add(new RowDefinition());
root.SizeChanged += (_, e) =>
{
@@ -298,18 +294,14 @@ public sealed class ToolboxPage : Page
root.Children.Add(BuildHeader());
var surfacePanel = BuildFeaturedSurfacePanel();
Grid.SetRow(surfacePanel, 1);
root.Children.Add(surfacePanel);
var scopeBar = BuildScopeBar();
Grid.SetRow(scopeBar, 2);
Grid.SetRow(scopeBar, 1);
root.Children.Add(scopeBar);
var body = new Grid { ColumnSpacing = 16 };
body.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(224) });
body.ColumnDefinitions.Add(new ColumnDefinition());
Grid.SetRow(body, 3);
Grid.SetRow(body, 2);
root.Children.Add(body);
var categories = BuildCategoryPane();
@@ -341,156 +333,6 @@ public sealed class ToolboxPage : Page
return root;
}
private FrameworkElement BuildFeaturedSurfacePanel()
{
var cardGrid = new Grid { ColumnSpacing = 10, RowSpacing = 10 };
var cards = ToolboxSurfaceCatalog.All.Select(surface => BuildSurfaceCard(surface)).ToArray();
foreach (var card in cards)
{
cardGrid.Children.Add(card);
}
var compactCardRow = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8
};
foreach (var surface in ToolboxSurfaceCatalog.All)
{
compactCardRow.Children.Add(BuildSurfaceCard(surface, compact: true));
}
var compactScroller = new ScrollViewer
{
Content = compactCardRow,
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollMode = ScrollMode.Enabled,
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
VerticalScrollMode = ScrollMode.Disabled,
Visibility = Visibility.Collapsed
};
var cardHost = new Grid();
cardHost.Children.Add(cardGrid);
cardHost.Children.Add(compactScroller);
void Arrange(double width)
{
var compact = width < 660;
compactScroller.Visibility = compact ? Visibility.Visible : Visibility.Collapsed;
cardGrid.Visibility = compact ? Visibility.Collapsed : Visibility.Visible;
if (compact)
{
return;
}
var columns = width < 1040 ? 2 : 3;
cardGrid.ColumnDefinitions.Clear();
cardGrid.RowDefinitions.Clear();
for (var column = 0; column < columns; column++)
{
cardGrid.ColumnDefinitions.Add(new ColumnDefinition());
}
var rows = (int)Math.Ceiling(cards.Length / (double)columns);
for (var row = 0; row < rows; row++)
{
cardGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
}
for (var index = 0; index < cards.Length; index++)
{
Grid.SetColumn(cards[index], index % columns);
Grid.SetRow(cards[index], index / columns);
}
}
cardHost.SizeChanged += (_, args) => Arrange(args.NewSize.Width);
Arrange(1200);
return new StackPanel
{
Spacing = 9,
Children =
{
new Grid
{
Children =
{
ModernUi.Text(AppLocalizer.T("系统中心", "System centers"), 16, FontWeights.SemiBold),
new TextBlock
{
Text = AppLocalizer.T("独立界面", "Dedicated interfaces"),
FontSize = 12,
Foreground = ModernUi.TextSecondary,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center
}
}
},
cardHost
}
};
}
private Button BuildSurfaceCard(ToolboxSurfaceDefinition surface, bool compact = false)
{
var text = new StackPanel
{
Spacing = compact ? 2 : 4,
VerticalAlignment = VerticalAlignment.Center
};
text.Children.Add(ModernUi.Text(surface.CurrentTitle, compact ? 14 : 16, FontWeights.SemiBold, maxLines: 1));
if (!compact)
{
text.Children.Add(ModernUi.Text(surface.CurrentDescription, 12.5, foreground: ModernUi.TextSecondary, maxLines: 2));
}
text.Children.Add(ModernUi.Text(surface.Capability, compact ? 11 : 11.5, FontWeights.SemiBold, ModernUi.Accent, maxLines: 1));
var content = new Grid { ColumnSpacing = compact ? 9 : 12 };
content.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
content.ColumnDefinitions.Add(new ColumnDefinition());
content.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
content.Children.Add(ModernUi.IconTile(
surface.IconGlyph,
compact ? 38 : 46,
ModernUi.AccentSoft,
ModernUi.Accent,
compact ? 17 : 20));
Grid.SetColumn(text, 1);
content.Children.Add(text);
var chevron = new FontIcon
{
Glyph = "\uE76C",
FontSize = 13,
Foreground = ModernUi.TextSecondary,
VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(chevron, 2);
content.Children.Add(chevron);
var button = new Button
{
Width = compact ? 248 : double.NaN,
Height = compact ? 80 : double.NaN,
MinHeight = compact ? 80 : 112,
Padding = compact ? new Thickness(12) : new Thickness(14),
CornerRadius = new CornerRadius(8),
Background = ModernUi.Surface,
BorderBrush = ModernUi.Stroke,
BorderThickness = new Thickness(1),
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Stretch,
Content = content,
UseSystemFocusVisuals = true
};
ModernUi.ApplyHoverResources(button, ModernUi.HoverSurface, ModernUi.StrokeStrong);
button.Click += (_, _) => _openSurface(surface);
ToolTipService.SetToolTip(button, surface.CurrentTitle);
AutomationProperties.SetName(button, surface.CurrentTitle);
return button;
}
private FrameworkElement BuildHeader()
{
var title = new StackPanel
@@ -1023,7 +865,7 @@ public sealed class ToolboxPage : Page
yield return (AppLocalizer.T("标签", "Tags"), string.Join(", ", external.Tool.Tags));
break;
case BuiltinReferenceToolModule builtin:
yield return (AppLocalizer.T("内置类型", "Built-in kind"), builtin.Definition.Kind.ToString());
yield return (AppLocalizer.T("内置类型", "Built-in kind"), BuiltinKindText(builtin.Definition.Kind));
yield return (AppLocalizer.T("风险等级", "Risk level"), RiskLabel(builtin.Definition.RiskLevel));
yield return (AppLocalizer.T("主操作", "Primary action"), builtin.Definition.PrimaryAction);
yield return (AppLocalizer.T("关键词", "Keywords"), string.Join(", ", builtin.Definition.Keywords));
@@ -1187,14 +1029,13 @@ public sealed class ToolboxPage : Page
{
try
{
var modules = _catalog.Modules
var filteredModules = _catalog.Modules
.Where(module => _selectedGroup is null ||
ToolPresentationCatalog.For(module.Metadata.Category).Group == _selectedGroup)
.Where(MatchesScope)
.Where(MatchesFilters)
.Where(module => ToolText.Matches(module, _query))
.OrderBy(SortGroup)
.ThenBy(module => ToolText.Name(module), StringComparer.CurrentCulture)
.Where(module => ToolText.Matches(module, _query));
var modules = ToolCatalog.SortForDisplay(filteredModules, SortGroup)
.ToArray();
var layout = ToolboxLayoutCalculator.Calculate(_toolGridView.ActualWidth);
@@ -1267,13 +1108,13 @@ public sealed class ToolboxPage : Page
string.Equals(_settingsService.Current.ToolDisplayMode, "list", StringComparison.OrdinalIgnoreCase);
var buttonWidth = Math.Max(ToolboxLayoutCalculator.MinCardWidth, _toolCardWidth);
var contentWidth = Math.Max(232, buttonWidth - 26);
var cardHeight = compact ? 158 : 198;
var cardHeight = compact ? 146 : 178;
var grid = new Grid
{
Width = contentWidth,
Height = cardHeight - 28,
Padding = new Thickness(14),
RowSpacing = compact ? 7 : 9
Padding = new Thickness(12),
RowSpacing = compact ? 6 : 8
};
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
grid.RowDefinitions.Add(new RowDefinition());
@@ -1656,6 +1497,19 @@ public sealed class ToolboxPage : Page
};
}
private static string BuiltinKindText(BuiltinReferenceToolKind kind)
{
return kind switch
{
BuiltinReferenceToolKind.Information => AppLocalizer.T("信息页", "Information"),
BuiltinReferenceToolKind.Dialog => AppLocalizer.T("对话框", "Dialog"),
BuiltinReferenceToolKind.Command => AppLocalizer.T("命令操作", "Command"),
BuiltinReferenceToolKind.SystemEntry => AppLocalizer.T("系统入口", "System entry"),
BuiltinReferenceToolKind.ExternalHelper => AppLocalizer.T("外部辅助程序", "External helper"),
_ => kind.ToString()
};
}
private static string ExperienceText(ToolPageExperienceKind experience)
{
return experience switch
+7 -1
View File
@@ -21,12 +21,15 @@ internal sealed record ToolboxSurfaceDefinition(
string EnglishDescription,
string IconGlyph,
string Capability,
string EnglishCapability,
ToolboxSurfaceKind Kind,
IReadOnlyList<string> Keywords)
{
internal string CurrentTitle => AppLocalizer.T(Title, EnglishTitle);
internal string CurrentDescription => AppLocalizer.T(Description, EnglishDescription);
internal string CurrentCapability => AppLocalizer.T(Capability, EnglishCapability);
}
internal static class ToolboxSurfaceCatalog
@@ -40,6 +43,7 @@ internal static class ToolboxSurfaceCatalog
"实时传感器、静态硬件清单、提供方状态与报告导出。",
"Live sensors, hardware inventory, provider status, and report export.",
"\uE950",
"实时遥测",
"Live telemetry",
ToolboxSurfaceKind.Hardware,
["hardware", "sensor", "monitor", "system status", "硬件", "传感器", "系统状态"]),
@@ -50,6 +54,7 @@ internal static class ToolboxSurfaceCatalog
"预览系统变更、按需提权、执行日志和可用回滚。",
"Preview system changes, elevate on demand, review logs, and roll back.",
"\uE9D9",
"预览与回滚",
"Plan and rollback",
ToolboxSurfaceKind.Optimization,
["optimization", "optimize", "memory", "power", "network", "优化", "内存", "电源"]),
@@ -60,6 +65,7 @@ internal static class ToolboxSurfaceCatalog
"搜索、歌单、播放队列、歌词与系统媒体控制。",
"Search, playlists, playback queue, lyrics, and system media controls.",
"\uE8D6",
"在线媒体",
"Online media",
ToolboxSurfaceKind.Music,
["music", "netease", "player", "playlist", "音乐", "网易云", "播放器"])
@@ -132,7 +138,7 @@ internal sealed class ToolboxSurfaceHostPage : Page
header.Children.Add(backButton);
Grid.SetColumn(title, 1);
header.Children.Add(title);
var capability = ModernUi.Badge(definition.Capability, ModernUi.Accent, ModernUi.AccentSoft);
var capability = ModernUi.Badge(definition.CurrentCapability, ModernUi.Accent, ModernUi.AccentSoft);
capability.VerticalAlignment = VerticalAlignment.Center;
Grid.SetColumn(capability, 2);
header.Children.Add(capability);
@@ -90,6 +90,7 @@ public sealed partial class ToolPageRegistry : IToolPageFactory
private static bool RequiresNativeRegistration(IToolModule module)
{
return module is ToolModule &&
!ToolCatalog.IsToolboxNativeSurface(module.Id) &&
!PluginIds.IsPluginToolId(module.Id) &&
!ExternalToolModule.IsExternalToolId(module.Id) &&
!BuiltinReferenceToolModule.IsBuiltinToolId(module.Id);