660 lines
28 KiB
C#
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);
|
|
}
|
|
}
|