Update application UI and functionality
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user