diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b3bafa0..da1ddbf 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -15,7 +15,7 @@ Tests and server/ projects are outside src/ and are unaffected. --> - 1.14.0 + 1.14.1 Erik Darling Darling Data LLC Performance Studio diff --git a/src/PlanViewer.App/App.axaml.cs b/src/PlanViewer.App/App.axaml.cs index 3fb559b..29fd241 100644 --- a/src/PlanViewer.App/App.axaml.cs +++ b/src/PlanViewer.App/App.axaml.cs @@ -4,6 +4,11 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Avalonia.Platform; +using Avalonia.Platform.Storage; +using System.Linq; +using System.Threading.Tasks; +using PlanViewer.App.Services; namespace PlanViewer.App; @@ -27,9 +32,43 @@ public override void OnFrameworkInitializationCompleted() MacOSDockIcon.SetDockIcon(iconPath); } + // macOS delivers a double-clicked .sqlplan via an activation event — the path + // is NOT passed in argv as it is on Windows/Linux. Subscribe so Finder "Open" + // (and drag-onto-dock) loads the plan through the same path as any other open. + if (this.TryGetFeature() is { } activatable) + activatable.Activated += OnAppActivated; + + // Register the .sqlplan association (Windows/Linux) off the UI thread so it + // never delays first paint. Best-effort; the OS then routes double-clicks to + // the existing argv/pipe open path. No-op on macOS (handled by Info.plist). + Task.Run(FileAssociationService.RegisterForCurrentExecutable); + base.OnFrameworkInitializationCompleted(); } + /// + /// Handles macOS file-open activations (). The + /// opened plan paths arrive here via the activation event rather than argv, so we + /// route them into the existing open path on the main window. + /// + private void OnAppActivated(object? sender, ActivatedEventArgs e) + { + if (e is not FileActivatedEventArgs fileArgs) + return; + + var paths = fileArgs.Files + .Select(f => f.TryGetLocalPath()) + .Where(p => !string.IsNullOrEmpty(p)) + .Select(p => p!) + .ToList(); + + if (paths.Count > 0 + && ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: MainWindow mainWindow }) + { + mainWindow.OpenFiles(paths); + } + } + private void OnAboutClicked(object? sender, System.EventArgs e) { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop diff --git a/src/PlanViewer.App/MainWindow.FileOps.cs b/src/PlanViewer.App/MainWindow.FileOps.cs index 41a8d7d..2579ea2 100644 --- a/src/PlanViewer.App/MainWindow.FileOps.cs +++ b/src/PlanViewer.App/MainWindow.FileOps.cs @@ -111,6 +111,29 @@ private void OnDrop(object? sender, DragEventArgs e) } } + /// + /// Opens one or more files by path. Used by the macOS activation handler when a + /// plan is double-clicked in Finder (the path arrives via an event, not argv). + /// Marshals to the UI thread and skips paths that no longer exist. + /// + public void OpenFiles(IEnumerable paths) + { + void OpenAll() + { + foreach (var path in paths) + { + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + OpenFileByExtension(path); + } + Activate(); + } + + if (Dispatcher.UIThread.CheckAccess()) + OpenAll(); + else + Dispatcher.UIThread.Post(OpenAll); + } + private void OpenFileByExtension(string filePath) { if (filePath.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj index 0536d09..373c606 100644 --- a/src/PlanViewer.App/PlanViewer.App.csproj +++ b/src/PlanViewer.App/PlanViewer.App.csproj @@ -24,6 +24,7 @@ + diff --git a/src/PlanViewer.App/Program.cs b/src/PlanViewer.App/Program.cs index 8c57028..8d2f1c7 100644 --- a/src/PlanViewer.App/Program.cs +++ b/src/PlanViewer.App/Program.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.IO.Pipes; +using PlanViewer.App.Services; using Velopack; namespace PlanViewer.App; @@ -13,7 +14,15 @@ class Program [STAThread] public static void Main(string[] args) { - VelopackApp.Build().Run(); + var velopack = VelopackApp.Build(); + if (OperatingSystem.IsWindows()) + { + // Clean up the .sqlplan association on uninstall. Velopack's uninstall + // hooks are Windows-only, which lines up — the association cleanup that + // needs them is Windows too (Linux ships as a plain zip, no uninstaller). + velopack = velopack.OnBeforeUninstallFastCallback((_) => FileAssociationService.Unregister()); + } + velopack.Run(); // If another instance is running, send the file path to it and exit if (args.Length > 0 && TrySendToRunningInstance(args[0])) diff --git a/src/PlanViewer.App/Services/FileAssociationService.cs b/src/PlanViewer.App/Services/FileAssociationService.cs new file mode 100644 index 0000000..4c27ed0 --- /dev/null +++ b/src/PlanViewer.App/Services/FileAssociationService.cs @@ -0,0 +1,245 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Win32; + +namespace PlanViewer.App.Services; + +/// +/// Registers the .sqlplan file association so a double-clicked plan opens in +/// Performance Studio. Best-effort and idempotent — it must never throw into startup. +/// +/// The open itself is handled by the existing argv + named-pipe path in +/// Program.Main/MainWindow: once the OS launches the app with the plan +/// path as an argument, it loads like any other file. This service only makes the OS +/// route the double-click to us. +/// +/// - Windows: HKCU\Software\Classes ProgId + open command, re-registered each +/// launch so the path tracks Velopack's versioned install directory. +/// - Linux: a freedesktop .desktop entry + MIME glob, with the desktop +/// databases refreshed only when something was actually written. +/// - macOS: no-op. Launch Services reads CFBundleDocumentTypes from the +/// bundle's Info.plist. (Loading the opened plan additionally needs Avalonia's +/// FileActivatedEventArgs to deliver the path; Avalonia 11.3.17 does not +/// expose it, so macOS double-click currently launches the app without the plan.) +/// +public static class FileAssociationService +{ + private const string Extension = ".sqlplan"; + private const string ProgId = "SQLPerformanceStudio.sqlplan"; + private const string FriendlyType = "SQL Server Execution Plan"; + private const string LinuxMimeType = "application/x-sqlplan"; + private const string LinuxDesktopFile = "sqlperformancestudio.desktop"; + + /// Registers the association for the currently running executable. + public static void RegisterForCurrentExecutable() + { + try + { + var exePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(exePath)) + return; + + if (OperatingSystem.IsWindows()) + RegisterWindows(exePath); + else if (OperatingSystem.IsLinux()) + RegisterLinux(exePath); + // macOS: handled declaratively by Info.plist; nothing to do at runtime. + } + catch + { + // The association is a convenience; a failure here must never block launch. + } + } + + /// Removes the association. Called from the Velopack uninstall hook. + public static void Unregister() + { + try + { + if (OperatingSystem.IsWindows()) + UnregisterWindows(); + else if (OperatingSystem.IsLinux()) + UnregisterLinux(); + } + catch + { + // Uninstall cleanup is best-effort. + } + } + + [SupportedOSPlatform("windows")] + private static void RegisterWindows(string exePath) + { + using var classes = Registry.CurrentUser.CreateSubKey(@"Software\Classes"); + if (classes == null) + return; + + var desiredCommand = $"\"{exePath}\" \"%1\""; + + // Skip the rewrite (and the shell-change broadcast) when already current — this + // runs on every launch, so the common case must be cheap and side-effect free. + using (var existing = classes.OpenSubKey($@"{ProgId}\shell\open\command")) + { + if (existing?.GetValue(null) as string == desiredCommand) + return; + } + + using (var progId = classes.CreateSubKey(ProgId)) + { + progId.SetValue(null, FriendlyType); + using (var icon = progId.CreateSubKey("DefaultIcon")) + icon.SetValue(null, $"\"{exePath}\",0"); + using (var cmd = progId.CreateSubKey(@"shell\open\command")) + cmd.SetValue(null, desiredCommand); + } + + using (var ext = classes.CreateSubKey(Extension)) + { + // Become the default only when nothing else owns it. We never overwrite an + // existing default (e.g. SSMS) — we just add ourselves to "Open with" so the + // user can choose us. (Windows' UserChoice hash blocks silently forcing it.) + if (ext.GetValue(null) is not string current || string.IsNullOrEmpty(current)) + ext.SetValue(null, ProgId); + using var progIds = ext.CreateSubKey("OpenWithProgids"); + progIds.SetValue(ProgId, string.Empty); + } + + NotifyShellAssociationsChanged(); + } + + [SupportedOSPlatform("windows")] + private static void UnregisterWindows() + { + using var classes = Registry.CurrentUser.OpenSubKey(@"Software\Classes", writable: true); + if (classes == null) + return; + + classes.DeleteSubKeyTree(ProgId, throwOnMissingSubKey: false); + + using (var ext = classes.OpenSubKey(Extension, writable: true)) + { + if (ext != null) + { + if (ext.GetValue(null) as string == ProgId) + ext.DeleteValue(string.Empty, throwOnMissingValue: false); + using var progIds = ext.OpenSubKey("OpenWithProgids", writable: true); + progIds?.DeleteValue(ProgId, throwOnMissingValue: false); + } + } + + NotifyShellAssociationsChanged(); + } + + [SupportedOSPlatform("windows")] + private static void NotifyShellAssociationsChanged() + { + try + { + // SHCNE_ASSOCCHANGED, SHCNF_IDLIST — tell Explorer the associations changed. + SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); + } + catch + { + // Cosmetic refresh only; failure just means Explorer updates a bit later. + } + } + + [DllImport("shell32.dll", SetLastError = false)] + private static extern void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2); + + [SupportedOSPlatform("linux")] + private static void RegisterLinux(string exePath) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) + return; + + var appsDir = Path.Combine(home, ".local", "share", "applications"); + var mimeRoot = Path.Combine(home, ".local", "share", "mime"); + var mimeDir = Path.Combine(mimeRoot, "packages"); + Directory.CreateDirectory(appsDir); + Directory.CreateDirectory(mimeDir); + + var desktop = string.Join("\n", + "[Desktop Entry]", + "Type=Application", + "Name=Performance Studio", + "GenericName=SQL Server Execution Plan Viewer", + $"Exec=\"{exePath}\" %f", + "Terminal=false", + "Categories=Development;Database;", + $"MimeType={LinuxMimeType};", + ""); + + var mime = string.Join("\n", + "", + "", + $" ", + " SQL Server Execution Plan", + " ", + " ", + "", + ""); + + var changed = WriteIfChanged(Path.Combine(appsDir, LinuxDesktopFile), desktop); + changed |= WriteIfChanged(Path.Combine(mimeDir, "sqlperformancestudio.xml"), mime); + if (!changed) + return; + + // Best-effort refresh; if these tools are absent the files are still written and + // most desktop environments pick them up after a relog. + RunQuiet("update-mime-database", mimeRoot); + RunQuiet("update-desktop-database", appsDir); + } + + [SupportedOSPlatform("linux")] + private static void UnregisterLinux() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) + return; + + var appsDir = Path.Combine(home, ".local", "share", "applications"); + var mimeRoot = Path.Combine(home, ".local", "share", "mime"); + DeleteIfExists(Path.Combine(appsDir, LinuxDesktopFile)); + DeleteIfExists(Path.Combine(mimeRoot, "packages", "sqlperformancestudio.xml")); + RunQuiet("update-mime-database", mimeRoot); + RunQuiet("update-desktop-database", appsDir); + } + + private static bool WriteIfChanged(string path, string content) + { + if (File.Exists(path) && File.ReadAllText(path) == content) + return false; + File.WriteAllText(path, content); + return true; + } + + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + File.Delete(path); + } + + private static void RunQuiet(string fileName, string argument) + { + try + { + var psi = new ProcessStartInfo(fileName, $"\"{argument}\"") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + Process.Start(psi); + } + catch + { + // update-mime-database / update-desktop-database not installed — ignore. + } + } +}