Update application UI and functionality
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 渲染器 运行 HTML、CSS、JS、引用脚本并查看输出日志 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user