diff --git a/QuickLook.Plugin/QuickLook.Plugin.HtmlViewer/WebpagePanel.cs b/QuickLook.Plugin/QuickLook.Plugin.HtmlViewer/WebpagePanel.cs index f0a7176..b5a2987 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.HtmlViewer/WebpagePanel.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.HtmlViewer/WebpagePanel.cs @@ -20,6 +20,8 @@ using System.Diagnostics; using System.Drawing; using System.IO; using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; using System.Windows; using System.Windows.Controls; using Microsoft.Web.WebView2.Core; @@ -30,8 +32,8 @@ namespace QuickLook.Plugin.HtmlViewer { public class WebpagePanel : UserControl { - public Uri _currentUri; - public WebView2 _webView; + private Uri _currentUri; + private WebView2 _webView; public WebpagePanel() { @@ -83,9 +85,131 @@ namespace QuickLook.Plugin.HtmlViewer return; var newUri = new Uri(e.Uri); - if (newUri != _currentUri) e.Cancel = true; + if (newUri == _currentUri) return; + e.Cancel = true; + + // Open in default browser + try + { + if (!Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri)) + { + Debug.WriteLine($"Invalid URI format: {e.Uri}"); + return; + } + + // Safe schemes can open directly + if (uri.Scheme == Uri.UriSchemeHttp || + uri.Scheme == Uri.UriSchemeHttps || + uri.Scheme == Uri.UriSchemeMailto) + { + try + { + Process.Start(uri.AbsoluteUri); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to open URL: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + return; + } + + // Ask user for unsafe schemes. Use dispatcher to avoid blocking thread. + string associatedApp = GetAssociatedAppForScheme(uri.Scheme); + Application.Current.Dispatcher.BeginInvoke(new Action(() => + { + var result = MessageBox.Show( + !string.IsNullOrEmpty(associatedApp) ? + $"The following link will open in {associatedApp}:\n{e.Uri}" : $"The following link will open:\n{e.Uri}", + !string.IsNullOrEmpty(associatedApp) ? + $"Open {associatedApp}?" : "Open custom URI?", + MessageBoxButton.YesNo, + MessageBoxImage.Information); + + if (result == MessageBoxResult.Yes) + { + try + { + Process.Start(e.Uri); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to open URL: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + })); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to open URL: {ex.Message}"); + } } + #region Get Associated App For Scheme + [DllImport("Shlwapi.dll", CharSet = CharSet.Unicode)] + private static extern uint AssocQueryString( + AssocF flags, + AssocStr str, + string pszAssoc, + string pszExtra, + [Out] StringBuilder pszOut, + ref uint pcchOut); + + [Flags] + private enum AssocF + { + None = 0, + VerifyExists = 0x1 + } + + private enum AssocStr + { + Command = 1, + Executable = 2, + FriendlyAppName = 4 + } + + private string GetAssociatedAppForScheme(string scheme) + { + try + { + // Try to get friendly app name first + uint pcchOut = 0; + AssocQueryString(AssocF.None, AssocStr.FriendlyAppName, scheme, null, null, ref pcchOut); + + if (pcchOut > 0) + { + StringBuilder pszOut = new StringBuilder((int)pcchOut); + AssocQueryString(AssocF.None, AssocStr.FriendlyAppName, scheme, null, pszOut, ref pcchOut); + + var appName = pszOut.ToString().Trim(); + if (!string.IsNullOrEmpty(appName)) + return appName; + } + + // Fall back to executable name if friendly name is not available + pcchOut = 0; + AssocQueryString(AssocF.None, AssocStr.Executable, scheme, null, null, ref pcchOut); + + if (pcchOut > 0) + { + StringBuilder pszOut = new StringBuilder((int)pcchOut); + AssocQueryString(AssocF.None, AssocStr.Executable, scheme, null, pszOut, ref pcchOut); + + var exeName = pszOut.ToString().Trim(); + if (!string.IsNullOrEmpty(exeName)) + return Path.GetFileName(exeName); + } + + return null; + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to get associated app: {ex.Message}"); + return null; + } + } + #endregion + private void WebView_NavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs e) { _webView.DefaultBackgroundColor = Color.White; // Reset to white after page load to match expected default behavior diff --git a/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/GenerateEmbeddedResourcesHash.ps1 b/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/GenerateEmbeddedResourcesHash.ps1 new file mode 100644 index 0000000..fe9bd91 --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/GenerateEmbeddedResourcesHash.ps1 @@ -0,0 +1,54 @@ +param( + [Parameter(Mandatory=$true)] + [string]$ResourcesDir, + + [Parameter(Mandatory=$true)] + [string]$OutputFile +) + +# Get all files in the Resources directory +$files = Get-ChildItem -Path $ResourcesDir -Recurse -File | Sort-Object -Property FullName + +# Create SHA256 hasher +$sha256 = [System.Security.Cryptography.SHA256]::Create() + +# Create memory stream to combine all file contents +$memoryStream = New-Object System.IO.MemoryStream + +# Add each file's path and contents to the hash +foreach ($file in $files) { + # Get relative path and convert to lowercase for consistent hashing + $relativePath = $file.FullName.Substring($ResourcesDir.Length + 1).ToLowerInvariant() + $pathBytes = [System.Text.Encoding]::UTF8.GetBytes("QuickLook.Plugin.MarkdownViewer.Resources.$relativePath".Replace("\", "/")) + $memoryStream.Write($pathBytes, 0, $pathBytes.Length) + + # Add file contents + $fileBytes = [System.IO.File]::ReadAllBytes($file.FullName) + $memoryStream.Write($fileBytes, 0, $fileBytes.Length) +} + +# Calculate final hash +$hashBytes = $sha256.ComputeHash($memoryStream.ToArray()) +$hash = [BitConverter]::ToString($hashBytes).Replace("-", "").ToLowerInvariant() + +# Generate C# code +$code = @" +// +// This file was generated during build to indicate changes to embedded resources. +// + +namespace QuickLook.Plugin.MarkdownViewer +{ + internal static class EmbeddedResourcesHash + { + internal const string Hash = "$hash"; + } +} +"@ + +# Write to output file +[System.IO.File]::WriteAllText($OutputFile, $code) + +# Cleanup +$memoryStream.Dispose() +$sha256.Dispose() diff --git a/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/Plugin.cs b/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/Plugin.cs index b5d68c1..5c45eed 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/Plugin.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/Plugin.cs @@ -20,12 +20,9 @@ using System.Diagnostics; using System.IO; using System.Linq; using System.Net; -using System.Reflection; -using System.Runtime.InteropServices; using System.Text; using System.Windows; using System.Windows.Threading; -using Microsoft.Web.WebView2.Core; using QuickLook.Common.Helpers; using QuickLook.Common.Plugin; using QuickLook.Plugin.HtmlViewer; @@ -36,84 +33,21 @@ namespace QuickLook.Plugin.MarkdownViewer public class Plugin : IViewer { private WebpagePanel? _panel; - private static string _resourcePath; - private static readonly string ResourcePrefix = "QuickLook.Plugin.MarkdownViewer.Resources."; private string? _currentHtmlPath; - private static bool OverrideFilesInDevelopment => true && Debugger.IsAttached; // Debug setting - - static Plugin() - { - // Set up resource path in AppData - _resourcePath = Path.Combine(SettingHelper.LocalDataPath, "QuickLook.Plugin.MarkdownViewer"); - } + private static readonly string _resourcePath = Path.Combine(SettingHelper.LocalDataPath, "QuickLook.Plugin.MarkdownViewer"); + private static readonly string _resourcePrefix = "QuickLook.Plugin.MarkdownViewer.Resources."; + private static readonly ResourceManager _resourceManager = new ResourceManager(_resourcePath, _resourcePrefix); public int Priority => 0; public void Init() { - // Create directory if it doesn't exist - if (!Directory.Exists(_resourcePath) || OverrideFilesInDevelopment) - { - Directory.CreateDirectory(_resourcePath); - ExtractResources(); - } + // Initialize resources and handle versioning + _resourceManager.InitializeResources(); // Clean up any temporary HTML files if QuickLook was forcibly terminated - try - { - var tempFiles = Directory.GetFiles(_resourcePath, "temp_*.html"); - foreach (var file in tempFiles) - { - try - { - File.Delete(file); - } - catch (IOException) { } // Ignore deletion errors - } - } - catch (Exception ex) - { - Debug.WriteLine($"Failed to clean up temporary HTML files: {ex.Message}"); - } - } - - private void ExtractResources() - { - var assembly = Assembly.GetExecutingAssembly(); - var resourceNames = assembly.GetManifestResourceNames(); - - foreach (var resourceName in resourceNames) - { - if (!resourceName.StartsWith(ResourcePrefix)) continue; - - var relativePath = resourceName.Substring(ResourcePrefix.Length); - if (relativePath.Equals("resources", StringComparison.OrdinalIgnoreCase)) continue; // Skip 'resources' binary file - - var targetPath = Path.Combine(_resourcePath, relativePath.Replace('/', Path.DirectorySeparatorChar)); - - // Create directory if it doesn't exist - var directory = Path.GetDirectoryName(targetPath); - if (directory != null) - Directory.CreateDirectory(directory); - - // Extract the resource (skip if it already exists, unless in debug mode) - if (File.Exists(targetPath) && !OverrideFilesInDevelopment) - continue; - - using (var resourceStream = assembly.GetManifestResourceStream(resourceName)) - using (var fileStream = File.Create(targetPath)) - { - resourceStream?.CopyTo(fileStream); - } - } - - // Verify that md2html.html was extracted - var htmlPath = Path.Combine(_resourcePath, "md2html.html"); - if (!File.Exists(htmlPath)) - { - throw new FileNotFoundException($"Required template file md2html.html not found in resources. Available resources: {string.Join(", ", resourceNames)}"); - } + CleanupTempFiles(); } public bool CanHandle(string path) @@ -135,123 +69,6 @@ namespace QuickLook.Plugin.MarkdownViewer var htmlPath = GenerateMarkdownHtml(path); _panel.NavigateToFile(htmlPath); _panel.Dispatcher.Invoke(() => { context.IsBusy = false; }, DispatcherPriority.Loaded); - - _panel._webView.NavigationStarting += NavigationStarting_CancelNavigation; - } - - private void NavigationStarting_CancelNavigation(object sender, CoreWebView2NavigationStartingEventArgs e) - { - if (e.Uri.StartsWith("data:")) // when using NavigateToString - return; - - var newUri = new Uri(e.Uri); - if (newUri == _panel?._currentUri) return; - e.Cancel = true; - - // Open in default browser - try - { - if (!Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri)) - { - Debug.WriteLine($"Invalid URI format: {e.Uri}"); - return; - } - - // Safe schemes can open directly - if (uri.Scheme == Uri.UriSchemeHttp || - uri.Scheme == Uri.UriSchemeHttps || - uri.Scheme == Uri.UriSchemeMailto) - { - Process.Start(uri.AbsoluteUri); - return; - } - - // Ask user for unsafe schemes. Use dispatcher to avoid blocking thread. - var associatedApp = GetAssociatedAppForScheme(uri.Scheme); - Application.Current.Dispatcher.BeginInvoke(new Action(() => - { - var result = MessageBox.Show( - !string.IsNullOrEmpty(associatedApp) ? - $"The following link will open in {associatedApp}:\n{e.Uri}" : $"The following link will open:\n{e.Uri}", - !string.IsNullOrEmpty(associatedApp) ? - $"Open {associatedApp}?" : "Open custom URI?", - MessageBoxButton.YesNo, - MessageBoxImage.Information); - - if (result == MessageBoxResult.Yes) - { - Process.Start(e.Uri); - } - })); - } - catch (Exception ex) - { - Debug.WriteLine($"Failed to open URL in browser: {ex.Message}"); - } - } - - [DllImport("Shlwapi.dll", CharSet = CharSet.Unicode)] - private static extern uint AssocQueryString( - AssocF flags, - AssocStr str, - string pszAssoc, - string? pszExtra, - [Out] StringBuilder? pszOut, - ref uint pcchOut); - - [Flags] - private enum AssocF - { - None = 0, - VerifyExists = 0x1 - } - - private enum AssocStr - { - Command = 1, - Executable = 2, - FriendlyAppName = 4 - } - - private string? GetAssociatedAppForScheme(string scheme) - { - try - { - // Try to get friendly app name first - uint pcchOut = 0; - AssocQueryString(AssocF.None, AssocStr.FriendlyAppName, scheme, null, null, ref pcchOut); - - if (pcchOut > 0) - { - StringBuilder pszOut = new StringBuilder((int)pcchOut); - AssocQueryString(AssocF.None, AssocStr.FriendlyAppName, scheme, null, pszOut, ref pcchOut); - - var appName = pszOut.ToString().Trim(); - if (!string.IsNullOrEmpty(appName)) - return appName; - } - - // Fall back to executable name if friendly name is not available - pcchOut = 0; - AssocQueryString(AssocF.None, AssocStr.Executable, scheme, null, null, ref pcchOut); - - if (pcchOut > 0) - { - StringBuilder pszOut = new StringBuilder((int)pcchOut); - AssocQueryString(AssocF.None, AssocStr.Executable, scheme, null, pszOut, ref pcchOut); - - var exeName = pszOut.ToString().Trim(); - if (!string.IsNullOrEmpty(exeName)) - return Path.GetFileName(exeName); - } - - return null; - } - catch (Exception ex) - { - Debug.WriteLine($"Failed to get associated app: {ex.Message}"); - return null; - } } private string GenerateMarkdownHtml(string path) @@ -286,6 +103,7 @@ namespace QuickLook.Plugin.MarkdownViewer return outputPath; } + #region Cleanup private void CleanupTempHtmlFile() { if (!string.IsNullOrEmpty(_currentHtmlPath) && File.Exists(_currentHtmlPath)) @@ -298,6 +116,26 @@ namespace QuickLook.Plugin.MarkdownViewer } } + private void CleanupTempFiles() + { + try + { + var tempFiles = Directory.GetFiles(_resourcePath, "temp_*.html"); + foreach (var file in tempFiles) + { + try + { + File.Delete(file); + } + catch (IOException) { } // Ignore deletion errors + } + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to clean up temporary HTML files: {ex.Message}"); + } + } + public void Cleanup() { GC.SuppressFinalize(this); @@ -307,5 +145,6 @@ namespace QuickLook.Plugin.MarkdownViewer _panel?.Dispose(); _panel = null; } + #endregion } } \ No newline at end of file diff --git a/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/QuickLook.Plugin.MarkdownViewer.csproj b/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/QuickLook.Plugin.MarkdownViewer.csproj index 9300258..9070b6b 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/QuickLook.Plugin.MarkdownViewer.csproj +++ b/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/QuickLook.Plugin.MarkdownViewer.csproj @@ -75,4 +75,22 @@ + + + $(IntermediateOutputPath)Generated + $(HashFileDir)\EmbeddedResourcesHash.cs + + + + + + + + + + + + + + \ No newline at end of file diff --git a/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/ResourceManager.cs b/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/ResourceManager.cs new file mode 100644 index 0000000..a700766 --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.MarkdownViewer/ResourceManager.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Windows; +using QuickLook.Common.Helpers; + +namespace QuickLook.Plugin.MarkdownViewer +{ + internal class ResourceManager + { + private readonly string _resourcePath; + private readonly string _resourcePrefix; + private readonly string _versionFilePath; + private readonly string _noUpdateFilePath; + private readonly string _embeddedHash; + + public ResourceManager(string resourcePath, string resourcePrefix) + { + _resourcePath = resourcePath; + _resourcePrefix = resourcePrefix; + _versionFilePath = Path.Combine(_resourcePath, ".version"); + _noUpdateFilePath = Path.Combine(_resourcePath, ".noupdate"); + _embeddedHash = GetEmbeddedResourcesHash(); + } + + public void InitializeResources() + { + // Extract resources for the first time + if (!Directory.Exists(_resourcePath)) + { + ExtractResources(); + return; + } + + // Check if updates are disabled + if (File.Exists(_noUpdateFilePath)) + return; + + // Check if resources need updating by comparing hashes + var versionInfo = ReadVersionFile(); + + if (versionInfo == null) + { + // No version file exists, create it and extract resources + ExtractResources(); + return; + } + + // If embedded hash matches stored hash, no update needed + if (_embeddedHash == versionInfo.EmbeddedHash) return; + + // Calculate current directory hash + var currentDirectoryHash = CalculateDirectoryHash(_resourcePath); + + // If current directory matches the stored extracted hash, user hasn't modified files + if (currentDirectoryHash == versionInfo.ExtractedHash) + { + // Safe to update + ExtractResources(); + return; + } + + // User has modified files, ask for permission to update + var result = MessageBox.Show( + "The MarkdownViewer resources have been updated. Would you like to update to the newest version?\n\n" + + "Note: Your current resources appear to have been modified. Updating will overwrite your modifications.", + "MarkdownViewer Update Available", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + + if (result == MessageBoxResult.Yes) + { + ExtractResources(); + } + else + { + // Update the embedded hash in the version file to prevent any more prompts for this version + UpdateVersionFileEmbeddedHash(); + } + } + + private void ExtractResources() + { + // Delete and recreate directory to ensure clean state + if (Directory.Exists(_resourcePath)) + { + try + { + Directory.Delete(_resourcePath, true); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to delete directory {_resourcePath}: {ex.Message}"); + // If we can't delete the directory, we'll try to continue with existing one + } + } + Directory.CreateDirectory(_resourcePath); + + var assembly = Assembly.GetExecutingAssembly(); + var resourceNames = assembly.GetManifestResourceNames(); + + foreach (var resourceName in resourceNames) + { + if (!resourceName.StartsWith(_resourcePrefix)) continue; + + var relativePath = resourceName.Substring(_resourcePrefix.Length); + if (relativePath.Equals("resources", StringComparison.OrdinalIgnoreCase)) continue; // Skip 'resources' binary file + + var targetPath = Path.Combine(_resourcePath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + + // Create directory if it doesn't exist + var directory = Path.GetDirectoryName(targetPath); + if (directory != null) + Directory.CreateDirectory(directory); + + // Extract the resource + using (var stream = assembly.GetManifestResourceStream(resourceName)) + using (var fileStream = File.Create(targetPath)) + { + if (stream == null) continue; + stream.CopyTo(fileStream); + } + } + + // Generate version file after extracting all resources + GenerateVersionFile(); + + // Verify that md2html.html was extracted + var htmlPath = Path.Combine(_resourcePath, "md2html.html"); + if (!File.Exists(htmlPath)) + { + throw new FileNotFoundException($"Required template file md2html.html not found in resources. Available resources: {string.Join(", ", resourceNames)}"); + } + } + + private class VersionInfo + { + public string EmbeddedHash { get; set; } + public string ExtractedHash { get; set; } + + public VersionInfo(string embeddedHash, string extractedHash) + { + EmbeddedHash = embeddedHash; + ExtractedHash = extractedHash; + } + } + + private static string CalculateDirectoryHash(string directory) + { + var files = Directory.GetFiles(directory, "*.*", SearchOption.AllDirectories) + .Where(f => !f.EndsWith(".version")) + .OrderBy(f => f) + .ToList(); + + using (var sha256 = SHA256.Create()) + { + var combinedBytes = new List(); + foreach (var file in files) + { + var relativePath = file.Substring(directory.Length + 1); + var pathBytes = Encoding.UTF8.GetBytes(relativePath.ToLowerInvariant()); + combinedBytes.AddRange(pathBytes); + + var contentBytes = File.ReadAllBytes(file); + combinedBytes.AddRange(contentBytes); + } + + var hash = sha256.ComputeHash(combinedBytes.ToArray()); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + } + + private string GetEmbeddedResourcesHash() + { + try + { + return EmbeddedResourcesHash.Hash; + } + catch (Exception) + { + Debug.WriteLine("QuickLook.Plugin.MarkdownViewer: Embedded resources hash file not found."); + return CalculateEmbeddedResourcesHash(); + } + } + + private string CalculateEmbeddedResourcesHash() + { + var assembly = Assembly.GetExecutingAssembly(); + using (var sha256 = SHA256.Create()) + { + var combinedBytes = new List(); + var resourceNames = assembly.GetManifestResourceNames() + .Where(name => name.StartsWith(_resourcePrefix) && + !name.EndsWith(".version")) + .OrderBy(name => name) + .ToList(); + + foreach (var resourceName in resourceNames) + { + var nameBytes = Encoding.UTF8.GetBytes(resourceName.ToLowerInvariant()); + combinedBytes.AddRange(nameBytes); + + using (var stream = assembly.GetManifestResourceStream(resourceName)) + { + if (stream == null) continue; + var buffer = new byte[stream.Length]; + stream.Read(buffer, 0, buffer.Length); + combinedBytes.AddRange(buffer); + } + } + + var hash = sha256.ComputeHash(combinedBytes.ToArray()); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + } + + private void GenerateVersionFile() + { + var extractedHash = CalculateDirectoryHash(_resourcePath); + var newVersionContent = $"{_embeddedHash}{Environment.NewLine}{extractedHash}"; + File.WriteAllText(_versionFilePath, newVersionContent); + } + + private void UpdateVersionFileEmbeddedHash() + { + var versionInfo = ReadVersionFile() ?? throw new InvalidOperationException("Cannot update version file: no existing version file found"); + + var newVersionContent = $"{_embeddedHash}{Environment.NewLine}{versionInfo.ExtractedHash}"; + File.WriteAllText(_versionFilePath, newVersionContent); + } + + private VersionInfo? ReadVersionFile() + { + if (!File.Exists(_versionFilePath)) return null; + + var lines = File.ReadAllLines(_versionFilePath); + if (lines.Length < 2) return null; + + return new VersionInfo(lines[0], lines[1]); + } + } +}