From 45a4aeb0cd4be8e0e507c0f06c743f83ea4124bb Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:31:20 -0400 Subject: [PATCH 1/3] Register .sqlplan file association for double-click-to-open (Windows + Linux) The .sqlplan document-type registration in 1.14.0 only put an icon on the files; nothing routed a double-click to the app. Add a best-effort FileAssociationService so the OS launches Performance Studio with the plan path, which the existing argv + named-pipe open path already handles. Windows: HKCU ProgId + open command + icon, re-registered each launch so the path tracks Velopack's versioned dir; adds itself to OpenWithProgids and only claims the default when .sqlplan is unowned (never overwrites e.g. SSMS); removed on Velopack uninstall. Linux: freedesktop .desktop + MIME glob under ~/.local/share, databases refreshed only when written. macOS: no-op (Launch Services reads Info.plist); loading the opened plan additionally needs Avalonia's FileActivatedEventArgs, absent in 11.3.17. Verified the Windows registry structure end-to-end on a real machine: ProgId + open command created, existing default preserved, cleanup restores state. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/PlanViewer.App/App.axaml.cs | 7 + src/PlanViewer.App/PlanViewer.App.csproj | 1 + src/PlanViewer.App/Program.cs | 11 +- .../Services/FileAssociationService.cs | 245 ++++++++++++++++++ 4 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 src/PlanViewer.App/Services/FileAssociationService.cs diff --git a/src/PlanViewer.App/App.axaml.cs b/src/PlanViewer.App/App.axaml.cs index 3fb559b..f9dc9ee 100644 --- a/src/PlanViewer.App/App.axaml.cs +++ b/src/PlanViewer.App/App.axaml.cs @@ -4,6 +4,8 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using System.Threading.Tasks; +using PlanViewer.App.Services; namespace PlanViewer.App; @@ -27,6 +29,11 @@ public override void OnFrameworkInitializationCompleted() MacOSDockIcon.SetDockIcon(iconPath); } + // 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(); } 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. + } + } +} From e201a50cf7b9a5fabeb4d35866ed4143d967e34f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:43:17 -0400 Subject: [PATCH 2/3] Wire macOS file-open activation to load the plan (FileActivatedEventArgs) macOS delivers a double-clicked .sqlplan via IActivatableLifetime.Activated (ActivationKind.File), not argv. Subscribe in App and route the opened paths into a new public MainWindow.OpenFiles, which marshals to the UI thread and reuses the existing OpenFileByExtension path. FileActivatedEventArgs has shipped in Avalonia since 11.1 and IS present in 11.3.17 (it just lacks XML doc comments, so it's absent from the doc file but present in the assembly). Compiles against 11.3.17; the event actually firing on macOS at runtime needs a Mac smoke-test. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/PlanViewer.App/App.axaml.cs | 32 ++++++++++++++++++++++++ src/PlanViewer.App/MainWindow.FileOps.cs | 23 +++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/PlanViewer.App/App.axaml.cs b/src/PlanViewer.App/App.axaml.cs index f9dc9ee..29fd241 100644 --- a/src/PlanViewer.App/App.axaml.cs +++ b/src/PlanViewer.App/App.axaml.cs @@ -4,6 +4,9 @@ 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; @@ -29,6 +32,12 @@ 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). @@ -37,6 +46,29 @@ public override void OnFrameworkInitializationCompleted() 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)) From c8057725c7e12ef6bea956cba4c26bb8efc8f27f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:44:59 -0400 Subject: [PATCH 3/3] Bump version to 1.14.1 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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