using System; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.IO.Pipes; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Documents; using System.Windows.Media; using System.Windows.Threading; using MmdPdf.Services; using Microsoft.Win32; namespace MmdPdf { public partial class App : Application { // ============================================================ // Paths // ============================================================ private static readonly string AppName = "MmdPdf"; // The public portable file remains MmdPdf.exe. Once installed, shortcuts launch the // loose-file application directly so installed startup never pays launcher/extraction cost. private static readonly string ExeName = "MmdPdf.App.exe"; private static readonly string InstallDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", AppName); private static readonly string InstallExe = Path.Combine(InstallDir, ExeName); private static readonly string LegacyUserInstallExe = Path.Combine(InstallDir, "MmdPdf.exe"); private static readonly string FileIconPath = Path.Combine(InstallDir, "pdf-file.ico"); private static readonly string StartMenuDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.Programs), AppName); private static readonly string StartMenuLnk = Path.Combine(StartMenuDir, $"{AppName}.lnk"); private static readonly string DesktopLnk = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), $"{AppName}.lnk"); // ── Machine-wide install (all users), matching Killendar / KillerShell ───────────── // ProgramFiles rather than LocalApplicationData\Programs, so it needs elevation - which // is why it goes through the /silent path under a UAC prompt instead of being done inline. private static readonly string MachineInstallDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), AppName); private static readonly string MachineInstallExe = Path.Combine(MachineInstallDir, ExeName); private static readonly string LegacyMachineInstallExe = Path.Combine(MachineInstallDir, "MmdPdf.exe"); private static readonly string MachineFileIconPath = Path.Combine(MachineInstallDir, "pdf-file.ico"); private static readonly string MachineStartMenuDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), AppName); private static readonly string MachineStartMenuLnk = Path.Combine(MachineStartMenuDir, $"{AppName}.lnk"); // ============================================================ // Shell interop // ============================================================ [DllImport("shell32.dll")] private static extern void SHChangeNotify(uint wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2); private const uint SHCNE_ASSOCCHANGED = 0x08000000; private const uint SHCNF_IDLIST = 0x0000; // ============================================================ // Startup // ============================================================ // Windows reports whether we are being viewed over a remote desktop. In that case WPF's // hardware compositing hands the session a surface it never repaints when the desktop is // resized, which leaves the part of the window outside the original size drawn as flat // black. Drawing in software costs nothing at this size and keeps the whole window painted. [DllImport("user32.dll")] private static extern int GetSystemMetrics(int index); private const int SM_REMOTESESSION = 0x1000; private static void UseSoftwareRenderingOnRemoteDesktops() { try { if (GetSystemMetrics(SM_REMOTESESSION) != 0) RenderOptions.ProcessRenderMode = System.Windows.Interop.RenderMode.SoftwareOnly; } catch { /* never let a rendering hint stop the application starting */ } } protected override void OnStartup(StartupEventArgs e) { UseSoftwareRenderingOnRemoteDesktops(); StartupTrace.Mark("App.OnStartup entered"); DispatcherUnhandledException += OnDispatcherException; AppDomain.CurrentDomain.UnhandledException += OnDomainException; TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; base.OnStartup(e); StartupTrace.Mark("Application base startup complete"); // Private launcher hand-off. The verified launcher has already staged the complete // payload; this fast headless pass only registers shortcuts, associations, protocol, // and uninstall metadata from the final installed path. if (e.Args.Any(a => string.Equals(a, "/register-user", StringComparison.OrdinalIgnoreCase)) || e.Args.Any(a => string.Equals(a, "/register-machine", StringComparison.OrdinalIgnoreCase))) { bool machine = e.Args.Any(a => string.Equals(a, "/register-machine", StringComparison.OrdinalIgnoreCase)); bool desktop = e.Args.Any(a => string.Equals(a, "/desktop", StringComparison.OrdinalIgnoreCase)); try { RegisterInstalledCopy(machine, desktop); Shutdown(0); } catch (Exception ex) { Console.Error.WriteLine("Install registration failed: " + ex.Message); Shutdown(1); } return; } if (e.Args.Any(a => string.Equals(a, "/remove-user-install", StringComparison.OrdinalIgnoreCase))) { RemovePerUserInstall(); Shutdown(0); return; } // Elevated half of the dual-install repair (OfferInstallConflictRepair): removes the // machine-wide copy. if (e.Args.Any(a => string.Equals(a, "/remove-machine-conflict", StringComparison.OrdinalIgnoreCase))) { RemoveMachineInstallConflict(); Shutdown(0); return; } // #168: install our font resolver before anything can create an XFont - PdfSharpCore // caches the resolver on first use. Without this the save path sees only *.ttf and // cannot embed the .ttc families every CJK script relies on. Services.KillerFontResolver.Install(); StartupTrace.Mark("Font resolver installed"); StartupTrace.Mark("pdfium integrity check starting"); if (!CheckPdfiumIntegrity()) { StartupTrace.Mark("pdfium integrity check failed"); Shutdown(2); return; } StartupTrace.Mark("pdfium integrity check complete"); // Machine-wide install, no UI. Used by winget / choco / RMM deployments and by the // all-users checkbox, which re-runs this exe elevated with /silent. Checked before // everything else: it must never show a window or touch the single-instance mutex. if (e.Args.Length > 0 && string.Equals(e.Args[0], "/silent", StringComparison.OrdinalIgnoreCase)) { DoSilentInstall(); Shutdown(0); return; } // Handle uninstall flag (called by Add/Remove Programs) if (e.Args.Length > 0 && string.Equals(e.Args[0], "/uninstall", StringComparison.OrdinalIgnoreCase)) { Uninstall(); Shutdown(); return; } // Headless CLI commands (see Features/Cli/CliRunner.cs; --batch-resave in // BatchRunner.cs). Checked before the single-instance mutex so CLI runs work // while a GUI instance is open, never forward to it, and never show a window. if (MmdPdf.Features.CliRunner.TryRunCli(e.Args, out int cliExit)) { Shutdown(cliExit); return; } // Refresh the current executable path for the browser extension handoff. This stays // after silent install, uninstall, and CLI dispatch so those headless paths never // register a protocol under an elevated or service account. // #183: skip the per-user refresh when running from the machine-wide install - the // elevated installer already registered the handler in HKLM for all users, and an // HKCU copy would shadow it for just this user. if (!string.Equals(Process.GetCurrentProcess().MainModule?.FileName, MachineInstallExe, StringComparison.OrdinalIgnoreCase)) Services.ProtocolRegistrar.Register(); StartupTrace.Mark("Protocol registration refresh complete"); // Single instance: a second launch (e.g. double-clicking another PDF in Explorer) // forwards its file path to the already-running instance, which opens it as a new // tab, then this process exits. Without this, every launch spawned its own window. bool isPrimary; try { _instanceMutex = new Mutex(true, MutexName, out isPrimary); } catch { isPrimary = true; } if (!isPrimary) { var fwd = e.Args.FirstOrDefault(a => !a.StartsWith("/", StringComparison.Ordinal)); ForwardToPrimary(fwd); Shutdown(0); return; } StartPipeServer(); StartupTrace.Mark("Single-instance initialization complete"); OfferInstallConflictRepair(); ShutdownMode = ShutdownMode.OnLastWindowClose; CleanupStaleTemps(); StartupTrace.Mark("Stale temporary-file cleanup complete"); ThemeManager.Initialize(); StartupTrace.Mark("Theme initialized"); // #211: translator test mode - load an external translation file as the language // override and re-apply it on every save (see LocaleManager). Documented in // TRANSLATING.md; must be set before LocaleManager.Initialize applies the locale. for (int i = 0; i < e.Args.Length - 1; i++) if (string.Equals(e.Args[i], "--lang-file", StringComparison.OrdinalIgnoreCase)) { try { LocaleManager.ExternalFile = Path.GetFullPath(e.Args[i + 1]); } catch { /* bad path: normal locale */ } } LocaleManager.Initialize(); StartupTrace.Mark("Locale initialized"); var mainWindow = new MainWindow(); StartupTrace.Mark("MainWindow constructed"); mainWindow.Show(); StartupTrace.Mark("MainWindow.Show returned"); } // ============================================================ // Single-instance IPC (mutex + named pipe) // ============================================================ private const string MutexName = @"Local\MmdPdf.SingleInstance"; private const string PipeName = "MmdPdf.OpenPipe"; private Mutex? _instanceMutex; private void StartPipeServer() { var t = new System.Threading.Thread(RunPipeServer) { IsBackground = true, Name = "MmdPdf-IPC", }; t.Start(); } // Accepts forwarded file paths from secondary launches and hands them to the UI thread. private void RunPipeServer() { while (true) { try { using var server = new NamedPipeServerStream( PipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.None); server.WaitForConnection(); using var reader = new StreamReader(server, Encoding.UTF8); string? path = reader.ReadLine(); Dispatcher.BeginInvoke(new Action(() => DeliverExternalOpen(path))); } catch { System.Threading.Thread.Sleep(150); // pipe error - back off, keep serving } } } private void DeliverExternalOpen(string? path) { if (Current?.MainWindow is MainWindow mw) { mw.RestoreAndActivate(); if (!string.IsNullOrEmpty(path)) mw.OpenFromExternal(path); } } // Release the single-instance mutex so a relaunched/installed copy starts as the PRIMARY instance. // Without this, the new process sees the still-running old process holding the mutex, treats itself // as a secondary launch, forwards its args and exits - so once the old process finishes shutting // down, no window is left (the "installed but it didn't come back" bug). internal void ReleaseInstanceMutex() { try { _instanceMutex?.ReleaseMutex(); } catch { /* not owned on this thread / already released */ } _instanceMutex?.Dispose(); _instanceMutex = null; } // Secondary instance: send our file path to the primary instance over the pipe. private static void ForwardToPrimary(string? path) { try { using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out); client.Connect(3000); using var w = new StreamWriter(client, new UTF8Encoding(false)) { AutoFlush = true }; w.WriteLine(path ?? ""); } catch { /* primary not accepting yet; nothing more we can do */ } } // ============================================================ // Crash handling // ============================================================ // // NOTE: AccessViolationException is not catchable on .NET 4.8 without // [HandleProcessCorruptedStateExceptions], which we deliberately omit. private void OnDispatcherException(object sender, DispatcherUnhandledExceptionEventArgs e) { var logPath = CrashReporter.Capture(e.Exception, "Dispatcher"); bool cont = ShowCrashDialog(e.Exception, logPath, isFatal: false); e.Handled = true; // always handle; we manage the exit ourselves if (!cont) { CleanupSessionTemps(); Shutdown(1); } } private void OnDomainException(object sender, UnhandledExceptionEventArgs e) { var ex = e.ExceptionObject as Exception ?? new Exception(e.ExceptionObject?.ToString() ?? "Unknown error"); var logPath = CrashReporter.Capture(ex, "AppDomain"); try { if (Dispatcher != null && !Dispatcher.HasShutdownStarted) Dispatcher.Invoke(() => ShowCrashDialog(ex, logPath, isFatal: true)); else ShowCrashDialog(ex, logPath, isFatal: true); } catch { /* at least the log was written */ } CleanupSessionTemps(); // CLR will terminate the process after this handler returns. } private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) { e.SetObserved(); // prevent process teardown var logPath = CrashReporter.Capture(e.Exception, "TaskScheduler"); try { if (Dispatcher != null && !Dispatcher.HasShutdownStarted) Dispatcher.BeginInvoke(new Action( () => ShowCrashDialog(e.Exception, logPath, isFatal: false))); } catch { /* best-effort */ } } /// /// Dark-themed crash report dialog. Returns true if the user chose Continue. /// Must be called on the UI thread. /// private static string CrashText(string key, string fallback) => System.Windows.Application.Current?.TryFindResource(key) as string ?? fallback; private static bool ShowCrashDialog(Exception ex, string logPath, bool isFatal) { bool shouldContinue = false; // ── Palette ───────────────────────────────────────────── var bg = new SolidColorBrush(Color.FromRgb(0x1a, 0x1a, 0x1a)); var dimBg = new SolidColorBrush(Color.FromRgb(0x25, 0x25, 0x25)); var codeBg = new SolidColorBrush(Color.FromRgb(0x12, 0x12, 0x12)); var red = new SolidColorBrush(Color.FromRgb(0xef, 0x44, 0x44)); var green = new SolidColorBrush(Color.FromRgb(0x1e, 0xa5, 0x4c)); var greenHov = new SolidColorBrush(Color.FromRgb(0x27, 0xc8, 0x60)); var dimText = new SolidColorBrush(Color.FromRgb(0x77, 0x77, 0x77)); var midText = new SolidColorBrush(Color.FromRgb(0xaa, 0xaa, 0xaa)); var redHov = new SolidColorBrush(Color.FromRgb(0xc4, 0x2b, 0x1c)); var grayBtn = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)); var grayHov = new SolidColorBrush(Color.FromRgb(0x44, 0x44, 0x44)); var quitNorm = new SolidColorBrush(Color.FromRgb(0x5a, 0x10, 0x10)); var quitHov = new SolidColorBrush(Color.FromRgb(0xc4, 0x2b, 0x1c)); var win = new Window { Title = CrashText("Str_Crash_Title", "MmdPdf - Unexpected Error"), Width = 680, Height = 520, MinWidth = 480, MinHeight = 360, WindowStartupLocation = WindowStartupLocation.CenterScreen, ResizeMode = ResizeMode.CanResize, WindowStyle = WindowStyle.None, Background = bg, ShowInTaskbar = true }; // ── Layout ────────────────────────────────────────────── var root = new Grid(); root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(36) }); root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // ── Title bar ─────────────────────────────────────────── var titleBar = new DockPanel { Background = dimBg }; Grid.SetRow(titleBar, 0); titleBar.MouseLeftButtonDown += (_, ea) => { if (ea.ButtonState == MouseButtonState.Pressed) win.DragMove(); }; var xBtn = MakeTitleBarCloseButton(dimText, redHov); xBtn.Click += (_, _) => { shouldContinue = false; win.Close(); }; DockPanel.SetDock(xBtn, Dock.Right); titleBar.Children.Add(xBtn); titleBar.Children.Add(new TextBlock { Text = CrashText("Str_Crash_Title", "MmdPdf - Unexpected Error"), Foreground = dimText, FontSize = 12, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(12, 0, 0, 0) }); root.Children.Add(titleBar); // ── Error header ───────────────────────────────────────── var headerPanel = new StackPanel { Background = dimBg, Margin = new Thickness(0, 1, 0, 0) }; Grid.SetRow(headerPanel, 1); var headerInner = new StackPanel { Margin = new Thickness(20, 14, 20, 14) }; var typeRow = new StackPanel { Orientation = Orientation.Horizontal }; typeRow.Children.Add(new TextBlock { Text = "⚠ ", Foreground = red, FontSize = 18, VerticalAlignment = VerticalAlignment.Center }); typeRow.Children.Add(new TextBlock { Text = ex.GetType().Name, Foreground = red, FontSize = 16, FontWeight = FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center }); headerInner.Children.Add(typeRow); headerInner.Children.Add(new TextBlock { Text = ex.Message, Foreground = Brushes.White, FontSize = 13, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) }); headerInner.Children.Add(new TextBlock { Text = string.Format(CrashText("Str_Crash_Log", "Log: {0}"), logPath), Foreground = dimText, FontSize = 11, Margin = new Thickness(0, 6, 0, 0) }); headerPanel.Children.Add(headerInner); root.Children.Add(headerPanel); // ── Stack trace ────────────────────────────────────────── var traceBox = new TextBox { Text = FormatExceptionChain(ex), Background = codeBg, Foreground = midText, FontFamily = new FontFamily("Consolas,Courier New"), FontSize = 11, IsReadOnly = true, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, TextWrapping = TextWrapping.NoWrap, BorderThickness = new Thickness(0), Padding = new Thickness(12, 8, 12, 8), Margin = new Thickness(0, 1, 0, 0) }; Grid.SetRow(traceBox, 2); root.Children.Add(traceBox); // ── Button bar ─────────────────────────────────────────── var btnBorder = new Border { Background = dimBg, Padding = new Thickness(16, 10, 16, 10) }; Grid.SetRow(btnBorder, 3); var btnPanel = new DockPanel(); // Left: utility buttons var leftBtns = new StackPanel { Orientation = Orientation.Horizontal }; var copyBtn = MakeCrashButton(CrashText("Str_Crash_Copy", "Copy Report"), grayBtn, grayHov, Brushes.White, 100); copyBtn.Click += (_, _) => { try { Clipboard.SetText(BuildFullCrashReport(ex)); } catch { } }; leftBtns.Children.Add(copyBtn); var logsBtn = MakeCrashButton(CrashText("Str_Crash_OpenLogs", "Open Logs"), grayBtn, grayHov, Brushes.White, 88); logsBtn.Margin = new Thickness(8, 0, 0, 0); logsBtn.Click += (_, _) => { try { Directory.CreateDirectory(CrashReporter.LogDir); Process.Start(new ProcessStartInfo(CrashReporter.LogDir) { UseShellExecute = true }); } catch { } }; leftBtns.Children.Add(logsBtn); var githubBtn = MakeCrashButton(CrashText("Str_Crash_Report", "Report on GitHub"), grayBtn, grayHov, new SolidColorBrush(Color.FromRgb(0x60, 0xc0, 0xff)), 128); githubBtn.Margin = new Thickness(8, 0, 0, 0); githubBtn.Click += (_, _) => { try { var ver = Assembly.GetExecutingAssembly().GetName().Version; var msgLen = Math.Min(80, ex.Message.Length); var title = Uri.EscapeDataString( $"Crash: {ex.GetType().Name}: {ex.Message[..msgLen]}"); var stack = ex.StackTrace?.Length > 800 ? ex.StackTrace[..800] + "\n... (truncated)" : ex.StackTrace ?? "(no stack trace)"; var body = Uri.EscapeDataString( $"**Version:** {ver?.ToString(3)}\n" + $"**OS:** {Environment.OSVersion}\n" + $"**Exception:** `{ex.GetType().FullName}`\n" + $"**Message:** {ex.Message}\n\n" + $"```\n{stack}\n```\n\n" + $"_Log folder: `{CrashReporter.LogDir}`_"); Process.Start(new ProcessStartInfo( $"https://github.com/SteveTheKiller/MmdPdf/issues/new?title={title}&body={body}") { UseShellExecute = true }); } catch { } }; leftBtns.Children.Add(githubBtn); DockPanel.SetDock(leftBtns, Dock.Left); btnPanel.Children.Add(leftBtns); // Right: Continue / Quit var rightBtns = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right }; var contBtn = MakeCrashButton(CrashText("Str_Crash_Continue", "Continue"), green, greenHov, new SolidColorBrush(Color.FromRgb(0x0a, 0x0a, 0x0a)), 88); contBtn.IsEnabled = !isFatal; contBtn.FontWeight = isFatal ? FontWeights.Normal : FontWeights.SemiBold; contBtn.Margin = new Thickness(0, 0, 8, 0); contBtn.Click += (_, _) => { shouldContinue = true; win.Close(); }; var quitBtnCtrl = MakeCrashButton(CrashText("Str_Crash_Quit", "Quit"), quitNorm, quitHov, Brushes.White, 72); quitBtnCtrl.FontWeight = isFatal ? FontWeights.SemiBold : FontWeights.Normal; quitBtnCtrl.Click += (_, _) => { shouldContinue = false; win.Close(); }; rightBtns.Children.Add(contBtn); rightBtns.Children.Add(quitBtnCtrl); DockPanel.SetDock(rightBtns, Dock.Right); btnPanel.Children.Add(rightBtns); btnBorder.Child = btnPanel; root.Children.Add(btnBorder); win.Content = root; win.ShowDialog(); return shouldContinue; } private static Button MakeTitleBarCloseButton(SolidColorBrush fg, SolidColorBrush hoverBg) { var t = new ControlTemplate(typeof(Button)); var b = new FrameworkElementFactory(typeof(Border)); b.SetBinding(Border.BackgroundProperty, new System.Windows.Data.Binding("Background") { RelativeSource = new System.Windows.Data.RelativeSource( System.Windows.Data.RelativeSourceMode.TemplatedParent) }); var cp = new FrameworkElementFactory(typeof(ContentPresenter)); cp.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center); cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); b.AppendChild(cp); t.VisualTree = b; var s = new Style(typeof(Button)); s.Setters.Add(new Setter(Button.BackgroundProperty, Brushes.Transparent)); s.Setters.Add(new Setter(Button.ForegroundProperty, fg)); s.Setters.Add(new Setter(Button.TemplateProperty, t)); var trig = new Trigger { Property = UIElement.IsMouseOverProperty, Value = true }; trig.Setters.Add(new Setter(Button.BackgroundProperty, hoverBg)); trig.Setters.Add(new Setter(Button.ForegroundProperty, Brushes.White)); s.Triggers.Add(trig); return new Button { Content = "", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, Width = 46, BorderThickness = new Thickness(0), VerticalAlignment = VerticalAlignment.Stretch, VerticalContentAlignment = VerticalAlignment.Center, Cursor = Cursors.Arrow, Style = s }; } private static Button MakeCrashButton(string label, SolidColorBrush normal, SolidColorBrush hover, SolidColorBrush fg, double width = 88) { var btn = UiKit.Make(label, normal, hover, fg, fg); btn.Width = width; btn.Height = 28; return btn; } private static string FormatExceptionChain(Exception ex) { var sb = new StringBuilder(); var inner = ex; var depth = 0; while (inner != null && depth < 5) { if (depth > 0) { sb.AppendLine(); sb.AppendLine("=== Inner Exception ==="); } sb.AppendLine($"{inner.GetType().FullName}: {inner.Message}"); sb.AppendLine(inner.StackTrace ?? "(no stack trace)"); inner = inner.InnerException; depth++; } return sb.ToString().TrimEnd(); } private static string BuildFullCrashReport(Exception ex) { var sb = new StringBuilder(); var ver = Assembly.GetExecutingAssembly().GetName().Version; sb.AppendLine($"MmdPdf v{ver?.ToString(3)}"); sb.AppendLine($"Time : {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); sb.AppendLine($"OS : {Environment.OSVersion}"); sb.AppendLine(); sb.Append(FormatExceptionChain(ex)); return sb.ToString(); } // ============================================================ // Public surface used by MainWindow (portable badge / install) // ============================================================ /// /// True when running from outside the installed location (i.e. portable mode). /// internal static bool IsPortable() { try { string currentExe = Process.GetCurrentProcess().MainModule!.FileName; return !string.Equals(currentExe, InstallExe, StringComparison.OrdinalIgnoreCase) && !string.Equals(currentExe, MachineInstallExe, StringComparison.OrdinalIgnoreCase); } catch { return false; } } /// True when MmdPdf is already installed machine-wide. internal static bool MachineInstallExists() => File.Exists(MachineInstallExe) || File.Exists(LegacyMachineInstallExe); /// True when MmdPdf is already installed for the current user. internal static bool UserInstallExists() => File.Exists(InstallExe) || File.Exists(LegacyUserInstallExe); /// Repairs a machine that carries BOTH a per-user and a machine-wide install - /// the state where each Add/Remove Programs entry describes the other copy's version and /// launching gets whichever exe the shell resolves first. Detected at startup; offers to /// remove whichever copy is NOT running. Removing the machine copy needs elevation, so /// that path re-runs this exe with /remove-machine-conflict under UAC. private static void OfferInstallConflictRepair() { if (!UserInstallExists() || !MachineInstallExists()) return; string current = Process.GetCurrentProcess().MainModule?.FileName ?? ""; bool runningMachine = string.Equals(current, MachineInstallExe, StringComparison.OrdinalIgnoreCase) || string.Equals(current, LegacyMachineInstallExe, StringComparison.OrdinalIgnoreCase); bool runningUser = string.Equals(current, InstallExe, StringComparison.OrdinalIgnoreCase) || string.Equals(current, LegacyUserInstallExe, StringComparison.OrdinalIgnoreCase); if (!runningMachine && !runningUser) return; string other = runningMachine ? "per-user" : "all-users"; if (MessageBox.Show($"MmdPdf is installed twice. Remove the other {other} copy now?\n\nYour settings will not be removed.", $"{AppName} installation conflict", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) return; if (runningMachine) { RemovePerUserInstall(); // The machine install's HKLM handler serves every account; drop the per-user // registration so it cannot shadow the shared Program Files paths (#183). UnregisterFileHandler(Registry.CurrentUser); } else { try { using var p = Process.Start(new ProcessStartInfo(current, "/remove-machine-conflict") { UseShellExecute = true, Verb = "runas" }); p?.WaitForExit(); } catch { /* declining UAC leaves both copies in place */ } } } private static void RemoveMachineInstallConflict() { try { File.Delete(MachineStartMenuLnk); } catch { } try { Directory.Delete(MachineStartMenuDir, recursive: false); } catch { } UnregisterFileHandler(Registry.LocalMachine); Services.ProtocolRegistrar.Unregister(Registry.LocalMachine); try { Registry.LocalMachine.DeleteSubKeyTree(@"Software\MmdPdf"); } catch { } try { Registry.LocalMachine.DeleteSubKeyTree( @"Software\Microsoft\Windows\CurrentVersion\Uninstall\MmdPdf"); } catch { } try { if (Directory.Exists(MachineInstallDir)) Directory.Delete(MachineInstallDir, true); } catch { } SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); } /// /// Installs MmdPdf, offers to set it as the default PDF handler, then relaunches from /// the installed location. Returns false if the install did not happen, so the caller can /// put the portable badge back rather than leaving the user with a half-finished state. /// /// An all-users install re-runs this exe elevated with /silent - the same machine-wide path /// winget and choco use - so UAC only appears when the user actually asked for it. /// internal static bool InstallAndRelaunch(string? fileToOpen, bool wantDesktop, bool allUsers) { string targetExe; string? launcher = GetPortableLauncherPath(); if (!allUsers && MachineInstallExists()) { KillerDialog.Show(Current.MainWindow, Current.TryFindResource("Str_Dlg_OneInstallOnly") as string ?? "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.", Current.TryFindResource("Str_Dlg_InstallTitle") as string ?? "Install MmdPdf", MessageBoxButton.OK, MessageBoxImage.Information); return false; } if (allUsers) { if (!RunElevatedSilentInstall(launcher)) return false; // Only ever one install: drop the per-user copy so there is a single Start Menu // entry and a single uninstall entry. Settings are deliberately left alone. RemovePerUserInstall(); // The elevated pass registered the handler in HKLM for every account. Remove an // older per-user registration so it cannot shadow the shared Program Files paths. UnregisterFileHandler(Registry.CurrentUser); targetExe = MachineInstallExe; } else { if (launcher != null) { if (!RunLauncherUserInstall(launcher, wantDesktop)) return false; } else { // Compatibility for old woven builds and direct developer runs. DoInstall(wantDesktop); } if (!File.Exists(InstallExe)) return false; // trust gate / copy failure already reported targetExe = InstallExe; } if (!IsDefaultPdfHandler()) { // Loc() is a MainWindow member; App resolves strings off the merged dictionaries // directly, the same way Uninstall() does. var res = KillerDialog.Show(null, Current.TryFindResource("Str_Dlg_SetDefaultPdfMsg") as string ?? "Would you like to set MmdPdf as your default PDF viewer?", AppName, MessageBoxButton.YesNo); if (res == MessageBoxResult.Yes) Process.Start(new ProcessStartInfo("ms-settings:defaultapps") { UseShellExecute = true }); } var psi = new ProcessStartInfo(targetExe); if (fileToOpen != null) psi.Arguments = $"\"{fileToOpen}\""; // Free the single-instance mutex first so the launched copy becomes primary (and shows a // window) instead of treating itself as a duplicate and exiting. (Current as App)?.ReleaseInstanceMutex(); Process.Start(psi); Application.Current.Shutdown(); return true; } /// Re-run this exe elevated with /silent and wait for it to finish. private static string? GetPortableLauncherPath() { try { string? path = Environment.GetEnvironmentVariable("MMDPDF_LAUNCHER_PATH"); return !string.IsNullOrWhiteSpace(path) && File.Exists(path) ? Path.GetFullPath(path) : null; } catch { return null; } } private static bool RunLauncherUserInstall(string launcher, bool wantDesktop) { try { string arguments = wantDesktop ? "/install-user /desktop" : "/install-user"; using var process = Process.Start(new ProcessStartInfo(launcher, arguments) { UseShellExecute = false, }); process?.WaitForExit(); return process is not null && process.ExitCode == 0 && File.Exists(InstallExe); } catch { return false; } } private static bool RunElevatedSilentInstall(string? launcher = null) { try { string installer = launcher ?? Process.GetCurrentProcess().MainModule!.FileName; var psi = new ProcessStartInfo(installer, "/silent") { UseShellExecute = true, Verb = "runas", // triggers the UAC prompt }; using var p = Process.Start(psi); p?.WaitForExit(); return p is not null && p.ExitCode == 0 && File.Exists(MachineInstallExe); } catch { // Declining the UAC prompt throws Win32Exception 1223 (ERROR_CANCELLED). return false; } } /// Remove a per-user install: files, shortcuts and its HKCU install markers. /// The settings are deliberately left alone so theme, accent, locale, recent files and /// window placement survive the move to a machine-wide install. private static void RemovePerUserInstall() { try { if (File.Exists(StartMenuLnk)) File.Delete(StartMenuLnk); } catch { } try { if (Directory.Exists(StartMenuDir)) Directory.Delete(StartMenuDir, true); } catch { } try { if (File.Exists(DesktopLnk)) File.Delete(DesktopLnk); } catch { } try { if (Directory.Exists(InstallDir)) Directory.Delete(InstallDir, true); } catch { } try { using var key = Registry.CurrentUser.OpenSubKey(@"Software\MmdPdf", writable: true); key?.DeleteValue("Installed", throwOnMissingValue: false); key?.DeleteValue("InstallPath", throwOnMissingValue: false); } catch { } try { Registry.CurrentUser.DeleteSubKeyTree( @"Software\Microsoft\Windows\CurrentVersion\Uninstall\MmdPdf", throwOnMissingSubKey: false); } catch { } } // ============================================================ // Silent (machine-wide) install - winget / choco / RMM / the all-users checkbox // ============================================================ /// /// Machine-wide install. Runs elevated with no UI by definition, so the exit code is the /// only signal a deployment tool - or RunElevatedSilentInstall - gets back. /// /// File associations are registered in HKLM so every account sees MmdPdf in Open With /// and Default apps. Windows still leaves the actual default choice to each user. /// private static void DoSilentInstall() { try { string src = Process.GetCurrentProcess().MainModule!.FileName; // Same trust gate as the interactive path - an unsigned or wrong-publisher exe must // not be able to write itself into Program Files, least of all while elevated. var (valid, _, _) = VerifyAuthenticode(src); if (!valid) { Console.Error.WriteLine("Silent install refused: EXE has no valid Authenticode signature."); Environment.Exit(1); return; } Directory.CreateDirectory(MachineInstallDir); if (File.Exists(MachineInstallExe)) { try { File.SetAttributes(MachineInstallExe, FileAttributes.Normal); } catch { } } File.Copy(src, MachineInstallExe, overwrite: true); try { File.SetAttributes(MachineInstallExe, FileAttributes.Normal); } catch { } RegisterInstalledCopy(machine: true, desktop: false); } catch (Exception ex) { Console.Error.WriteLine("Silent install failed: " + ex.Message); Environment.Exit(1); } } // ============================================================ // Registry helpers // ============================================================ // ============================================================ // Temp file tracking // ============================================================ /// /// User-private temp directory for session working files (encrypted PDFs, etc.). /// %LOCALAPPDATA% is user-private and not indexed by Windows Search. /// internal static readonly string TempDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MmdPdf", "Temp"); private static readonly List _sessionTemps = []; /// /// Creates a tracked temp path of the form mmdpdf_<tag>_<guid>.pdf /// under %LOCALAPPDATA%\MmdPdf\Temp\. /// All registered paths are deleted when CleanupSessionTemps() is called. /// internal static string MakeTempFile(string tag) { try { Directory.CreateDirectory(TempDir); } catch { } var path = Path.Combine(TempDir, $"mmdpdf_{tag}_{Guid.NewGuid():N}.pdf"); lock (_sessionTemps) _sessionTemps.Add(path); return path; } /// Deletes all temp files registered this session (best-effort). internal static void CleanupSessionTemps() { lock (_sessionTemps) { foreach (var f in _sessionTemps) try { if (File.Exists(f)) File.Delete(f); } catch { } _sessionTemps.Clear(); } } /// /// Deletes mmdpdf_*.pdf files left over from previous crashed sessions. /// Sweeps both the current TempDir and the legacy %TEMP% location. /// Locked files (still open by another instance) are silently skipped. /// internal static void CleanupStaleTemps() { // Current location try { if (Directory.Exists(TempDir)) foreach (var f in Directory.GetFiles(TempDir, "mmdpdf_*.pdf")) try { File.Delete(f); } catch { } } catch { } // Legacy %TEMP% location - sweep once for users upgrading from older builds try { foreach (var f in Directory.GetFiles(Path.GetTempPath(), "mmdpdf_*.pdf")) try { File.Delete(f); } catch { } } catch { } } internal static string? GetSetting(string name) { try { using var key = Registry.CurrentUser.OpenSubKey(@"Software\MmdPdf\Settings"); return key?.GetValue(name) as string; } catch { return null; } } internal static void SetSetting(string name, string value) { try { using var key = Registry.CurrentUser.CreateSubKey(@"Software\MmdPdf\Settings"); key.SetValue(name, value); } catch { /* best-effort */ } } internal static void RemoveSetting(string name) { try { using var key = Registry.CurrentUser.OpenSubKey(@"Software\MmdPdf\Settings", writable: true); key?.DeleteValue(name, throwOnMissingValue: false); } catch { /* best-effort */ } } /// /// Wipes all persisted MmdPdf state: settings (registry), downloaded OCR language packs, the /// native OCR cache, and temp files. Best-effort - files locked this session (e.g. loaded native /// DLLs) are skipped and clear on the next restart. The user's actual PDFs are never touched. /// internal static void ClearAllData() { try { Registry.CurrentUser.DeleteSubKeyTree(@"Software\MmdPdf\Settings", throwOnMissingSubKey: false); } catch { } string localKp = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MmdPdf"); TryDeleteDir(Path.Combine(localKp, "tessdata")); // downloaded language packs + bundled English TryDeleteDir(Path.Combine(localKp, "ocr")); // native Tesseract cache TryDeleteDir(TempDir); // temp working files // Legacy temp PDFs that may linger in %TEMP%. try { foreach (var f in Directory.GetFiles(Path.GetTempPath(), "mmdpdf_*.pdf")) try { File.Delete(f); } catch { } } catch { } } private static void TryDeleteDir(string dir) { try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } catch { // A whole-folder delete fails if any file is locked; remove what we can so the rest clears now. try { foreach (var f in Directory.GetFiles(dir, "*", SearchOption.AllDirectories)) try { File.Delete(f); } catch { } } catch { } } } // ── Recent files (most-recent first, capped) ───────────────────── private const int RecentFilesMax = 10; // "1" = don't track recently opened files at all (#146, privacy on shared machines). internal const string NoRecentFilesSetting = "NoRecentFiles"; internal static System.Collections.Generic.List GetRecentFiles() { var list = new System.Collections.Generic.List(); var raw = GetSetting("RecentFiles"); if (string.IsNullOrEmpty(raw)) return list; foreach (var p in raw!.Split('|')) if (!string.IsNullOrWhiteSpace(p)) list.Add(p); return list; } internal static void AddRecentFile(string path) { if (GetSetting(NoRecentFilesSetting) == "1") return; // tracking disabled (#146) if (string.IsNullOrWhiteSpace(path)) return; var list = GetRecentFiles(); list.RemoveAll(p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); list.Insert(0, path); while (list.Count > RecentFilesMax) list.RemoveAt(list.Count - 1); SetSetting("RecentFiles", string.Join("|", list)); // '|' is illegal in Windows paths } internal static void ClearRecentFiles() => RemoveSetting("RecentFiles"); // Drops a single entry from the recent-files list (used by the per-row remove button). internal static void RemoveRecentFile(string path) { if (string.IsNullOrWhiteSpace(path)) return; var list = GetRecentFiles(); list.RemoveAll(p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); if (list.Count == 0) RemoveSetting("RecentFiles"); else SetSetting("RecentFiles", string.Join("|", list)); } private static bool IsInstalled() { using var key = Registry.CurrentUser.OpenSubKey(@"Software\MmdPdf"); if (key is null) return false; return key.GetValue("Installed") is int i && i == 1; } private static bool IsDefaultPdfHandler() { using var key = Registry.CurrentUser.OpenSubKey( @"Software\Microsoft\Windows\Shell\Associations\FileAssociations\.pdf\UserChoice"); return key?.GetValue("ProgId") is string progId && progId.Equals("MmdPdf.pdf", StringComparison.OrdinalIgnoreCase); } // ============================================================ // Launcher dialog // ============================================================ /// /// Shows the Install / Run dialog. /// Returns (canceled, install, wantDesktopShortcut). /// private static (bool canceled, bool install, bool desktop) ShowLauncher(bool alreadyInstalled) { bool canceled = true; bool install = false; bool desktop = true; var bg = new SolidColorBrush(Color.FromRgb(0x1a, 0x1a, 0x1a)); var dimBg = new SolidColorBrush(Color.FromRgb(0x25, 0x25, 0x25)); var accent = new SolidColorBrush(Color.FromRgb(0x4a, 0xde, 0x80)); var dimText = new SolidColorBrush(Color.FromRgb(0x77, 0x77, 0x77)); var win = new Window { Title = AppName, Width = 400, Height = 280, WindowStartupLocation = WindowStartupLocation.CenterScreen, ResizeMode = ResizeMode.NoResize, WindowStyle = WindowStyle.None, Background = bg }; // ── Root grid: title bar row + content row ────────────────── var root = new Grid(); root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(36) }); root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // ── Title bar ─────────────────────────────────────────────── var titleBar = new DockPanel { Background = dimBg }; Grid.SetRow(titleBar, 0); // Drag anywhere on the title bar titleBar.MouseLeftButtonDown += (_, e) => { if (e.ButtonState == MouseButtonState.Pressed) win.DragMove(); }; // Close button - custom template so Background trigger actually renders var closeBtnTemplate = new ControlTemplate(typeof(Button)); var closeBorder = new FrameworkElementFactory(typeof(Border)); closeBorder.SetBinding(Border.BackgroundProperty, new System.Windows.Data.Binding("Background") { RelativeSource = new System.Windows.Data.RelativeSource( System.Windows.Data.RelativeSourceMode.TemplatedParent) }); var closeContent = new FrameworkElementFactory(typeof(ContentPresenter)); closeContent.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center); closeContent.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); closeBorder.AppendChild(closeContent); closeBtnTemplate.VisualTree = closeBorder; var redHover = new SolidColorBrush(Color.FromRgb(0xc4, 0x2b, 0x1c)); var closeBtnStyle = new Style(typeof(Button)); closeBtnStyle.Setters.Add(new Setter(Button.BackgroundProperty, Brushes.Transparent)); closeBtnStyle.Setters.Add(new Setter(Button.ForegroundProperty, dimText)); closeBtnStyle.Setters.Add(new Setter(Button.TemplateProperty, closeBtnTemplate)); var hoverTrigger = new Trigger { Property = UIElement.IsMouseOverProperty, Value = true }; hoverTrigger.Setters.Add(new Setter(Button.BackgroundProperty, redHover)); hoverTrigger.Setters.Add(new Setter(Button.ForegroundProperty, Brushes.White)); closeBtnStyle.Triggers.Add(hoverTrigger); var closeBtn = new Button { Content = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, Width = 46, BorderThickness = new Thickness(0), VerticalAlignment = VerticalAlignment.Stretch, VerticalContentAlignment = VerticalAlignment.Center, Cursor = Cursors.Arrow, Style = closeBtnStyle }; closeBtn.Click += (_, _) => win.Close(); DockPanel.SetDock(closeBtn, Dock.Right); titleBar.Children.Add(closeBtn); // App label in title bar titleBar.Children.Add(new TextBlock { Text = AppName, Foreground = dimText, FontSize = 12, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(12, 0, 0, 0) }); root.Children.Add(titleBar); // ── Content ───────────────────────────────────────────────── var content = new StackPanel { Margin = new Thickness(36, 22, 36, 28) }; Grid.SetRow(content, 1); content.Children.Add(new TextBlock { Text = AppName, FontSize = 26, FontWeight = FontWeights.Bold, Foreground = accent }); var version = Assembly.GetExecutingAssembly().GetName().Version; content.Children.Add(new TextBlock { Text = $"Version {version?.ToString(3)}", Foreground = dimText, FontSize = 12, Margin = new Thickness(0, 2, 0, 18) }); content.Children.Add(new TextBlock { Text = alreadyInstalled ? "A newer version is available. Install it or run without updating." : "Install MmdPdf on this computer, or run it without installing.", Foreground = Brushes.White, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16) }); var desktopChk = new CheckBox { IsChecked = true, Margin = new Thickness(0, 0, 0, 22), Content = new TextBlock { Text = Current.TryFindResource("Str_Dlg_InstallShortcut") as string ?? "Create desktop shortcut", Foreground = Brushes.White } }; content.Children.Add(desktopChk); var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right }; var runBtn = UiKit.Make("Run", new SolidColorBrush(Color.FromRgb(0x30, 0x30, 0x30)), new SolidColorBrush(Color.FromRgb(0x16, 0x63, 0x34)), Brushes.White, Brushes.White); runBtn.Width = 88; runBtn.Margin = new Thickness(0, 0, 8, 0); var installBtn = UiKit.Make(alreadyInstalled ? "Update" : "Install", accent, new SolidColorBrush(Color.FromRgb(0x4a, 0xf0, 0x90)), new SolidColorBrush(Color.FromRgb(0x0a, 0x0a, 0x0a)), new SolidColorBrush(Color.FromRgb(0x0a, 0x0a, 0x0a))); installBtn.Width = 110; installBtn.FontWeight = FontWeights.SemiBold; runBtn.Click += (_, _) => { canceled = false; install = false; win.Close(); }; installBtn.Click += (_, _) => { canceled = false; install = true; desktop = desktopChk.IsChecked == true; win.Close(); }; btnRow.Children.Add(runBtn); btnRow.Children.Add(installBtn); content.Children.Add(btnRow); root.Children.Add(content); win.Content = root; win.ShowDialog(); return (canceled, install, desktop); } // ============================================================ // Security - Authenticode verification + pdfium integrity // ============================================================ // ── WinVerifyTrust P/Invoke ────────────────────────────────────────── [StructLayout(LayoutKind.Sequential)] private struct WINTRUST_FILE_INFO { public uint cbStruct; public IntPtr pcwszFilePath; // LPCWSTR public IntPtr hFile; public IntPtr pgKnownSubject; } [StructLayout(LayoutKind.Sequential)] private struct WINTRUST_DATA { public uint cbStruct; public IntPtr pPolicyCallbackData; public IntPtr pSIPClientData; public uint dwUIChoice; // 2 = WTD_UI_NONE public uint fdwRevocationChecks; // 0 = WTD_REVOKE_NONE public uint dwUnionChoice; // 1 = WTD_CHOICE_FILE public IntPtr pUnion; // → WINTRUST_FILE_INFO public uint dwStateAction; // 0 = WTD_STATEACTION_IGNORE public IntPtr hWVTStateData; public IntPtr pwszURLReference; public uint dwProvFlags; // 0x1000 = WTD_CACHE_ONLY_URL_RETRIEVAL public uint dwUIContext; public IntPtr pSignatureSettings; } private static readonly Guid WTD_VERIFY_GENERIC = new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); [DllImport("wintrust.dll", ExactSpelling = true, SetLastError = false, CharSet = CharSet.Unicode)] private static extern uint WinVerifyTrust( IntPtr hwnd, ref Guid pgActionID, IntPtr pWVTData); // ── Public helpers ─────────────────────────────────────────────────── /// /// Calls WinVerifyTrust to validate an Authenticode signature. /// Returns (Valid, SubjectCN, Thumbprint). /// Valid=false for unsigned, expired (past grace), or tampered files. /// internal static (bool Valid, string Subject, string Thumbprint) VerifyAuthenticode(string filePath) { var subject = "(not signed)"; var thumbprint = string.Empty; // Try to read cert info regardless of signature validity try { var raw = X509Certificate.CreateFromSignedFile(filePath); var cert = new X509Certificate2(raw); subject = cert.GetNameInfo(X509NameType.SimpleName, forIssuer: false); thumbprint = cert.Thumbprint ?? string.Empty; } catch { /* unsigned or unreadable */ } // Full chain + revocation check via WinVerifyTrust var pathPtr = Marshal.StringToHGlobalUni(filePath); var fileInfoPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); var dataPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); try { Marshal.StructureToPtr(new WINTRUST_FILE_INFO { cbStruct = (uint)Marshal.SizeOf(), pcwszFilePath = pathPtr }, fileInfoPtr, false); Marshal.StructureToPtr(new WINTRUST_DATA { cbStruct = (uint)Marshal.SizeOf(), dwUIChoice = 2, // WTD_UI_NONE dwUnionChoice = 1, // WTD_CHOICE_FILE pUnion = fileInfoPtr, dwProvFlags = 0x1000 // WTD_CACHE_ONLY_URL_RETRIEVAL, never hit the network }, dataPtr, false); var actionId = WTD_VERIFY_GENERIC; uint hr = WinVerifyTrust(IntPtr.Zero, ref actionId, dataPtr); return (hr == 0, subject, thumbprint); } finally { Marshal.FreeHGlobal(dataPtr); Marshal.FreeHGlobal(fileInfoPtr); Marshal.FreeHGlobal(pathPtr); } } /// /// Convenience wrapper: verify the currently running EXE. /// internal static (bool Valid, string Subject, string Thumbprint) GetExeSignerInfo() { try { return VerifyAuthenticode(Process.GetCurrentProcess().MainModule!.FileName); } catch { return (false, "(not signed)", string.Empty); } } /// SHA256 hex of the currently running EXE (for the About dialog). internal static string GetExeSha256() { try { var path = Process.GetCurrentProcess().MainModule!.FileName; using var sha = SHA256.Create(); using var fs = File.OpenRead(path); return BitConverter.ToString(sha.ComputeHash(fs)).Replace("-", ""); } catch { return "(unavailable)"; } } // ── About dialog ──────────────────────────────────────────────────────── internal static void ShowAboutDialog(Window owner) { // Gather info on a background thread so the UI isn't blocked by hashing var version = System.Reflection.Assembly.GetExecutingAssembly() .GetName().Version?.ToString(3) ?? "?"; var (sigValid, sigSubject, sigThumbprint) = GetExeSignerInfo(); var sha256 = GetExeSha256(); // ── Layout ────────────────────────────────────────────────────── var bg = new SolidColorBrush(Color.FromRgb(0x1e, 0x1e, 0x1e)); var bgCard = new SolidColorBrush(Color.FromRgb(0x2a, 0x2a, 0x2a)); var fg = new SolidColorBrush(Color.FromRgb(0xe0, 0xe0, 0xe0)); var fgDim = new SolidColorBrush(Color.FromRgb(0x77, 0x77, 0x77)); var accent = new SolidColorBrush(Color.FromRgb(0x1e, 0xa5, 0x4c)); var mono = new FontFamily("Consolas"); // Title bar var titleBar = new Grid { Background = new SolidColorBrush(Color.FromRgb(0x25, 0x25, 0x25)), Height = 32 }; titleBar.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); titleBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); var titleText = new TextBlock { Text = $"About MmdPdf", Foreground = fg, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(12, 0, 0, 0), FontSize = 13, FontWeight = FontWeights.SemiBold }; Grid.SetColumn(titleText, 0); titleBar.Children.Add(titleText); Window? dlg = null; var closeBtn = MakeTitleBarCloseButton( new SolidColorBrush(Color.FromRgb(0xcc, 0xcc, 0xcc)), new SolidColorBrush(Color.FromRgb(0xc0, 0x3b, 0x3b))); closeBtn.Click += (_, __) => dlg!.Close(); Grid.SetColumn(closeBtn, 1); titleBar.Children.Add(closeBtn); // Helper: labeled row (onClick makes the value a clickable hyperlink) static StackPanel MakeRow(string label, string value, SolidColorBrush labelBrush, SolidColorBrush valueBrush, FontFamily? valueFont = null, bool wrap = false, Action? onClick = null) { var sp = new StackPanel { Orientation = Orientation.Vertical, Margin = new Thickness(0, 0, 0, 10) }; sp.Children.Add(new TextBlock { Text = label, Foreground = labelBrush, FontSize = 10, Margin = new Thickness(0, 0, 0, 2) }); if (onClick != null) { var tb = new TextBlock { FontFamily = valueFont ?? new FontFamily("Segoe UI"), FontSize = 12, Cursor = Cursors.Hand }; var hl = new Hyperlink(new Run(value)) { Foreground = valueBrush, TextDecorations = null }; hl.Click += (_, _) => onClick(); tb.Inlines.Add(hl); sp.Children.Add(tb); } else { sp.Children.Add(new TextBlock { Text = value, Foreground = valueBrush, FontFamily = valueFont ?? new FontFamily("Segoe UI"), FontSize = 12, TextWrapping = wrap ? TextWrapping.Wrap : TextWrapping.NoWrap }); } return sp; } static void OpenUrl(string url) { try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } catch { } } var sigInfo = sigValid ? $"{sigSubject}" : "(not signed or chain failed)"; var thumbInfo = string.IsNullOrEmpty(sigThumbprint) ? "(none)" : sigThumbprint; var card = new Border { Background = bgCard, CornerRadius = new CornerRadius(6), Padding = new Thickness(16), Margin = new Thickness(0, 0, 0, 0) }; var cardContent = new StackPanel(); cardContent.Children.Add(MakeRow("VERSION", $"v{version}", fgDim, accent, onClick: () => OpenUrl($"https://github.com/SteveTheKiller/MmdPdf/releases/tag/v{version}"))); cardContent.Children.Add(MakeRow("PUBLISHER", sigInfo, fgDim, fg)); cardContent.Children.Add(MakeRow("THUMBPRINT", thumbInfo, fgDim, fg, mono, wrap: true)); cardContent.Children.Add(MakeRow("EXE SHA256", sha256, fgDim, fg, mono, wrap: true)); card.Child = cardContent; // Close button var okBtn = UiKit.Make("Close", new SolidColorBrush(Color.FromRgb(0x1e, 0xa5, 0x4c)), new SolidColorBrush(Color.FromRgb(0x17, 0x7a, 0x38)), new SolidColorBrush(Colors.White), new SolidColorBrush(Colors.White)); okBtn.Width = 80; okBtn.Height = 28; okBtn.HorizontalAlignment = HorizontalAlignment.Right; okBtn.Margin = new Thickness(0, 12, 0, 0); okBtn.Click += (_, __) => dlg!.Close(); // MmdPdf logo - clickable link to product site var logo = new TextBlock { FontSize = 22, FontWeight = FontWeights.Bold, Margin = new Thickness(0, 0, 0, 4) }; var logoHl = new Hyperlink(new Run("MmdPdf")) { Foreground = accent, TextDecorations = null }; logoHl.Click += (_, _) => OpenUrl("https://pdf.killertools.com"); logo.Inlines.Add(logoHl); // Tagline with Killer Tools link var tagline = new TextBlock { FontSize = 11, Margin = new Thickness(0, 0, 0, 16) }; tagline.Inlines.Add(new Run("A fast, free PDF toolkit for Windows. Part of ") { Foreground = fgDim }); var ktHl = new Hyperlink(new Run("Killer Tools")) { Foreground = accent, TextDecorations = null }; ktHl.Click += (_, _) => OpenUrl("https://baobab-ts.com"); tagline.Inlines.Add(ktHl); tagline.Inlines.Add(new Run(".") { Foreground = fgDim }); var body = new StackPanel { Margin = new Thickness(16, 16, 16, 20) }; body.Children.Add(logo); body.Children.Add(tagline); body.Children.Add(card); body.Children.Add(okBtn); var root = new DockPanel(); DockPanel.SetDock(titleBar, Dock.Top); root.Children.Add(titleBar); root.Children.Add(body); root.Background = bg; // Make title bar draggable titleBar.MouseLeftButtonDown += (_, me) => { if (me.ButtonState == MouseButtonState.Pressed) dlg!.DragMove(); }; dlg = new Window { Content = root, WindowStyle = WindowStyle.None, ResizeMode = ResizeMode.NoResize, SizeToContent = SizeToContent.Height, Width = 540, Owner = owner, WindowStartupLocation = WindowStartupLocation.CenterOwner, Background = bg }; dlg.ShowDialog(); } // ── pdfium.dll integrity check ─────────────────────────────────────── /// /// Finds the Costura-embedded pdfium resource, decompresses it in-memory, /// and compares its SHA256 to BuildInfo.PdfiumSha256. /// Returns false (and shows a message box) only on a confirmed mismatch. /// Fails-open if the check cannot complete (dev builds, missing resource, I/O error). /// private static bool CheckPdfiumIntegrity() { if (string.Equals(BuildInfo.PdfiumSha256, BuildInfo.PdfiumSha256Disabled, StringComparison.Ordinal)) return true; // disabled for this build (dev / SkipSign) var asm = Assembly.GetExecutingAssembly(); var resourceName = Array.Find(asm.GetManifestResourceNames(), n => n.IndexOf("pdfium", StringComparison.OrdinalIgnoreCase) >= 0 && n.EndsWith(".compressed", StringComparison.OrdinalIgnoreCase)); if (resourceName == null) return true; // not bundled via Costura (dev build running from bin/) try { string actual; using (var rs = asm.GetManifestResourceStream(resourceName)!) using (var deflate = new DeflateStream(rs, CompressionMode.Decompress)) using (var sha = SHA256.Create()) actual = BitConverter.ToString(sha.ComputeHash(deflate)).Replace("-", ""); if (!string.Equals(actual, BuildInfo.PdfiumSha256, StringComparison.OrdinalIgnoreCase)) { MessageBox.Show( "Security check failed: pdfium.dll integrity verification failed.\n\n" + $"Expected: {BuildInfo.PdfiumSha256}\n" + $"Actual : {actual}\n\n" + "The bundled PDF engine may have been tampered with. MmdPdf will exit.", $"{AppName} - Security", MessageBoxButton.OK, MessageBoxImage.Error); return false; } return true; } catch { return true; // fail-open: only block on confirmed mismatch } } // ============================================================ // Installation // ============================================================ private static void DoInstall(bool wantDesktop) { string src = Process.GetCurrentProcess().MainModule!.FileName; // ── Trust gate: refuse to install an unsigned or wrong-publisher EXE ── var (valid, _, _) = VerifyAuthenticode(src); if (!valid) { MessageBox.Show( "Installation refused: the running EXE does not carry a valid Authenticode " + "signature.\n\nOnly signed builds of MmdPdf can be installed.", AppName, MessageBoxButton.OK, MessageBoxImage.Error); return; } // ── Downgrade guard ──────────────────────────────────────────────────── if (File.Exists(InstallExe)) { var runVer = FileVersionInfo.GetVersionInfo(src).FileVersion ?? ""; var instVer = FileVersionInfo.GetVersionInfo(InstallExe).FileVersion ?? ""; if (string.Compare(runVer, instVer, StringComparison.OrdinalIgnoreCase) < 0) { var res = MessageBox.Show( $"You are about to install an older version ({runVer}) " + $"over the currently installed version ({instVer}).\n\n" + "Downgrade anyway?", AppName, MessageBoxButton.YesNo, MessageBoxImage.Warning); if (res != MessageBoxResult.Yes) return; } } try { // Copy EXE to install location. Two snags make a plain overwrite throw "access denied": // (1) File.Copy carries the source's attributes, so a prior install can leave the target // read-only - and File.Copy(overwrite:true) throws on a read-only target instead of // replacing it. Clear the attribute first (and again after, so the next update works). // (2) The installed copy is running, so it's locked. Catch that and say so plainly. Directory.CreateDirectory(InstallDir); if (File.Exists(InstallExe)) { try { File.SetAttributes(InstallExe, FileAttributes.Normal); } catch { } } try { File.Copy(src, InstallExe, overwrite: true); } catch (Exception copyEx) when (copyEx is UnauthorizedAccessException or IOException) { MessageBox.Show( "Couldn't write the installed copy at:\n" + InstallExe + "\n\nClose any open MmdPdf window (and check Task Manager for MmdPdf.exe), " + "then run the installer again.", AppName, MessageBoxButton.OK, MessageBoxImage.Warning); return; } try { File.SetAttributes(InstallExe, FileAttributes.Normal); } catch { } RegisterInstalledCopy(machine: false, desktop: wantDesktop); } catch (Exception ex) { MessageBox.Show($"Installation failed:\n{ex.Message}", AppName, MessageBoxButton.OK, MessageBoxImage.Error); } } // iconPath: which install location to drop it in. Parameterized for the machine-wide // install, which writes into Program Files rather than the per-user folder. private static void ExtractFileIcon(string iconPath) { try { var asm = Assembly.GetExecutingAssembly(); var rn = Array.Find(asm.GetManifestResourceNames(), n => n.IndexOf("pdf-file", StringComparison.OrdinalIgnoreCase) >= 0 && n.EndsWith(".ico", StringComparison.OrdinalIgnoreCase)); if (rn == null) return; // dev build running from bin/ without the embedded icon using (var rs = asm.GetManifestResourceStream(rn)!) using (var fs = File.Create(iconPath)) rs.CopyTo(fs); } catch { } } /// /// Register MmdPdf under either HKCU for a per-user install or HKLM for a machine-wide /// install. This advertises the handler without changing any user's chosen PDF default. /// private static void RegisterFileHandler(RegistryKey root, string exePath, string iconPath) { // Prefer the dedicated PDF file icon; fall back to the app icon if extraction didn't run string iconRef = File.Exists(iconPath) ? $"\"{iconPath}\",0" : $"{exePath},0"; // ProgID definition using (var k = root.CreateSubKey(@"Software\Classes\MmdPdf.pdf")) k.SetValue("", "PDF Document"); using (var k = root.CreateSubKey( @"Software\Classes\MmdPdf.pdf\DefaultIcon")) k.SetValue("", iconRef); using (var k = root.CreateSubKey( @"Software\Classes\MmdPdf.pdf\shell\open\command")) k.SetValue("", $"\"{exePath}\" \"%1\""); // Associate .pdf extension - adds MmdPdf to the "Open with" list using (var k = root.CreateSubKey( @"Software\Classes\.pdf\OpenWithProgids")) k.SetValue("MmdPdf.pdf", new byte[0], RegistryValueKind.None); // RegisteredApplications capability (used by Default Programs UI) using (var k = root.CreateSubKey( @"Software\MmdPdf\Capabilities")) { k.SetValue("ApplicationName", AppName); k.SetValue("ApplicationDescription", "Lightweight PDF viewer and editor"); } using (var k = root.CreateSubKey( @"Software\MmdPdf\Capabilities\FileAssociations")) k.SetValue(".pdf", "MmdPdf.pdf"); // #183: without this the mmdpdf:// handler never appears in Windows Settings > // Default apps > Choose defaults by link type. Maps the scheme to its ProgID // (Software\Classes\mmdpdf, written by ProtocolRegistrar). using (var k = root.CreateSubKey( @"Software\MmdPdf\Capabilities\UrlAssociations")) k.SetValue(Services.ProtocolRegistrar.Scheme, Services.ProtocolRegistrar.Scheme); using (var k = root.CreateSubKey(@"Software\RegisteredApplications")) k.SetValue(AppName, @"Software\MmdPdf\Capabilities"); // Tell the shell file associations have changed SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); } private static void UnregisterFileHandler(RegistryKey root) { try { root.DeleteSubKeyTree(@"Software\Classes\MmdPdf.pdf", false); } catch { } try { root.DeleteSubKeyTree(@"Software\MmdPdf\Capabilities", false); } catch { } try { using var k = root.OpenSubKey(@"Software\Classes\.pdf\OpenWithProgids", writable: true); k?.DeleteValue("MmdPdf.pdf", throwOnMissingValue: false); } catch { } try { using var k = root.OpenSubKey(@"Software\RegisteredApplications", writable: true); k?.DeleteValue(AppName, throwOnMissingValue: false); } catch { } } private static void CreateShortcut(string lnkPath, string targetPath) { try { var shellType = Type.GetTypeFromProgID("WScript.Shell"); if (shellType is null) return; dynamic shell = Activator.CreateInstance(shellType)!; dynamic shortcut = shell.CreateShortcut(lnkPath); shortcut.TargetPath = targetPath; shortcut.WorkingDirectory = Path.GetDirectoryName(targetPath); shortcut.Save(); } catch { /* best-effort */ } } // ============================================================ // Uninstall // ============================================================ private static bool RelaunchMachineUninstallElevatedIfNeeded(bool machine) { if (!machine) return false; try { using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); var principal = new System.Security.Principal.WindowsPrincipal(identity); if (principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)) return false; Process.Start(new ProcessStartInfo( Process.GetCurrentProcess().MainModule!.FileName, "/uninstall") { UseShellExecute = true, Verb = "runas", }); } catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223) { // UAC was declined. Leave the installation untouched. } catch (Exception ex) { MessageBox.Show($"Uninstall could not request administrator access:\n{ex.Message}", AppName, MessageBoxButton.OK, MessageBoxImage.Error); } return true; } private static void RegisterInstalledCopy(bool machine, bool desktop) { string exePath = machine ? MachineInstallExe : InstallExe; string installDirectory = machine ? MachineInstallDir : InstallDir; string iconPath = machine ? MachineFileIconPath : FileIconPath; string startMenuDirectory = machine ? MachineStartMenuDir : StartMenuDir; string startMenuShortcut = machine ? MachineStartMenuLnk : StartMenuLnk; RegistryKey registryRoot = machine ? Registry.LocalMachine : Registry.CurrentUser; if (!File.Exists(exePath)) throw new FileNotFoundException("The installed application is missing.", exePath); Directory.CreateDirectory(startMenuDirectory); CreateShortcut(startMenuShortcut, exePath); if (!machine && desktop) CreateShortcut(DesktopLnk, exePath); if (!File.Exists(iconPath)) ExtractFileIcon(iconPath); RegisterFileHandler(registryRoot, exePath, iconPath); Services.ProtocolRegistrar.Register(registryRoot, exePath); using (var key = registryRoot.CreateSubKey(@"Software\MmdPdf")) { key.SetValue("Installed", 1); key.SetValue("InstallPath", exePath); key.SetValue("Version", Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? ""); } using (var key = registryRoot.CreateSubKey( @"Software\Microsoft\Windows\CurrentVersion\Uninstall\MmdPdf")) { key.SetValue("DisplayName", AppName); key.SetValue("DisplayVersion", Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? ""); key.SetValue("Publisher", "Steve / thekiller.net"); key.SetValue("InstallLocation", installDirectory); key.SetValue("DisplayIcon", $"{exePath},0"); key.SetValue("UninstallString", $"\"{exePath}\" /uninstall"); key.SetValue("QuietUninstallString", $"\"{exePath}\" /uninstall"); key.SetValue("NoModify", 1); key.SetValue("NoRepair", 1); } SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); } private static void Uninstall() { bool machine = string.Equals( Process.GetCurrentProcess().MainModule?.FileName, MachineInstallExe, StringComparison.OrdinalIgnoreCase); if (RelaunchMachineUninstallElevatedIfNeeded(machine)) return; var res = MessageBox.Show( "Uninstall MmdPdf from this computer?", $"{AppName} Uninstall", MessageBoxButton.YesNo, MessageBoxImage.Question); if (res != MessageBoxResult.Yes) return; // Shortcuts try { File.Delete(StartMenuLnk); } catch { } try { Directory.Delete(StartMenuDir, recursive: false); } catch { } try { File.Delete(DesktopLnk); } catch { } // Registry cleanup try { Registry.CurrentUser.DeleteSubKeyTree(@"Software\MmdPdf"); } catch { } Services.ProtocolRegistrar.Unregister(); try { Registry.CurrentUser.DeleteSubKeyTree( @"Software\Microsoft\Windows\CurrentVersion\Uninstall\MmdPdf"); } catch { } UnregisterFileHandler(Registry.CurrentUser); // Machine-wide half. Only reachable when Add/Remove Programs launched the Program Files // copy, which Windows runs elevated from the HKLM uninstall entry - so these writes // succeed there and simply fail harmlessly on a per-user uninstall. if (machine) { try { File.Delete(MachineStartMenuLnk); } catch { } try { Directory.Delete(MachineStartMenuDir, recursive: false); } catch { } UnregisterFileHandler(Registry.LocalMachine); Services.ProtocolRegistrar.Unregister(Registry.LocalMachine); // #183 try { Registry.LocalMachine.DeleteSubKeyTree(@"Software\MmdPdf"); } catch { } try { Registry.LocalMachine.DeleteSubKeyTree( @"Software\Microsoft\Windows\CurrentVersion\Uninstall\MmdPdf"); } catch { } } // Self-delete: deferred via cmd batch so the EXE can exit first string bat = Path.Combine(Path.GetTempPath(), "mmdpdf_uninstall.bat"); File.WriteAllText(bat, "@echo off\r\n" + "ping -n 3 127.0.0.1 >nul\r\n" + $"rmdir /s /q \"{(machine ? MachineInstallDir : InstallDir)}\"\r\n" + "del \"%~f0\"\r\n"); Process.Start(new ProcessStartInfo("cmd.exe", $"/c \"{bat}\"") { WindowStyle = ProcessWindowStyle.Hidden, UseShellExecute = true }); MessageBox.Show(Application.Current.TryFindResource("Str_Dlg_Uninstalled") as string ?? "MmdPdf has been uninstalled.", AppName, MessageBoxButton.OK, MessageBoxImage.Information); } } }