- Services/PdfRedactText.cs: strip text whose origin falls inside a CoverAnnotation from the page content stream at save time, hooked into PdfBurn.DrawAnnotationsIntoDoc. Fixes edited values staying recoverable by text extraction. - Themes/MMD.xaml replaces all thirteen themes; picker and accent strip removed; no dark mode. - Rename KillerPDF -> MMD PDF across code, resources, packaging and locale strings; new icon. - Remove the upstream author credit and the in-app install button.
1937 lines
89 KiB
C#
1937 lines
89 KiB
C#
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
|
|
// ============================================================
|
|
|
|
protected override void OnStartup(StartupEventArgs e)
|
|
{
|
|
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 */ }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dark-themed crash report dialog. Returns true if the user chose Continue.
|
|
/// Must be called on the UI thread.
|
|
/// </summary>
|
|
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)
|
|
// ============================================================
|
|
|
|
/// <summary>
|
|
/// True when running from outside the installed location (i.e. portable mode).
|
|
/// </summary>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>True when MmdPdf is already installed machine-wide.</summary>
|
|
internal static bool MachineInstallExists() =>
|
|
File.Exists(MachineInstallExe) || File.Exists(LegacyMachineInstallExe);
|
|
|
|
/// <summary>True when MmdPdf is already installed for the current user.</summary>
|
|
internal static bool UserInstallExists() =>
|
|
File.Exists(InstallExe) || File.Exists(LegacyUserInstallExe);
|
|
|
|
/// <summary>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.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Re-run this exe elevated with /silent and wait for it to finish.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
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
|
|
// ============================================================
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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
|
|
// ============================================================
|
|
|
|
/// <summary>
|
|
/// User-private temp directory for session working files (encrypted PDFs, etc.).
|
|
/// %LOCALAPPDATA% is user-private and not indexed by Windows Search.
|
|
/// </summary>
|
|
internal static readonly string TempDir = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"MmdPdf", "Temp");
|
|
|
|
private static readonly List<string> _sessionTemps = [];
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Deletes all temp files registered this session (best-effort).</summary>
|
|
internal static void CleanupSessionTemps()
|
|
{
|
|
lock (_sessionTemps)
|
|
{
|
|
foreach (var f in _sessionTemps)
|
|
try { if (File.Exists(f)) File.Delete(f); } catch { }
|
|
_sessionTemps.Clear();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 */ }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string> GetRecentFiles()
|
|
{
|
|
var list = new System.Collections.Generic.List<string>();
|
|
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
|
|
// ============================================================
|
|
|
|
/// <summary>
|
|
/// Shows the Install / Run dialog.
|
|
/// Returns (canceled, install, wantDesktopShortcut).
|
|
/// </summary>
|
|
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 ───────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Calls WinVerifyTrust to validate an Authenticode signature.
|
|
/// Returns (Valid, SubjectCN, Thumbprint).
|
|
/// Valid=false for unsigned, expired (past grace), or tampered files.
|
|
/// </summary>
|
|
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<WINTRUST_FILE_INFO>());
|
|
var dataPtr = Marshal.AllocHGlobal(Marshal.SizeOf<WINTRUST_DATA>());
|
|
try
|
|
{
|
|
Marshal.StructureToPtr(new WINTRUST_FILE_INFO
|
|
{
|
|
cbStruct = (uint)Marshal.SizeOf<WINTRUST_FILE_INFO>(),
|
|
pcwszFilePath = pathPtr
|
|
}, fileInfoPtr, false);
|
|
|
|
Marshal.StructureToPtr(new WINTRUST_DATA
|
|
{
|
|
cbStruct = (uint)Marshal.SizeOf<WINTRUST_DATA>(),
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convenience wrapper: verify the currently running EXE.
|
|
/// </summary>
|
|
internal static (bool Valid, string Subject, string Thumbprint) GetExeSignerInfo()
|
|
{
|
|
try
|
|
{
|
|
return VerifyAuthenticode(Process.GetCurrentProcess().MainModule!.FileName);
|
|
}
|
|
catch
|
|
{
|
|
return (false, "(not signed)", string.Empty);
|
|
}
|
|
}
|
|
|
|
/// <summary>SHA256 hex of the currently running EXE (for the About dialog).</summary>
|
|
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 ───────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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 { }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|