Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
Tests and server/ projects are outside src/ and are unaffected.
-->
<PropertyGroup>
<Version>1.14.0</Version>
<Version>1.14.1</Version>
<Authors>Erik Darling</Authors>
<Company>Darling Data LLC</Company>
<Product>Performance Studio</Product>
Expand Down
39 changes: 39 additions & 0 deletions src/PlanViewer.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<IActivatableLifetime>() 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();
}

/// <summary>
/// Handles macOS file-open activations (<see cref="ActivationKind.File"/>). 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.
/// </summary>
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
Expand Down
23 changes: 23 additions & 0 deletions src/PlanViewer.App/MainWindow.FileOps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@
{
e.DragEffects = DragDropEffects.None;

if (e.Data.Contains(DataFormats.Files))

Check warning on line 91 in src/PlanViewer.App/MainWindow.FileOps.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'DataFormats.Files' is obsolete: 'Use DataFormat.File instead.'

Check warning on line 91 in src/PlanViewer.App/MainWindow.FileOps.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'DragEventArgs.Data' is obsolete: 'Use DataTransfer instead.'

Check warning on line 91 in src/PlanViewer.App/MainWindow.FileOps.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'DataFormats.Files' is obsolete: 'Use DataFormat.File instead.'

Check warning on line 91 in src/PlanViewer.App/MainWindow.FileOps.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'DragEventArgs.Data' is obsolete: 'Use DataTransfer instead.'
{
var files = e.Data.GetFiles();

Check warning on line 93 in src/PlanViewer.App/MainWindow.FileOps.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'DragEventArgs.Data' is obsolete: 'Use DataTransfer instead.'

Check warning on line 93 in src/PlanViewer.App/MainWindow.FileOps.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'DragEventArgs.Data' is obsolete: 'Use DataTransfer instead.'
if (files != null && files.Any(f => IsSupportedFile(f.TryGetLocalPath())))
e.DragEffects = DragDropEffects.Copy;
}
Expand All @@ -98,9 +98,9 @@

private void OnDrop(object? sender, DragEventArgs e)
{
if (!e.Data.Contains(DataFormats.Files)) return;

Check warning on line 101 in src/PlanViewer.App/MainWindow.FileOps.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'DragEventArgs.Data' is obsolete: 'Use DataTransfer instead.'

Check warning on line 101 in src/PlanViewer.App/MainWindow.FileOps.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'DataFormats.Files' is obsolete: 'Use DataFormat.File instead.'

Check warning on line 101 in src/PlanViewer.App/MainWindow.FileOps.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'DragEventArgs.Data' is obsolete: 'Use DataTransfer instead.'

var files = e.Data.GetFiles();

Check warning on line 103 in src/PlanViewer.App/MainWindow.FileOps.cs

View workflow job for this annotation

GitHub Actions / build-and-test

'DragEventArgs.Data' is obsolete: 'Use DataTransfer instead.'
if (files == null) return;

foreach (var file in files)
Expand All @@ -111,6 +111,29 @@
}
}

/// <summary>
/// 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.
/// </summary>
public void OpenFiles(IEnumerable<string> 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))
Expand Down
1 change: 1 addition & 0 deletions src/PlanViewer.App/PlanViewer.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.4.1" />
<PackageReference Include="AvaloniaEdit.TextMate.Grammars" Version="0.10.12.1" />
<PackageReference Include="Meziantou.Framework.Win32.CredentialManager" Version="2.0.1" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="ModelContextProtocol" Version="1.4.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.4" />
Expand Down
11 changes: 10 additions & 1 deletion src/PlanViewer.App/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System;
using System.IO;
using System.IO.Pipes;
using PlanViewer.App.Services;
using Velopack;

namespace PlanViewer.App;
Expand All @@ -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]))
Expand Down
245 changes: 245 additions & 0 deletions src/PlanViewer.App/Services/FileAssociationService.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Registers the <c>.sqlplan</c> 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
/// <c>Program.Main</c>/<c>MainWindow</c>: 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.
///
/// - <b>Windows</b>: HKCU\Software\Classes ProgId + open command, re-registered each
/// launch so the path tracks Velopack's versioned install directory.
/// - <b>Linux</b>: a freedesktop <c>.desktop</c> entry + MIME glob, with the desktop
/// databases refreshed only when something was actually written.
/// - <b>macOS</b>: no-op. Launch Services reads <c>CFBundleDocumentTypes</c> from the
/// bundle's Info.plist. (Loading the opened plan additionally needs Avalonia's
/// <c>FileActivatedEventArgs</c> to deliver the path; Avalonia 11.3.17 does not
/// expose it, so macOS double-click currently launches the app without the plan.)
/// </summary>
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";

/// <summary>Registers the association for the currently running executable.</summary>
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.
}
}

/// <summary>Removes the association. Called from the Velopack uninstall hook.</summary>
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",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"<mime-info xmlns=\"http://www.freedesktop.org/standards/shared-mime-info\">",
$" <mime-type type=\"{LinuxMimeType}\">",
" <comment>SQL Server Execution Plan</comment>",
" <glob pattern=\"*.sqlplan\"/>",
" </mime-type>",
"</mime-info>",
"");

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.
}
}
}