Files
YMhut-box-C-/src/YMhut.Box.InstallerBootstrap/InstallerEngine.cs
T

660 lines
28 KiB
C#

using System.Diagnostics;
using System.ComponentModel;
using System.Security.Cryptography;
using System.Security.Principal;
namespace YMhut.Box.InstallerBootstrap;
public sealed record InstallerOptions(
string InstallDirectory,
bool DesktopShortcut,
bool StartMenuShortcut,
bool AutoStart,
bool LaunchAfterInstall,
IReadOnlyList<string> OriginalArguments)
{
public static bool IsAutomation(IEnumerable<string> arguments)
=> arguments.Any(argument => argument.Equals("/SILENT", StringComparison.OrdinalIgnoreCase) ||
argument.Equals("/VERYSILENT", StringComparison.OrdinalIgnoreCase));
public static InstallerOptions Parse(IReadOnlyList<string> arguments)
{
var directoryArgument = arguments.FirstOrDefault(argument => argument.StartsWith("/DIR=", StringComparison.OrdinalIgnoreCase));
var tasksArgument = arguments.FirstOrDefault(argument => argument.StartsWith("/TASKS=", StringComparison.OrdinalIgnoreCase));
var directory = directoryArgument is null
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "YMhut Box")
: directoryArgument[5..].Trim('"');
if (tasksArgument is null)
{
return new(directory, true, true, false, true, arguments);
}
var tasks = tasksArgument[7..]
.Trim('"')
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Select(task => task.TrimStart('*'))
.Where(task => !task.StartsWith('!'))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return new(
directory,
tasks.Contains("desktopicon"),
tasks.Contains("startmenuicon"),
tasks.Contains("autostart"),
tasks.Contains("launchapp"),
arguments);
}
}
public sealed record InstallerProgress(double? Percent, string Phase, string Detail);
public sealed record InstallerResult(bool Success, int ExitCode, string LogPath, string? Error = null);
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);
public static bool SelfTest()
{
try
{
var engine = ResolveEngine();
// 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
{
return false;
}
}
public static string GetPackageVersion()
{
try
{
var engine = ResolveEngine();
if (engine is not null)
{
var version = FileVersionInfo.GetVersionInfo(engine.Path).ProductVersion?.Trim();
if (!string.IsNullOrWhiteSpace(version))
{
return version.Split('+')[0];
}
}
}
catch
{
}
return typeof(InstallerEngine).Assembly.GetName().Version?.ToString() ?? "0.0.0";
}
public static async Task<int> RunForwardedAsync(IReadOnlyList<string> arguments)
{
var engine = ResolveEngine();
if (engine is null) return 3;
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(
InstallerOptions options,
IProgress<InstallerProgress>? progress = null,
CancellationToken cancellationToken = default)
{
// 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);
var logPath = Path.Combine(logRoot, $"setup-{DateTime.Now:yyyyMMdd-HHmmss}.log");
var tasks = new List<string>();
if (options.DesktopShortcut) tasks.Add("desktopicon");
if (options.StartMenuShortcut) tasks.Add("startmenuicon");
if (options.AutoStart) tasks.Add("autostart");
var arguments = new List<string>
{
"/VERYSILENT",
"/SUPPRESSMSGBOXES",
"/NORESTART",
$"/DIR={options.InstallDirectory}",
$"/TASKS={string.Join(',', tasks)}",
$"/LOG={logPath}"
};
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, 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)
{
var failure = ReadInstallerFailure(logPath) ?? $"安装引擎退出代码 {process.ExitCode}。";
return new(false, process.ExitCode, logPath, failure);
}
progress?.Report(new(100, "安装完成", "程序文件、快捷方式和运行库检查已完成。"));
return new(true, 0, logPath);
}
catch (OperationCanceledException)
{
try
{
if (process is { HasExited: false })
{
process.Kill(entireProcessTree: true);
await process.WaitForExitAsync().ConfigureAwait(false);
}
}
catch
{
}
try
{
await monitor.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
return new(false, 1223, logPath, "安装已取消,安装引擎已停止。");
}
catch (Win32Exception exception) when (exception.NativeErrorCode == 1223)
{
return new(false, 1223, logPath, "管理员权限请求已取消,未继续写入受保护目录。");
}
catch (Exception exception)
{
return new(false, 5, logPath, LimitError(exception.Message));
}
finally
{
process?.Dispose();
}
}
public static string? DetectExistingInstall()
=> DetectExistingInstallInfo()?.InstallDirectory;
public static ExistingInstallInfo? DetectExistingInstallInfo()
{
const string key = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\cn.ymhut.box.winui_is1";
foreach (var entry in new[]
{
(Hive: Microsoft.Win32.Registry.CurrentUser, PerUser: true),
(Hive: Microsoft.Win32.Registry.LocalMachine, PerUser: false)
})
{
using var subKey = entry.Hive.OpenSubKey(key);
var location = subKey?.GetValue("InstallLocation")?.ToString()?.Trim('"', ' ');
if (string.IsNullOrWhiteSpace(location))
{
continue;
}
var version = subKey?.GetValue("DisplayVersion")?.ToString()?.Trim();
return new ExistingInstallInfo(
location,
version,
File.Exists(Path.Combine(location, "YMhutBox.exe")),
entry.PerUser);
}
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 完整性检查。")
: new PreflightItem("engine", "安装引擎完整性", PreflightStatus.Failed, "安装引擎缺失或已损坏,请重新下载安装包。")
};
long? available = null;
try
{
var root = Path.GetPathRoot(Path.GetFullPath(targetDirectory));
available = new DriveInfo(root!).AvailableFreeSpace;
items.Add(InstallerModeResolver.EvaluateDiskSpace(available.Value, RequiredInstallBytes));
}
catch (Exception exception)
{
items.Add(new PreflightItem("disk", "磁盘空间", PreflightStatus.Failed, $"无法读取目标磁盘:{LimitError(exception.Message)}"));
}
items.Add(new PreflightItem(
"mode",
"安装模式",
PreflightStatus.Passed,
existing is null
? $"{plan.DisplayName} / 目标版本 {plan.PackageVersion}"
: $"{plan.DisplayName} / 已安装 {existing.DisplayVersion ?? "未知"} / 目标 {plan.PackageVersion}"));
var userData = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "YMhut Box", "WinUI");
items.Add(new PreflightItem("userdata", "用户数据保留", PreflightStatus.Passed, $"设置、缓存和日志保留在 {userData}"));
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
{
if (string.IsNullOrWhiteSpace(targetDirectory))
{
return new("directory", "目标目录", PreflightStatus.Failed, "请选择有效的安装目录。");
}
var full = Path.GetFullPath(targetDirectory);
var probeDirectory = full;
while (!Directory.Exists(probeDirectory))
{
var parent = Path.GetDirectoryName(probeDirectory);
if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, probeDirectory, StringComparison.OrdinalIgnoreCase))
{
break;
}
probeDirectory = parent;
}
if (!Directory.Exists(probeDirectory))
{
return new("directory", "目标目录", PreflightStatus.Failed, "找不到可写入的父目录。");
}
var requiresElevation = RequiresElevation(full);
if (!requiresElevation)
{
var probe = Path.Combine(probeDirectory, $".ymhut-write-{Environment.ProcessId}-{Guid.NewGuid():N}.tmp");
try
{
using (File.Create(probe))
{
}
}
finally
{
if (File.Exists(probe)) File.Delete(probe);
}
}
return new(
"directory",
"目标目录",
requiresElevation ? PreflightStatus.Warning : PreflightStatus.Passed,
requiresElevation ? $"{full} / 写入时将请求管理员权限。" : $"{full} / 当前用户可写。");
}
catch (Exception exception)
{
return new("directory", "目标目录", PreflightStatus.Failed, LimitError(exception.Message));
}
}
private static string LimitError(string value)
=> string.IsNullOrWhiteSpace(value) ? "未知错误" : value.Length <= 220 ? value : value[..220];
private static async Task MonitorLogAsync(string path, Process process, IProgress<InstallerProgress>? progress, CancellationToken cancellationToken)
{
long position = 0;
while (!process.HasExited)
{
cancellationToken.ThrowIfCancellationRequested();
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);
}
var finalUpdate = await ReadAvailableLogAsync(path, position, cancellationToken).ConfigureAwait(false);
foreach (var item in finalUpdate.Progress) progress?.Report(item);
}
private static async Task<(long Position, IReadOnlyList<InstallerProgress> Progress)> ReadAvailableLogAsync(
string path,
long position,
CancellationToken cancellationToken)
{
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 && (updates.Count == 0 || updates[^1] != phase)) updates.Add(phase);
}
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 shortcut", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Creating shortcuts", StringComparison.OrdinalIgnoreCase))
return new(null, "创建快捷方式", detail);
if (line.Contains("Finalizing installation", StringComparison.OrdinalIgnoreCase))
return new(null, "完成配置", detail);
if (line.Contains("Installation process succeeded", StringComparison.OrdinalIgnoreCase))
return new(100, "完成配置", detail);
if (line.Contains("error", StringComparison.OrdinalIgnoreCase))
return new(null, "安装引擎", detail);
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
{
FileName = engine,
WorkingDirectory = Path.GetDirectoryName(engine)!,
UseShellExecute = elevate,
CreateNoWindow = !elevate
};
if (elevate) startInfo.Verb = "runas";
foreach (var argument in arguments) startInfo.ArgumentList.Add(argument);
return Process.Start(startInfo);
}
private static ResolvedEngine? ResolveEngine()
=> ResolvedEngineCache.Value;
private static ResolvedEngine? ResolveEngineCore()
{
using var resource = typeof(InstallerEngine).Assembly.GetManifestResourceStream("YMhutBox.Engine.exe");
if (resource is not null)
{
return ExtractEmbeddedEngine(resource);
}
var direct = Path.Combine(AppContext.BaseDirectory, "YMhutBox.Engine.exe");
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);
}
private static ResolvedEngine ExtractEmbeddedEngine(Stream resource)
{
var resourceLength = resource.Length;
var hash = Convert.ToHexStringLower(SHA256.HashData(resource));
resource.Position = 0;
var root = Path.Combine(Path.GetTempPath(), "YMhutBoxSetup", "Engine");
Directory.CreateDirectory(root);
var extracted = Path.Combine(root, $"YMhutBox.Engine.{hash[..16]}.exe");
if (!FileMatches(extracted, resourceLength, hash))
{
var temporary = extracted + "." + Environment.ProcessId + ".tmp";
try
{
using (var output = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024, FileOptions.WriteThrough))
{
resource.CopyTo(output);
output.Flush(flushToDisk: true);
}
try
{
File.Move(temporary, extracted, true);
}
catch (IOException) when (FileMatches(extracted, resourceLength, hash))
{
}
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
}
if (!FileMatches(extracted, resourceLength, hash))
{
throw new InvalidDataException("嵌入安装引擎的 SHA-256 校验失败。");
}
return new ResolvedEngine(extracted);
}
private static bool FileMatches(string path, long length, string sha256)
{
try
{
var info = new FileInfo(path);
if (!info.Exists || info.Length != length) return false;
using var stream = info.OpenRead();
return string.Equals(Convert.ToHexStringLower(SHA256.HashData(stream)), sha256, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
private static bool IsPortableExecutable(string path)
{
using var stream = File.OpenRead(path);
return stream.Length > 1_000_000 && stream.ReadByte() == 'M' && stream.ReadByte() == 'Z';
}
private static bool RequiresElevation(string directory)
{
if (IsAdministrator()) return false;
var full = Path.GetFullPath(directory);
return full.StartsWith(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), StringComparison.OrdinalIgnoreCase) ||
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();
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
}
}