Update application UI and functionality

This commit is contained in:
2026-07-26 16:20:36 +08:00
parent b9aff58f32
commit 97ea6fb7aa
48 changed files with 2790 additions and 628 deletions
@@ -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();