using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Windows.Forms; namespace MmdPdfLauncher { internal static class Program { private const string ProductName = "MmdPdf"; private const string InnerExeName = "MmdPdf.App.exe"; private const string PayloadResourceName = "MmdPdfLauncher.payload.zip"; private const string ManifestName = "payload.manifest"; private const string PortableMarkerName = ".mmdpdf-portable"; private const string TestInstallRootEnvironmentVariable = "MMDPDF_TEST_INSTALL_ROOT"; private const string SkipRegistrationEnvironmentVariable = "MMDPDF_SKIP_REGISTRATION"; private static readonly string UserInstallDirectory = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", ProductName); private static readonly string MachineInstallDirectory = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), ProductName); private static readonly string PortableRoot = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ProductName, "Portable"); [STAThread] private static int Main(string[] args) { try { if (args.Any(a => string.Equals(a, "/install-user", StringComparison.OrdinalIgnoreCase))) return Install(machine: false, desktop: args.Any(a => string.Equals(a, "/desktop", StringComparison.OrdinalIgnoreCase))); if (args.Any(a => string.Equals(a, "/silent", StringComparison.OrdinalIgnoreCase))) return Install(machine: true, desktop: false); return RunPortable(args); } catch (Exception ex) { MessageBox.Show( ProductName + " could not start.\n\n" + ex.Message, ProductName, MessageBoxButtons.OK, MessageBoxIcon.Error); return 1; } } private static int RunPortable(string[] args) { SweepAbandonedPortableDirectories(); Directory.CreateDirectory(PortableRoot); string version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "unknown"; string directory = Path.Combine(PortableRoot, version + "-" + Process.GetCurrentProcess().Id.ToString(CultureInfo.InvariantCulture) + "-" + Guid.NewGuid().ToString("N")); try { ExtractAndVerify(directory); WritePortableMarker(directory, version, Process.GetCurrentProcess().Id, null); var start = new ProcessStartInfo(Path.Combine(directory, InnerExeName), QuoteArguments(args)) { UseShellExecute = false, WorkingDirectory = directory }; start.EnvironmentVariables["MMDPDF_LAUNCHER_PATH"] = CurrentExecutablePath(); start.EnvironmentVariables["MMDPDF_LAUNCHER_PID"] = Process.GetCurrentProcess().Id.ToString(CultureInfo.InvariantCulture); start.EnvironmentVariables["MMDPDF_PORTABLE_ROOT"] = directory; using (var child = Process.Start(start)) { if (child == null) throw new InvalidOperationException("The application process could not be created."); WritePortableMarker(directory, version, Process.GetCurrentProcess().Id, child.Id); child.WaitForExit(); return child.ExitCode; } } finally { DeleteDirectoryWithRetries(directory); } } private static int Install(bool machine, bool desktop) { if (!IsTrustedForInstall(CurrentExecutablePath())) throw new InvalidOperationException( "Installation was refused because this download does not have a valid MmdPdf digital signature."); string? testRoot = Environment.GetEnvironmentVariable(TestInstallRootEnvironmentVariable); if (string.IsNullOrWhiteSpace(testRoot) && !machine && (File.Exists(Path.Combine(MachineInstallDirectory, InnerExeName)) || File.Exists(Path.Combine(MachineInstallDirectory, "MmdPdf.exe")))) throw new InvalidOperationException( "MmdPdf is already installed for everyone on this computer. Update that installation, " + "or uninstall it before choosing a per-user install. MmdPdf will not create two installed copies."); string destination = !string.IsNullOrWhiteSpace(testRoot) ? Path.GetFullPath(testRoot) : (machine ? MachineInstallDirectory : UserInstallDirectory); string parent = Path.GetDirectoryName(destination) ?? throw new InvalidOperationException("Invalid install directory."); Directory.CreateDirectory(parent); string staging = destination + ".staging-" + Guid.NewGuid().ToString("N"); string backup = destination + ".previous-" + Guid.NewGuid().ToString("N"); bool movedExisting = false; try { ExtractAndVerify(staging); if (Directory.Exists(destination)) { Directory.Move(destination, backup); movedExisting = true; } Directory.Move(staging, destination); int registrationExit = string.Equals( Environment.GetEnvironmentVariable(SkipRegistrationEnvironmentVariable), "1", StringComparison.Ordinal) ? 0 : RunRegistration(destination, machine, desktop); if (registrationExit != 0) throw new InvalidOperationException("Windows integration could not be registered (exit " + registrationExit + ")."); // A machine-wide install supersedes the current account's per-user copy. This // also covers unattended /silent installs that do not return through the portable // app's InstallAndRelaunch cleanup path. if (machine && string.IsNullOrWhiteSpace(testRoot)) RunMaintenance(destination, "/remove-user-install"); if (movedExisting) DeleteDirectoryWithRetries(backup); return 0; } catch { DeleteDirectoryWithRetries(staging); if (movedExisting && Directory.Exists(backup)) { DeleteDirectoryWithRetries(destination); if (!Directory.Exists(destination)) Directory.Move(backup, destination); } throw; } } private static int RunRegistration(string directory, bool machine, bool desktop) { var arguments = new List { machine ? "/register-machine" : "/register-user" }; if (desktop) arguments.Add("/desktop"); var start = new ProcessStartInfo(Path.Combine(directory, InnerExeName), QuoteArguments(arguments.ToArray())) { UseShellExecute = false, WorkingDirectory = directory }; using (var process = Process.Start(start)) { if (process == null) return 1; process.WaitForExit(); return process.ExitCode; } } private static int RunMaintenance(string directory, string argument) { var start = new ProcessStartInfo(Path.Combine(directory, InnerExeName), argument) { UseShellExecute = false, WorkingDirectory = directory }; using (var process = Process.Start(start)) { if (process == null) return 1; process.WaitForExit(); return process.ExitCode; } } private static void ExtractAndVerify(string destination) { Directory.CreateDirectory(destination); using (var payload = Assembly.GetExecutingAssembly().GetManifestResourceStream(PayloadResourceName)) { if (payload == null) throw new InvalidOperationException("The application payload is missing."); using (var archive = new ZipArchive(payload, ZipArchiveMode.Read, leaveOpen: false)) { var manifestEntry = archive.GetEntry(ManifestName) ?? throw new InvalidDataException("The payload manifest is missing."); Dictionary manifest; using (var reader = new StreamReader(manifestEntry.Open(), Encoding.UTF8, true)) manifest = ReadManifest(reader); var payloadEntries = archive.Entries .Where(e => !string.IsNullOrEmpty(e.Name) && !string.Equals(e.FullName, ManifestName, StringComparison.Ordinal)) .ToDictionary(e => NormalizeRelativePath(e.FullName), StringComparer.OrdinalIgnoreCase); if (payloadEntries.Count != manifest.Count || manifest.Keys.Any(k => !payloadEntries.ContainsKey(k))) throw new InvalidDataException("The payload contents do not match its manifest."); string destinationRoot = EnsureTrailingSeparator(Path.GetFullPath(destination)); foreach (var item in manifest.OrderBy(p => p.Key, StringComparer.Ordinal)) { string outputPath = Path.GetFullPath(Path.Combine(destination, item.Key.Replace('/', Path.DirectorySeparatorChar))); if (!outputPath.StartsWith(destinationRoot, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("The payload contains an unsafe path."); Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? destination); using (var input = payloadEntries[item.Key].Open()) using (var output = new FileStream(outputPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) input.CopyTo(output); var info = new FileInfo(outputPath); if (info.Length != item.Value.Size || !string.Equals(HashFile(outputPath), item.Value.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Payload verification failed for " + item.Key + "."); } File.WriteAllLines(Path.Combine(destination, ManifestName), manifest.OrderBy(p => p.Key, StringComparer.Ordinal) .Select(p => p.Value.Sha256 + "\t" + p.Value.Size.ToString(CultureInfo.InvariantCulture) + "\t" + p.Key), new UTF8Encoding(false)); } } if (!File.Exists(Path.Combine(destination, InnerExeName))) throw new InvalidDataException("The payload does not contain " + InnerExeName + "."); } private static Dictionary ReadManifest(TextReader reader) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); string? line; while ((line = reader.ReadLine()) != null) { if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#", StringComparison.Ordinal)) continue; var parts = line.Split(new[] { '\t' }, 3); if (parts.Length != 3 || parts[0].Length != 64 || !long.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out long size) || size < 0) throw new InvalidDataException("The payload manifest is invalid."); string path = NormalizeRelativePath(parts[2]); if (result.ContainsKey(path)) throw new InvalidDataException("The payload manifest contains a duplicate path."); result.Add(path, new ManifestFile(parts[0], size)); } return result; } private static string NormalizeRelativePath(string path) { string normalized = path.Replace('\\', '/').TrimStart('/'); if (string.IsNullOrWhiteSpace(normalized) || Path.IsPathRooted(path) || normalized.Contains(":") || normalized.Split('/').Any(p => p.Length == 0 || p == "." || p == "..")) throw new InvalidDataException("The payload contains an unsafe path: " + path); return normalized; } private static void SweepAbandonedPortableDirectories() { if (!Directory.Exists(PortableRoot)) return; foreach (string directory in Directory.GetDirectories(PortableRoot)) { string marker = Path.Combine(directory, PortableMarkerName); if (!File.Exists(marker)) continue; try { var lines = File.ReadAllLines(marker); if (lines.Length > 0 && string.Equals(lines[0], ProductName, StringComparison.Ordinal) && !MarkerHasLiveProcess(lines)) DeleteDirectoryWithRetries(directory); } catch { } } } private static void WritePortableMarker(string directory, string version, int launcherPid, int? childPid) { File.WriteAllLines(Path.Combine(directory, PortableMarkerName), new[] { ProductName, version, launcherPid.ToString(CultureInfo.InvariantCulture), childPid?.ToString(CultureInfo.InvariantCulture) ?? string.Empty }, new UTF8Encoding(false)); } private static bool MarkerHasLiveProcess(string[] lines) { foreach (string text in lines.Skip(2).Take(2)) { if (!int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out int pid)) continue; try { using (var process = Process.GetProcessById(pid)) if (!process.HasExited) return true; } catch { } } return false; } private static void DeleteDirectoryWithRetries(string directory) { if (!Directory.Exists(directory)) return; for (int attempt = 0; attempt < 5; attempt++) { try { foreach (string file in Directory.GetFiles(directory, "*", SearchOption.AllDirectories)) try { File.SetAttributes(file, FileAttributes.Normal); } catch { } Directory.Delete(directory, recursive: true); return; } catch when (attempt < 4) { System.Threading.Thread.Sleep(150 * (attempt + 1)); } } } private static bool IsTrustedForInstall(string path) { #if ALLOW_UNSIGNED_INSTALL // Local packages produced by build-portable.ps1 are intentionally unsigned and must // remain installable for end-to-end testing. release.ps1 omits this compile-time flag; // its public launcher has no environment-variable or command-line bypass. return true; #else return AuthenticodeTrust.IsValid(path); #endif } private static string HashFile(string path) { using (var stream = File.OpenRead(path)) using (var sha = SHA256.Create()) return BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", string.Empty); } private static string CurrentExecutablePath() => Process.GetCurrentProcess().MainModule?.FileName ?? Assembly.GetExecutingAssembly().Location; private static string EnsureTrailingSeparator(string path) => path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) ? path : path + Path.DirectorySeparatorChar; private static string QuoteArguments(IEnumerable arguments) => string.Join(" ", arguments.Select(QuoteArgument)); private static string QuoteArgument(string argument) { if (argument.Length > 0 && argument.All(c => !char.IsWhiteSpace(c) && c != '"')) return argument; var sb = new StringBuilder("\""); int slashes = 0; foreach (char c in argument) { if (c == '\\') { slashes++; continue; } if (c == '"') sb.Append('\\', slashes * 2 + 1).Append('"'); else { sb.Append('\\', slashes).Append(c); } slashes = 0; } sb.Append('\\', slashes * 2).Append('"'); return sb.ToString(); } private sealed class ManifestFile { internal ManifestFile(string sha256, long size) { Sha256 = sha256; Size = size; } internal string Sha256 { get; } internal long Size { get; } } } internal static class AuthenticodeTrust { [StructLayout(LayoutKind.Sequential)] private struct WinTrustFileInfo { internal uint Size; internal IntPtr FilePath; internal IntPtr File; internal IntPtr KnownSubject; } [StructLayout(LayoutKind.Sequential)] private struct WinTrustData { internal uint Size; internal IntPtr PolicyCallbackData; internal IntPtr SipClientData; internal uint UiChoice; internal uint RevocationChecks; internal uint UnionChoice; internal IntPtr Union; internal uint StateAction; internal IntPtr StateData; internal IntPtr UrlReference; internal uint ProviderFlags; internal uint UiContext; internal IntPtr SignatureSettings; } private static readonly Guid VerifyGeneric = new Guid("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); [DllImport("wintrust.dll", ExactSpelling = true, CharSet = CharSet.Unicode)] private static extern uint WinVerifyTrust(IntPtr window, ref Guid action, IntPtr trustData); internal static bool IsValid(string path) { IntPtr pathPointer = Marshal.StringToHGlobalUni(path); IntPtr filePointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(WinTrustFileInfo))); IntPtr dataPointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(WinTrustData))); try { Marshal.StructureToPtr(new WinTrustFileInfo { Size = (uint)Marshal.SizeOf(typeof(WinTrustFileInfo)), FilePath = pathPointer }, filePointer, false); Marshal.StructureToPtr(new WinTrustData { Size = (uint)Marshal.SizeOf(typeof(WinTrustData)), UiChoice = 2, UnionChoice = 1, Union = filePointer, ProviderFlags = 0x1000 }, dataPointer, false); var action = VerifyGeneric; return WinVerifyTrust(IntPtr.Zero, ref action, dataPointer) == 0; } finally { Marshal.FreeHGlobal(dataPointer); Marshal.FreeHGlobal(filePointer); Marshal.FreeHGlobal(pathPointer); } } } }