Skip to content

Commit 09c33cf

Browse files
Merge pull request #376 from erikdarlingdata/dev
Release v1.14.1
2 parents e9946a9 + 0e4fa84 commit 09c33cf

6 files changed

Lines changed: 319 additions & 2 deletions

File tree

‎src/Directory.Build.props‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
Tests and server/ projects are outside src/ and are unaffected.
1616
-->
1717
<PropertyGroup>
18-
<Version>1.14.0</Version>
18+
<Version>1.14.1</Version>
1919
<Authors>Erik Darling</Authors>
2020
<Company>Darling Data LLC</Company>
2121
<Product>Performance Studio</Product>

‎src/PlanViewer.App/App.axaml.cs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
using Avalonia.Controls;
55
using Avalonia.Controls.ApplicationLifetimes;
66
using Avalonia.Markup.Xaml;
7+
using Avalonia.Platform;
8+
using Avalonia.Platform.Storage;
9+
using System.Linq;
10+
using System.Threading.Tasks;
11+
using PlanViewer.App.Services;
712

813
namespace PlanViewer.App;
914

@@ -27,9 +32,43 @@ public override void OnFrameworkInitializationCompleted()
2732
MacOSDockIcon.SetDockIcon(iconPath);
2833
}
2934

35+
// macOS delivers a double-clicked .sqlplan via an activation event — the path
36+
// is NOT passed in argv as it is on Windows/Linux. Subscribe so Finder "Open"
37+
// (and drag-onto-dock) loads the plan through the same path as any other open.
38+
if (this.TryGetFeature<IActivatableLifetime>() is { } activatable)
39+
activatable.Activated += OnAppActivated;
40+
41+
// Register the .sqlplan association (Windows/Linux) off the UI thread so it
42+
// never delays first paint. Best-effort; the OS then routes double-clicks to
43+
// the existing argv/pipe open path. No-op on macOS (handled by Info.plist).
44+
Task.Run(FileAssociationService.RegisterForCurrentExecutable);
45+
3046
base.OnFrameworkInitializationCompleted();
3147
}
3248

49+
/// <summary>
50+
/// Handles macOS file-open activations (<see cref="ActivationKind.File"/>). The
51+
/// opened plan paths arrive here via the activation event rather than argv, so we
52+
/// route them into the existing open path on the main window.
53+
/// </summary>
54+
private void OnAppActivated(object? sender, ActivatedEventArgs e)
55+
{
56+
if (e is not FileActivatedEventArgs fileArgs)
57+
return;
58+
59+
var paths = fileArgs.Files
60+
.Select(f => f.TryGetLocalPath())
61+
.Where(p => !string.IsNullOrEmpty(p))
62+
.Select(p => p!)
63+
.ToList();
64+
65+
if (paths.Count > 0
66+
&& ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: MainWindow mainWindow })
67+
{
68+
mainWindow.OpenFiles(paths);
69+
}
70+
}
71+
3372
private void OnAboutClicked(object? sender, System.EventArgs e)
3473
{
3574
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop

‎src/PlanViewer.App/MainWindow.FileOps.cs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,29 @@ private void OnDrop(object? sender, DragEventArgs e)
111111
}
112112
}
113113

114+
/// <summary>
115+
/// Opens one or more files by path. Used by the macOS activation handler when a
116+
/// plan is double-clicked in Finder (the path arrives via an event, not argv).
117+
/// Marshals to the UI thread and skips paths that no longer exist.
118+
/// </summary>
119+
public void OpenFiles(IEnumerable<string> paths)
120+
{
121+
void OpenAll()
122+
{
123+
foreach (var path in paths)
124+
{
125+
if (!string.IsNullOrEmpty(path) && File.Exists(path))
126+
OpenFileByExtension(path);
127+
}
128+
Activate();
129+
}
130+
131+
if (Dispatcher.UIThread.CheckAccess())
132+
OpenAll();
133+
else
134+
Dispatcher.UIThread.Post(OpenAll);
135+
}
136+
114137
private void OpenFileByExtension(string filePath)
115138
{
116139
if (filePath.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))

‎src/PlanViewer.App/PlanViewer.App.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.4.1" />
2525
<PackageReference Include="AvaloniaEdit.TextMate.Grammars" Version="0.10.12.1" />
2626
<PackageReference Include="Meziantou.Framework.Win32.CredentialManager" Version="2.0.1" />
27+
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
2728
<PackageReference Include="ModelContextProtocol" Version="1.4.0" />
2829
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
2930
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.4" />

‎src/PlanViewer.App/Program.cs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System;
33
using System.IO;
44
using System.IO.Pipes;
5+
using PlanViewer.App.Services;
56
using Velopack;
67

78
namespace PlanViewer.App;
@@ -13,7 +14,15 @@ class Program
1314
[STAThread]
1415
public static void Main(string[] args)
1516
{
16-
VelopackApp.Build().Run();
17+
var velopack = VelopackApp.Build();
18+
if (OperatingSystem.IsWindows())
19+
{
20+
// Clean up the .sqlplan association on uninstall. Velopack's uninstall
21+
// hooks are Windows-only, which lines up — the association cleanup that
22+
// needs them is Windows too (Linux ships as a plain zip, no uninstaller).
23+
velopack = velopack.OnBeforeUninstallFastCallback((_) => FileAssociationService.Unregister());
24+
}
25+
velopack.Run();
1726

1827
// If another instance is running, send the file path to it and exit
1928
if (args.Length > 0 && TrySendToRunningInstance(args[0]))
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
using System;
2+
using System.Diagnostics;
3+
using System.IO;
4+
using System.Runtime.InteropServices;
5+
using System.Runtime.Versioning;
6+
using Microsoft.Win32;
7+
8+
namespace PlanViewer.App.Services;
9+
10+
/// <summary>
11+
/// Registers the <c>.sqlplan</c> file association so a double-clicked plan opens in
12+
/// Performance Studio. Best-effort and idempotent — it must never throw into startup.
13+
///
14+
/// The open itself is handled by the existing argv + named-pipe path in
15+
/// <c>Program.Main</c>/<c>MainWindow</c>: once the OS launches the app with the plan
16+
/// path as an argument, it loads like any other file. This service only makes the OS
17+
/// route the double-click to us.
18+
///
19+
/// - <b>Windows</b>: HKCU\Software\Classes ProgId + open command, re-registered each
20+
/// launch so the path tracks Velopack's versioned install directory.
21+
/// - <b>Linux</b>: a freedesktop <c>.desktop</c> entry + MIME glob, with the desktop
22+
/// databases refreshed only when something was actually written.
23+
/// - <b>macOS</b>: no-op. Launch Services reads <c>CFBundleDocumentTypes</c> from the
24+
/// bundle's Info.plist. (Loading the opened plan additionally needs Avalonia's
25+
/// <c>FileActivatedEventArgs</c> to deliver the path; Avalonia 11.3.17 does not
26+
/// expose it, so macOS double-click currently launches the app without the plan.)
27+
/// </summary>
28+
public static class FileAssociationService
29+
{
30+
private const string Extension = ".sqlplan";
31+
private const string ProgId = "SQLPerformanceStudio.sqlplan";
32+
private const string FriendlyType = "SQL Server Execution Plan";
33+
private const string LinuxMimeType = "application/x-sqlplan";
34+
private const string LinuxDesktopFile = "sqlperformancestudio.desktop";
35+
36+
/// <summary>Registers the association for the currently running executable.</summary>
37+
public static void RegisterForCurrentExecutable()
38+
{
39+
try
40+
{
41+
var exePath = Environment.ProcessPath;
42+
if (string.IsNullOrEmpty(exePath))
43+
return;
44+
45+
if (OperatingSystem.IsWindows())
46+
RegisterWindows(exePath);
47+
else if (OperatingSystem.IsLinux())
48+
RegisterLinux(exePath);
49+
// macOS: handled declaratively by Info.plist; nothing to do at runtime.
50+
}
51+
catch
52+
{
53+
// The association is a convenience; a failure here must never block launch.
54+
}
55+
}
56+
57+
/// <summary>Removes the association. Called from the Velopack uninstall hook.</summary>
58+
public static void Unregister()
59+
{
60+
try
61+
{
62+
if (OperatingSystem.IsWindows())
63+
UnregisterWindows();
64+
else if (OperatingSystem.IsLinux())
65+
UnregisterLinux();
66+
}
67+
catch
68+
{
69+
// Uninstall cleanup is best-effort.
70+
}
71+
}
72+
73+
[SupportedOSPlatform("windows")]
74+
private static void RegisterWindows(string exePath)
75+
{
76+
using var classes = Registry.CurrentUser.CreateSubKey(@"Software\Classes");
77+
if (classes == null)
78+
return;
79+
80+
var desiredCommand = $"\"{exePath}\" \"%1\"";
81+
82+
// Skip the rewrite (and the shell-change broadcast) when already current — this
83+
// runs on every launch, so the common case must be cheap and side-effect free.
84+
using (var existing = classes.OpenSubKey($@"{ProgId}\shell\open\command"))
85+
{
86+
if (existing?.GetValue(null) as string == desiredCommand)
87+
return;
88+
}
89+
90+
using (var progId = classes.CreateSubKey(ProgId))
91+
{
92+
progId.SetValue(null, FriendlyType);
93+
using (var icon = progId.CreateSubKey("DefaultIcon"))
94+
icon.SetValue(null, $"\"{exePath}\",0");
95+
using (var cmd = progId.CreateSubKey(@"shell\open\command"))
96+
cmd.SetValue(null, desiredCommand);
97+
}
98+
99+
using (var ext = classes.CreateSubKey(Extension))
100+
{
101+
// Become the default only when nothing else owns it. We never overwrite an
102+
// existing default (e.g. SSMS) — we just add ourselves to "Open with" so the
103+
// user can choose us. (Windows' UserChoice hash blocks silently forcing it.)
104+
if (ext.GetValue(null) is not string current || string.IsNullOrEmpty(current))
105+
ext.SetValue(null, ProgId);
106+
using var progIds = ext.CreateSubKey("OpenWithProgids");
107+
progIds.SetValue(ProgId, string.Empty);
108+
}
109+
110+
NotifyShellAssociationsChanged();
111+
}
112+
113+
[SupportedOSPlatform("windows")]
114+
private static void UnregisterWindows()
115+
{
116+
using var classes = Registry.CurrentUser.OpenSubKey(@"Software\Classes", writable: true);
117+
if (classes == null)
118+
return;
119+
120+
classes.DeleteSubKeyTree(ProgId, throwOnMissingSubKey: false);
121+
122+
using (var ext = classes.OpenSubKey(Extension, writable: true))
123+
{
124+
if (ext != null)
125+
{
126+
if (ext.GetValue(null) as string == ProgId)
127+
ext.DeleteValue(string.Empty, throwOnMissingValue: false);
128+
using var progIds = ext.OpenSubKey("OpenWithProgids", writable: true);
129+
progIds?.DeleteValue(ProgId, throwOnMissingValue: false);
130+
}
131+
}
132+
133+
NotifyShellAssociationsChanged();
134+
}
135+
136+
[SupportedOSPlatform("windows")]
137+
private static void NotifyShellAssociationsChanged()
138+
{
139+
try
140+
{
141+
// SHCNE_ASSOCCHANGED, SHCNF_IDLIST — tell Explorer the associations changed.
142+
SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero);
143+
}
144+
catch
145+
{
146+
// Cosmetic refresh only; failure just means Explorer updates a bit later.
147+
}
148+
}
149+
150+
[DllImport("shell32.dll", SetLastError = false)]
151+
private static extern void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2);
152+
153+
[SupportedOSPlatform("linux")]
154+
private static void RegisterLinux(string exePath)
155+
{
156+
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
157+
if (string.IsNullOrEmpty(home))
158+
return;
159+
160+
var appsDir = Path.Combine(home, ".local", "share", "applications");
161+
var mimeRoot = Path.Combine(home, ".local", "share", "mime");
162+
var mimeDir = Path.Combine(mimeRoot, "packages");
163+
Directory.CreateDirectory(appsDir);
164+
Directory.CreateDirectory(mimeDir);
165+
166+
var desktop = string.Join("\n",
167+
"[Desktop Entry]",
168+
"Type=Application",
169+
"Name=Performance Studio",
170+
"GenericName=SQL Server Execution Plan Viewer",
171+
$"Exec=\"{exePath}\" %f",
172+
"Terminal=false",
173+
"Categories=Development;Database;",
174+
$"MimeType={LinuxMimeType};",
175+
"");
176+
177+
var mime = string.Join("\n",
178+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
179+
"<mime-info xmlns=\"http://www.freedesktop.org/standards/shared-mime-info\">",
180+
$" <mime-type type=\"{LinuxMimeType}\">",
181+
" <comment>SQL Server Execution Plan</comment>",
182+
" <glob pattern=\"*.sqlplan\"/>",
183+
" </mime-type>",
184+
"</mime-info>",
185+
"");
186+
187+
var changed = WriteIfChanged(Path.Combine(appsDir, LinuxDesktopFile), desktop);
188+
changed |= WriteIfChanged(Path.Combine(mimeDir, "sqlperformancestudio.xml"), mime);
189+
if (!changed)
190+
return;
191+
192+
// Best-effort refresh; if these tools are absent the files are still written and
193+
// most desktop environments pick them up after a relog.
194+
RunQuiet("update-mime-database", mimeRoot);
195+
RunQuiet("update-desktop-database", appsDir);
196+
}
197+
198+
[SupportedOSPlatform("linux")]
199+
private static void UnregisterLinux()
200+
{
201+
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
202+
if (string.IsNullOrEmpty(home))
203+
return;
204+
205+
var appsDir = Path.Combine(home, ".local", "share", "applications");
206+
var mimeRoot = Path.Combine(home, ".local", "share", "mime");
207+
DeleteIfExists(Path.Combine(appsDir, LinuxDesktopFile));
208+
DeleteIfExists(Path.Combine(mimeRoot, "packages", "sqlperformancestudio.xml"));
209+
RunQuiet("update-mime-database", mimeRoot);
210+
RunQuiet("update-desktop-database", appsDir);
211+
}
212+
213+
private static bool WriteIfChanged(string path, string content)
214+
{
215+
if (File.Exists(path) && File.ReadAllText(path) == content)
216+
return false;
217+
File.WriteAllText(path, content);
218+
return true;
219+
}
220+
221+
private static void DeleteIfExists(string path)
222+
{
223+
if (File.Exists(path))
224+
File.Delete(path);
225+
}
226+
227+
private static void RunQuiet(string fileName, string argument)
228+
{
229+
try
230+
{
231+
var psi = new ProcessStartInfo(fileName, $"\"{argument}\"")
232+
{
233+
UseShellExecute = false,
234+
CreateNoWindow = true,
235+
RedirectStandardOutput = true,
236+
RedirectStandardError = true,
237+
};
238+
Process.Start(psi);
239+
}
240+
catch
241+
{
242+
// update-mime-database / update-desktop-database not installed — ignore.
243+
}
244+
}
245+
}

0 commit comments

Comments
 (0)