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 OriginalArguments) { public static bool IsAutomation(IEnumerable arguments) => arguments.Any(argument => argument.Equals("/SILENT", StringComparison.OrdinalIgnoreCase) || argument.Equals("/VERYSILENT", StringComparison.OrdinalIgnoreCase)); public static InstallerOptions Parse(IReadOnlyList 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 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 RunForwardedAsync(IReadOnlyList 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 InstallAsync( InstallerOptions options, IProgress? 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(); if (options.DesktopShortcut) tasks.Add("desktopicon"); if (options.StartMenuShortcut) tasks.Add("startmenuicon"); if (options.AutoStart) tasks.Add("autostart"); var arguments = new List { "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", $"/DIR={options.InstallDirectory}", $"/TASKS={string.Join(',', tasks)}", $"/LOG={logPath}" }; var prerequisites = GetPrerequisiteStatus(); var missingPrerequisites = new List(); 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 { 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? 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 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(); 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 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); } }