diff --git a/docs/design/architecture.md b/docs/design/architecture.md
index b062c2c2..3d6c5270 100644
--- a/docs/design/architecture.md
+++ b/docs/design/architecture.md
@@ -74,9 +74,12 @@ through a named pipe with one server instance. It disconnects between clients wi
not replace that with disposal and recreation between requests: another process could claim ownership in the gap.
`BravoSingleInstance.ClaimOrRedirect` claims the pipe before the bootstrap: a secondary process forwards its startup
-arguments and exits without creating directories, bootstrap services, a host or a window, and without reading the
-user settings file. Keep the arbitration and the activation payload free of those steps: a secondary that builds
-bootstrap services can transmit stored telemetry and reads files that the primary writes.
+arguments and exits without creating directories, bootstrap services, a host or a window, without reading the user
+settings file, and without running the `AppEnvironment` static constructor. Keep the arbitration and the activation
+payload free of those steps: a secondary that builds bootstrap services can transmit stored telemetry and reads files
+that the primary writes, and the `AppEnvironment` static constructor probes the WebView2 runtime, the deployment mode
+and the registry. The payload code does not reference `AppEnvironment` and takes the Windows session from the
+current process.
Current activation limits:
diff --git a/src/Bravo.csproj b/src/Bravo.csproj
index 7957c75b..9942425c 100644
--- a/src/Bravo.csproj
+++ b/src/Bravo.csproj
@@ -60,7 +60,6 @@
-
diff --git a/src/Infrastructure/AppEnvironment.cs b/src/Infrastructure/AppEnvironment.cs
index e3541c20..b35f183e 100644
--- a/src/Infrastructure/AppEnvironment.cs
+++ b/src/Infrastructure/AppEnvironment.cs
@@ -33,26 +33,6 @@ internal static class AppEnvironment
public static readonly string ApplicationRegistryApplicationTelemetryEnabledValue = "applicationTelemetryEnabled";
public static readonly string ApplicationRegistryApplicationTitleVersionHiddenValue = "applicationTitleVersionHidden";
public static readonly string ApplicationRegistryApplicationInstallFolderValue = "installFolder";
- public static readonly string PBIDesktopProcessName = "PBIDesktop";
- public static readonly string PBIDesktopSSASProcessImageName = "msmdsrv.exe";
- public static readonly string[] PBIDesktopMainWindowTitleSuffixes = new string[]
- {
- // The PBIDesktop main window title is culture-specific.
- // The suffix is not always present, for example, it is not added when the save/share function in OneDrive/SharePoint is active.
-
- // Different dash characters are used as a separator
- // See https://github.com/sql-bi/Bravo/issues/476
- " \u002D Power BI Desktop", // Dash Punctuation - minus hyphen
- " \u2013 Power BI Desktop", // Dash Punctuation - en dash
- " \u2014 Power BI Desktop", // Dash Punctuation - em dash
-
- // The whitespace character may not be present - Swedish/sv
- // See https://github.com/sql-bi/Bravo/issues/510
- "\u2013Power BI Desktop", // Dash Punctuation - en dash
-
- // NBSP char instead of whitespace - Latvian/lv
- "\u00A0\u2014 Power BI Desktop",
- };
public static readonly Color ThemeColorDark = ColorTranslator.FromHtml("#202020");
public static readonly Color ThemeColorLight = ColorTranslator.FromHtml("#F3F3F3");
public static readonly DaxLineBreakStyle FormatDaxLineBreakDefault = DaxLineBreakStyle.InitialLineBreak;
@@ -77,7 +57,6 @@ static AppEnvironment()
_deploymentMode = new(() => GetDeploymentMode());
using var currentProcess = Process.GetCurrentProcess();
- ProcessId = Environment.ProcessId;
SessionId = currentProcess.SessionId;
ApplicationDataPath = Path.Combine(Environment.GetFolderPath(DeploymentMode == AppDeploymentMode.Packaged ? Environment.SpecialFolder.UserProfile : Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.DoNotVerify), ApplicationName);
@@ -97,8 +76,6 @@ static AppEnvironment()
///
public static int SessionId { get; }
- public static int ProcessId { get; }
-
public static AppPublishMode PublishMode
{
get
diff --git a/src/Infrastructure/Configuration/Settings/StartupCommandLine.cs b/src/Infrastructure/Configuration/Settings/StartupCommandLine.cs
new file mode 100644
index 00000000..f1d8ceb7
--- /dev/null
+++ b/src/Infrastructure/Configuration/Settings/StartupCommandLine.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.CommandLine.Parsing;
+using System.Linq;
+
+namespace Sqlbi.Bravo.Infrastructure.Configuration.Settings;
+
+///
+/// The arguments Bravo was started with, as a Power BI Desktop External Tool passes them.
+///
+internal sealed class StartupCommandLine
+{
+ private StartupCommandLine()
+ {
+ }
+
+ public bool IsEmpty { get; private init; }
+
+ ///
+ /// Whether --server or --database was passed, even with a missing value.
+ ///
+ public bool HasConnectionArguments { get; private init; }
+
+ public string? ServerName { get; private init; }
+
+ public string? DatabaseName { get; private init; }
+
+ public int? ParentProcessId { get; private init; }
+
+ public IReadOnlyList Errors { get; private init; } = [];
+
+ /// The arguments without the program name.
+ public static StartupCommandLine Parse(IReadOnlyList args)
+ {
+ ArgumentNullException.ThrowIfNull(args);
+
+ if (args.Count == 0)
+ return new StartupCommandLine { IsEmpty = true };
+
+ var serverOption = new Option("--server");
+ serverOption.AddAlias("-s");
+ serverOption.IsRequired = true;
+ serverOption.Description = "Server name";
+ serverOption.Arity = ArgumentArity.ExactlyOne;
+
+ var databaseOption = new Option("--database");
+ databaseOption.AddAlias("-d");
+ databaseOption.IsRequired = true;
+ databaseOption.Description = "Database name";
+ databaseOption.Arity = ArgumentArity.ExactlyOne;
+
+ var parentProcessIdOption = new Option("--ppid");
+ parentProcessIdOption.IsRequired = false;
+ parentProcessIdOption.Description = "Parent process ID";
+ parentProcessIdOption.Arity = ArgumentArity.ExactlyOne;
+
+ var command = new RootCommand
+ {
+ serverOption,
+ databaseOption,
+ parentProcessIdOption
+ };
+
+ var parseResult = command.Parse(args.ToArray());
+
+ return new StartupCommandLine
+ {
+ HasConnectionArguments = parseResult.HasOption(serverOption) || parseResult.HasOption(databaseOption),
+ ServerName = HasValidValue(parseResult, serverOption) ? parseResult.GetValueForOption(serverOption) : null,
+ DatabaseName = HasValidValue(parseResult, databaseOption) ? parseResult.GetValueForOption(databaseOption) : null,
+ ParentProcessId = HasValidValue(parseResult, parentProcessIdOption) ? parseResult.GetValueForOption(parentProcessIdOption) : null,
+ Errors = parseResult.Errors.Select((e) => e.Message).ToArray(),
+ };
+ }
+
+ // GetValueForOption throws when the value is missing or cannot be converted, so it is read only without an error.
+ private static bool HasValidValue(ParseResult parseResult, Option option)
+ {
+ var optionResult = parseResult.FindResultFor(option);
+ if (optionResult is null)
+ return false;
+
+ return optionResult.ErrorMessage is null;
+ }
+}
diff --git a/src/Infrastructure/Configuration/Settings/StartupSettings.cs b/src/Infrastructure/Configuration/Settings/StartupSettings.cs
index ace85e29..1c88caf4 100644
--- a/src/Infrastructure/Configuration/Settings/StartupSettings.cs
+++ b/src/Infrastructure/Configuration/Settings/StartupSettings.cs
@@ -1,11 +1,10 @@
using System;
-using System.CommandLine;
-using System.CommandLine.Parsing;
using System.Diagnostics;
using System.Linq;
using System.Text.Json.Serialization;
using Sqlbi.Bravo.Infrastructure.Extensions;
using Sqlbi.Bravo.Infrastructure.Helpers;
+using Sqlbi.Bravo.Infrastructure.Services.PowerBI;
namespace Sqlbi.Bravo.Infrastructure.Configuration.Settings;
@@ -27,7 +26,7 @@ internal class StartupSettings
public string[]? CommandLineErrors { get; set; }
[JsonIgnore]
- public bool IsPBIDesktopExternalTool => IsExternalTool && AppEnvironment.PBIDesktopProcessName.Equals(ParentProcessName, StringComparison.OrdinalIgnoreCase);
+ public bool IsPBIDesktopExternalTool => IsExternalTool && PowerBIDesktop.ProcessName.Equals(ParentProcessName, StringComparison.OrdinalIgnoreCase);
[JsonIgnore]
public int? ParentProcessId { get; set; }
@@ -60,70 +59,47 @@ public static void FromCommandLineArguments(this StartupSettings settings)
// If the file name is not available, the first element is equal to String.Empty.
// In.NET 5 and later versions, for single-file publishing, the first element is the name of the host executable.
var args = Environment.GetCommandLineArgs().Skip(1).ToArray();
- if (args.Length == 0)
+ var commandLine = StartupCommandLine.Parse(args);
+
+ using var currentProcess = Process.GetCurrentProcess();
+
+ settings.FromCommandLine(commandLine, currentProcess);
+ }
+
+ // Does not run the AppEnvironment static constructor: a secondary instance builds its activation payload from these
+ // settings before it exits.
+ public static void FromCommandLine(this StartupSettings settings, StartupCommandLine commandLine, Process currentProcess)
+ {
+ if (commandLine.IsEmpty)
{
- // No args provided
settings.IsEmpty = true;
return;
}
- var serverOption = new Option("--server");
- serverOption.AddAlias("-s");
- serverOption.IsRequired = true;
- serverOption.Description = "Server name";
- serverOption.Arity = ArgumentArity.ExactlyOne;
-
- var databaseOption = new Option("--database");
- databaseOption.AddAlias("-d");
- databaseOption.IsRequired = true;
- databaseOption.Description = "Database name";
- databaseOption.Arity = ArgumentArity.ExactlyOne;
-
- var parentProcessIdOption = new Option("--ppid");
- parentProcessIdOption.IsRequired = false;
- parentProcessIdOption.Description = "Parent process ID";
- parentProcessIdOption.Arity = ArgumentArity.ExactlyOne;
+ settings.ArgumentServerName = commandLine.ServerName;
+ settings.ArgumentDatabaseName = commandLine.DatabaseName;
+ settings.IsExternalTool = commandLine.HasConnectionArguments;
+ settings.CommandLineErrors = commandLine.Errors.ToArray();
- var command = new RootCommand
+ using var parentProcess = GetParentProcess(commandLine, currentProcess);
+ if (parentProcess is not null)
{
- serverOption,
- databaseOption,
- parentProcessIdOption
- };
-
- var parseResult = command.Parse(args);
-
- var serverOptionResult = parseResult.FindResultFor(serverOption);
- if (serverOptionResult is not null && serverOptionResult.ErrorMessage is null)
- settings.ArgumentServerName = parseResult.GetValueForOption(serverOption);
+ settings.ParentProcessId = parentProcess.Id;
+ settings.ParentProcessName = parentProcess.ProcessName;
+ //settings.ParentProcessMainWindowHandle = parentProcess.MainWindowHandle;
+ var windowTitle = parentProcess.GetMainWindowTitle();
+ settings.ParentProcessMainWindowTitle = settings.IsPBIDesktopExternalTool ? PowerBIDesktop.GetReportName(windowTitle) : windowTitle;
+ }
+ }
- var databaseOptionResult = parseResult.FindResultFor(databaseOption);
- if (databaseOptionResult is not null && databaseOptionResult.ErrorMessage is null)
- settings.ArgumentDatabaseName = parseResult.GetValueForOption(databaseOption);
+ private static Process? GetParentProcess(StartupCommandLine commandLine, Process currentProcess)
+ {
+ if (commandLine.ParentProcessId is not int parentProcessId || !DesktopBridgeHelper.IsRunningAsMsixPackage())
+ return currentProcess.GetParentProcess();
- settings.IsExternalTool = parseResult.HasOption(serverOption) || parseResult.HasOption(databaseOption);
- settings.CommandLineErrors = parseResult.Errors.Select((e) => e.Message).ToArray();
+ if (!Process.TryGetById(parentProcessId, currentProcess.SessionId, out var parentProcess))
+ return null;
- Process? parentProcess = null;
- {
- if (AppEnvironment.DeploymentMode == AppDeploymentMode.Packaged && parseResult.HasOption(parentProcessIdOption))
- {
- var parentProcessId = parseResult.GetValueForOption(parentProcessIdOption);
- parentProcess = ProcessHelper.SafeGetProcessById(parentProcessId);
- }
- else
- {
- parentProcess = ProcessHelper.GetParentProcess();
- }
-
- if (parentProcess is not null)
- {
- settings.ParentProcessId = parentProcess.Id;
- settings.ParentProcessName = parentProcess.ProcessName;
- //settings.ParentProcessMainWindowHandle = parentProcess.MainWindowHandle;
- settings.ParentProcessMainWindowTitle = settings.IsPBIDesktopExternalTool ? parentProcess.GetPBIDesktopMainWindowTitle() : parentProcess.GetMainWindowTitle();
- }
- }
- parentProcess?.Dispose();
+ return parentProcess;
}
}
diff --git a/src/Infrastructure/Extensions/ProcessExtensions.cs b/src/Infrastructure/Extensions/ProcessExtensions.cs
index 2840462d..2229db1f 100644
--- a/src/Infrastructure/Extensions/ProcessExtensions.cs
+++ b/src/Infrastructure/Extensions/ProcessExtensions.cs
@@ -2,78 +2,120 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
-using System.Management;
-using System.Runtime.InteropServices;
+using System.Diagnostics.CodeAnalysis;
using System.Text;
-using Sqlbi.Bravo.Infrastructure.Helpers;
+using Sqlbi.Bravo.Infrastructure.Windows;
using Sqlbi.Bravo.Infrastructure.Windows.Interop;
namespace Sqlbi.Bravo.Infrastructure.Extensions;
internal static class ProcessExtensions
{
- //[DebuggerStepThrough]
- [Obsolete("Use WMI query")]
- public static Process? InteropGetParent(this Process process)
+ public static IReadOnlyList GetChildProcessIds(this Process process, ProcessTree processTree, string? childProcessImageName = null)
{
- Ntdll.PROCESS_BASIC_INFORMATION processInformation;
- int? retval;
- try
- {
- retval = Ntdll.NtQueryInformationProcess(process.Handle, Ntdll.PROCESSINFOCLASS.ProcessBasicInformation, out processInformation, processInformationLength: (uint)Marshal.SizeOf(typeof(Ntdll.PROCESS_BASIC_INFORMATION)), returnLength: out _);
- }
- catch (Win32Exception ex) when (ex.ErrorCode == -2147467259) // System.ComponentModel.Win32Exception {"Access is denied."}
- {
- return null;
- }
+ var sessionId = process.SessionId;
- if (retval.Value == (int)Ntdll.NTSTATUS.STATUS_SUCCESS)
+ var childProcessIds = new List();
+ foreach (var childProcessId in processTree.GetChildIds(process.Id, childProcessImageName))
{
- var parentProcessId = (int)(uint)processInformation.InheritedFromUniqueProcessId;
- var parentProcess = ProcessHelper.SafeGetProcessById(parentProcessId);
+ if (!Process.TryGetById(childProcessId, sessionId, out var childProcess))
+ continue;
- return parentProcess;
- }
+ var isChild = IsStartedBefore(process, childProcess);
+ childProcess.Dispose();
+
+ if (!isChild)
+ continue;
- // TODO: How NTSTATUS codes are translated into Win32 errors ?
- // throw new Win32Exception(retval);
+ childProcessIds.Add(childProcessId);
+ }
- return null;
+ return childProcessIds;
}
- public static IEnumerable GetChildrenPIDs(this Process process, string? childProcessImageName = null)
+ extension(Process)
{
- // ManagementObjectSearcher.Get() raises a System.InvalidCastException when executed on the current thread, this regardless of the apartment state of the current thread (which is STA)
- //
- // System.InvalidCastException "Specified cast is not valid."
- // at System.StubHelpers.InterfaceMarshaler.ConvertToNative(Object objSrc, IntPtr itfMT, IntPtr classMT, Int32 flags)
- // at System.Management.SecuredIWbemServicesHandler.ExecQuery_(String strQueryLanguage, String strQuery, Int32 lFlags, IWbemContext pCtx, IEnumWbemClassObject& ppEnum)
- // at System.Management.ManagementObjectSearcher.Get()
+ ///
+ /// Gets the running process with the given identifier in the given Windows session.
+ ///
+ ///
+ /// when the process is not running, has exited, runs in another session or cannot be
+ /// opened.
+ ///
+ public static bool TryGetById(int processId, int sessionId, [NotNullWhen(true)] out Process? process)
+ {
+ process = null;
+ Process? candidate = null;
+ try
+ {
+ candidate = Process.GetProcessById(processId); // Throws ArgumentException if the process is not running
- var pids = new List();
+ if (candidate.SessionId != sessionId)
+ return false;
- ProcessHelper.RunOnSTAThread(GetImpl);
+ if (candidate.HasExited) // Throws Win32Exception if the process cannot be opened, for example another user's
+ return false;
- return pids;
+ _ = candidate.ProcessName; // Throws InvalidOperationException if the process has exited in the meantime
- void GetImpl()
+ process = candidate;
+ candidate = null; // Owned by the caller from here on, so the finally block must not dispose it
+ return true;
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or Win32Exception)
+ {
+ return false;
+ }
+ finally
+ {
+ candidate?.Dispose();
+ }
+ }
+ }
+
+ extension(Process process)
+ {
+ ///
+ /// Gets the parent of the process when it runs in the same Windows session; otherwise, .
+ ///
+ public Process? GetParentProcess()
{
- var queryString = $"SELECT ProcessId FROM Win32_Process WHERE ParentProcessId = {process.Id} AND SessionId = {AppEnvironment.SessionId}";
+ var sessionId = process.SessionId;
+ var processTree = ProcessTree.Capture();
- if (childProcessImageName is not null)
- queryString += $" AND Name = '{childProcessImageName}'";
+ var parentProcessId = processTree.GetParentId(process.Id);
+ if (parentProcessId is null)
+ return null;
- using var searcher = new ManagementObjectSearcher(queryString);
- using var collection = searcher.Get();
+ if (!Process.TryGetById(parentProcessId.Value, sessionId, out var parentProcess))
+ return null;
- foreach (var @object in collection)
+ if (!IsStartedBefore(parentProcess, process))
{
- if (@object is not null)
- {
- var processId = (int)(uint)@object.GetPropertyValue("ProcessId");
- pids.Add(processId);
- }
+ parentProcess.Dispose();
+ return null;
}
+
+ return parentProcess;
+ }
+ }
+
+ // A process identifier is reused once its process exits, so the recorded parent or child can be an unrelated
+ // process: a real parent always started before its child.
+ private static bool IsStartedBefore(Process earlier, Process later)
+ {
+ try
+ {
+ // StartTime is local time, which repeats an hour when daylight saving time ends: only UTC keeps the order.
+ var earlierStartTime = earlier.StartTime.ToUniversalTime();
+ var laterStartTime = later.StartTime.ToUniversalTime();
+
+ return earlierStartTime <= laterStartTime;
+ }
+ catch (Exception ex) when (ex is Win32Exception or InvalidOperationException)
+ {
+ // The start time of a process that has exited, or that cannot be opened, is unknown.
+ return false;
}
}
@@ -107,24 +149,4 @@ public static string GetMainWindowTitle(this Process process)
return builder.ToString();
}
-
- public static string? GetPBIDesktopMainWindowTitle(this Process process)
- {
- var windowTitle = process.GetMainWindowTitle();
-
- if (windowTitle.IsNullOrWhiteSpace())
- return null; // PBIDesktop process is starting and/or the SSAS instance is not yet started and/or the model is not yet fully loaded
-
- foreach (var suffix in AppEnvironment.PBIDesktopMainWindowTitleSuffixes)
- {
- var index = windowTitle.LastIndexOf(suffix);
- if (index >= 0)
- {
- windowTitle = windowTitle[..index];
- break;
- }
- }
-
- return windowTitle;
- }
}
diff --git a/src/Infrastructure/Helpers/ProcessHelper.cs b/src/Infrastructure/Helpers/ProcessHelper.cs
index a3a009dc..1208b336 100644
--- a/src/Infrastructure/Helpers/ProcessHelper.cs
+++ b/src/Infrastructure/Helpers/ProcessHelper.cs
@@ -6,13 +6,13 @@
using System.Globalization;
using System.IO;
using System.Linq;
-using System.Management;
using System.Runtime.ExceptionServices;
using System.Security.Claims;
using System.Security.Principal;
using System.Threading;
using System.Windows.Forms;
using Sqlbi.Bravo.Infrastructure.Extensions;
+using Sqlbi.Bravo.Infrastructure.Services.PowerBI;
namespace Sqlbi.Bravo.Infrastructure.Helpers;
@@ -228,8 +228,8 @@ public static bool OpenShellExecute(string path, bool waitForStarted, [NotNullWh
return false;
}
- // If the window title is null it means that the SSAS instance is not yet started and/or the model is not yet fully loaded
- var isAvailable = process.GetPBIDesktopMainWindowTitle() is not null;
+ // If the report name is null it means that the SSAS instance is not yet started and/or the model is not yet fully loaded
+ var isAvailable = PowerBIDesktop.GetReportName(process.GetMainWindowTitle()) is not null;
if (isAvailable)
break;
@@ -280,37 +280,6 @@ public static IReadOnlyList GetProcessesByName(string processName)
return processes;
}
- public static Process? GetParentProcess()
- {
- // ManagementObjectSearcher.Get() raises a System.InvalidCastException when executed on the current thread, this regardless of the apartment state of the current thread (which is STA)
- //
- // System.InvalidCastException "Specified cast is not valid."
- // at System.StubHelpers.InterfaceMarshaler.ConvertToNative(Object objSrc, IntPtr itfMT, IntPtr classMT, Int32 flags)
- // at System.Management.SecuredIWbemServicesHandler.ExecQuery_(String strQueryLanguage, String strQuery, Int32 lFlags, IWbemContext pCtx, IEnumWbemClassObject& ppEnum)
- // at System.Management.ManagementObjectSearcher.Get()
-
- var parentProcessId = (int?)null;
-
- RunOnSTAThread(GetImpl);
-
- var parentProcess = SafeGetProcessById(parentProcessId);
- return parentProcess;
-
- void GetImpl()
- {
- var queryString = $"SELECT ParentProcessId FROM Win32_Process WHERE ProcessId = {AppEnvironment.ProcessId} AND SessionId = {AppEnvironment.SessionId}";
-
- using var searcher = new ManagementObjectSearcher(queryString);
- using var collection = searcher.Get();
- using var @object = collection.OfType().SingleOrDefault();
-
- if (@object is not null)
- {
- parentProcessId = (int)(uint)@object["ParentProcessId"];
- }
- }
- }
-
public static IntPtr GetCurrentProcessMainWindowHandle()
{
using var current = Process.GetCurrentProcess();
@@ -318,39 +287,15 @@ public static IntPtr GetCurrentProcessMainWindowHandle()
return current.MainWindowHandle;
}
- public static IntPtr GetParentProcessMainWindowHandle()
- {
- using var parent = GetParentProcess();
-
- if (parent is not null)
- return parent.MainWindowHandle;
-
- return IntPtr.Zero;
- }
-
public static Process? SafeGetProcessById(int? processId)
{
if (processId is null)
return null;
- try
- {
- var process = Process.GetProcessById(processId.Value); // Throws ArgumentException if the process specified by the processId parameter is not running.
-
- if (process.SessionId != AppEnvironment.SessionId)
- return null;
-
- if (process.HasExited)
- return null;
-
- _ = process.ProcessName; // Throws InvalidOperationException if the process has exited, so the requested information is not available
-
- return process;
- }
- catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException)
- {
+ if (!Process.TryGetById(processId.Value, AppEnvironment.SessionId, out var process))
return null;
- }
+
+ return process;
}
public static bool IsUserAdministrator()
diff --git a/src/Infrastructure/Messages/AppInstanceStartupMessage.cs b/src/Infrastructure/Messages/AppInstanceStartupMessage.cs
index 738bd475..8d2c1316 100644
--- a/src/Infrastructure/Messages/AppInstanceStartupMessage.cs
+++ b/src/Infrastructure/Messages/AppInstanceStartupMessage.cs
@@ -4,6 +4,7 @@
using Sqlbi.Bravo.Infrastructure.Configuration.Settings;
using Sqlbi.Bravo.Infrastructure.Extensions;
using Sqlbi.Bravo.Infrastructure.Helpers;
+using Sqlbi.Bravo.Infrastructure.Services.PowerBI;
using Sqlbi.Bravo.Models;
using SSAS = Microsoft.AnalysisServices;
@@ -33,7 +34,7 @@ internal class AppInstanceStartupMessage
public string[]? CommandLineErrors { get; set; }
[JsonIgnore]
- public bool IsExternalTool => AppEnvironment.PBIDesktopProcessName.EqualsI(ParentProcessName);
+ public bool IsExternalTool => PowerBIDesktop.ProcessName.EqualsI(ParentProcessName);
// Reads no user settings: a secondary instance builds its activation payload before the bootstrap.
public static AppInstanceStartupMessage CreateFrom(StartupSettings settings)
diff --git a/src/Infrastructure/Services/PowerBI/PBIDesktopService.cs b/src/Infrastructure/Services/PowerBI/PBIDesktopService.cs
index 58b7bfe3..2a6612a6 100644
--- a/src/Infrastructure/Services/PowerBI/PBIDesktopService.cs
+++ b/src/Infrastructure/Services/PowerBI/PBIDesktopService.cs
@@ -5,6 +5,7 @@
using System.Threading.Tasks;
using Sqlbi.Bravo.Infrastructure.Extensions;
using Sqlbi.Bravo.Infrastructure.Helpers;
+using Sqlbi.Bravo.Infrastructure.Windows;
using Sqlbi.Bravo.Models;
namespace Sqlbi.Bravo.Infrastructure.Services.PowerBI;
@@ -18,14 +19,15 @@ internal class PBIDesktopService : IPBIDesktopService
{
public IEnumerable GetReports(CancellationToken cancellationToken)
{
- var processes = ProcessHelper.GetProcessesByName(AppEnvironment.PBIDesktopProcessName);
+ var processes = ProcessHelper.GetProcessesByName(PowerBIDesktop.ProcessName);
try
{
+ var processTree = ProcessTree.Capture();
var reports = new ConcurrentBag();
var parallelOptions = new ParallelOptions { CancellationToken = cancellationToken };
var parallelLoop = Parallel.ForEach(processes, parallelOptions, (process) =>
{
- var report = PBIDesktopReport.CreateFrom(process);
+ var report = PBIDesktopReport.CreateFrom(process, processTree);
reports.Add(report);
});
diff --git a/src/Infrastructure/Services/PowerBI/PowerBIDesktop.cs b/src/Infrastructure/Services/PowerBI/PowerBIDesktop.cs
new file mode 100644
index 00000000..8a6d51fb
--- /dev/null
+++ b/src/Infrastructure/Services/PowerBI/PowerBIDesktop.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Immutable;
+
+namespace Sqlbi.Bravo.Infrastructure.Services.PowerBI;
+
+internal static class PowerBIDesktop
+{
+ public const string ProcessName = "PBIDesktop";
+
+ public const string AnalysisServicesImageName = "msmdsrv.exe";
+
+ // The main window title is culture-specific. The suffix is not always present, for example, it is not added when
+ // the save/share function in OneDrive/SharePoint is active.
+ private static readonly ImmutableArray s_applicationNameSuffixes =
+ [
+ // Different dash characters are used as a separator
+ // See https://github.com/sql-bi/Bravo/issues/476
+ " \u002D Power BI Desktop", // Dash Punctuation - minus hyphen
+ " \u2013 Power BI Desktop", // Dash Punctuation - en dash
+ " \u2014 Power BI Desktop", // Dash Punctuation - em dash
+
+ // The whitespace character may not be present - Swedish/sv
+ // See https://github.com/sql-bi/Bravo/issues/510
+ "\u2013Power BI Desktop", // Dash Punctuation - en dash
+
+ // NBSP char instead of whitespace - Latvian/lv
+ "\u00A0\u2014 Power BI Desktop",
+ ];
+
+ ///
+ /// Returns the report name shown in the main window title of Power BI Desktop.
+ ///
+ ///
+ /// The title without the application name; when the title is empty, which happens while
+ /// Power BI Desktop starts its Analysis Services instance or loads the model.
+ ///
+ public static string? GetReportName(string windowTitle)
+ {
+ ArgumentNullException.ThrowIfNull(windowTitle);
+
+ if (string.IsNullOrWhiteSpace(windowTitle))
+ return null;
+
+ foreach (var suffix in s_applicationNameSuffixes)
+ {
+ var index = windowTitle.LastIndexOf(suffix, StringComparison.Ordinal);
+ if (index >= 0)
+ return windowTitle[..index];
+ }
+
+ return windowTitle;
+ }
+}
diff --git a/src/Infrastructure/Windows/Interop/Ntdll.cs b/src/Infrastructure/Windows/Interop/Ntdll.cs
deleted file mode 100644
index 5091283f..00000000
--- a/src/Infrastructure/Windows/Interop/Ntdll.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using System;
-using System.Runtime.InteropServices;
-
-namespace Sqlbi.Bravo.Infrastructure.Windows.Interop;
-
-internal static class Ntdll
-{
- internal enum NTSTATUS
- {
- STATUS_SUCCESS = 0
- }
-
- internal enum PROCESSINFOCLASS
- {
- ProcessBasicInformation = 0,
- //ProcessDebugPort = 7,
- //ProcessWow64Information = 26,
- //ProcessImageFileName = 27,
- //ProcessBreakOnTermination = 29,
- //ProcessSubsystemInformation = 75
- }
-
- internal struct PROCESS_BASIC_INFORMATION
- {
- public uint ExitStatus;
-
- public IntPtr PebBaseAddress;
-
- public UIntPtr AffinityMask;
-
- public int BasePriority;
-
- public UIntPtr UniqueProcessId;
-
- public UIntPtr InheritedFromUniqueProcessId;
- }
-
- [DllImport(ExternDll.Ntdll, ExactSpelling = true, SetLastError = true)]
- internal static extern int NtQueryInformationProcess(IntPtr processHandle, PROCESSINFOCLASS processInformationClass, out PROCESS_BASIC_INFORMATION processInformation, uint processInformationLength, out int returnLength);
-}
diff --git a/src/Infrastructure/Windows/Interop/ProcessInterop.cs b/src/Infrastructure/Windows/Interop/ProcessInterop.cs
new file mode 100644
index 00000000..2cc1eefe
--- /dev/null
+++ b/src/Infrastructure/Windows/Interop/ProcessInterop.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.System.Diagnostics.ToolHelp;
+
+namespace Sqlbi.Bravo.Infrastructure.Windows.Interop;
+
+internal static class ProcessInterop
+{
+ ///
+ /// Returns the processes running now, each with the identifier of the process that created it.
+ ///
+ /// The processes cannot be read.
+ public static IReadOnlyList GetProcesses()
+ {
+ using var snapshot = PInvoke.CreateToolhelp32Snapshot_SafeHandle(CREATE_TOOLHELP_SNAPSHOT_FLAGS.TH32CS_SNAPPROCESS, th32ProcessID: 0);
+ if (snapshot.IsInvalid)
+ throw new Win32Exception();
+
+ var entry = new PROCESSENTRY32W
+ {
+ dwSize = (uint)Marshal.SizeOf(),
+ };
+
+ if (!PInvoke.Process32FirstW(snapshot, ref entry))
+ throw new Win32Exception();
+
+ var processes = new List();
+ do
+ {
+ var process = new ProcessEntry((int)entry.th32ProcessID, (int)entry.th32ParentProcessID, entry.szExeFile.ToString());
+ processes.Add(process);
+ }
+ while (PInvoke.Process32NextW(snapshot, ref entry));
+
+ var error = (WIN32_ERROR)Marshal.GetLastPInvokeError();
+ if (error != WIN32_ERROR.ERROR_NO_MORE_FILES)
+ throw new Win32Exception((int)error);
+
+ return processes;
+ }
+}
diff --git a/src/Infrastructure/Windows/ProcessTree.cs b/src/Infrastructure/Windows/ProcessTree.cs
new file mode 100644
index 00000000..d55fd96a
--- /dev/null
+++ b/src/Infrastructure/Windows/ProcessTree.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using Sqlbi.Bravo.Infrastructure.Windows.Interop;
+
+namespace Sqlbi.Bravo.Infrastructure.Windows;
+
+internal readonly record struct ProcessEntry(int ProcessId, int ParentProcessId, string ImageName);
+
+///
+/// The processes that were running at one moment, each with the identifier of the process that created it.
+///
+///
+/// A parent identifier is recorded when the process is created and is not updated: once the parent exits, the
+/// identifier can belong to an unrelated process.
+///
+internal sealed class ProcessTree
+{
+ private readonly Dictionary _entries = [];
+
+ public ProcessTree(IEnumerable entries)
+ {
+ ArgumentNullException.ThrowIfNull(entries);
+
+ foreach (var entry in entries)
+ _ = _entries.TryAdd(entry.ProcessId, entry);
+ }
+
+ ///
+ /// Takes a snapshot of the processes running now.
+ ///
+ /// The processes cannot be read.
+ public static ProcessTree Capture()
+ {
+ var entries = ProcessInterop.GetProcesses();
+
+ return new ProcessTree(entries);
+ }
+
+ ///
+ /// Returns the identifier of the process that created ; when the
+ /// process is not in the snapshot.
+ ///
+ public int? GetParentId(int processId)
+ {
+ if (!_entries.TryGetValue(processId, out var entry))
+ return null;
+
+ return entry.ParentProcessId;
+ }
+
+ ///
+ /// Returns the processes created by a process, optionally only those with the given image name.
+ ///
+ /// The identifier of the parent process.
+ /// The executable file name, such as msmdsrv.exe, compared without case.
+ public IReadOnlyList GetChildIds(int processId, string? imageName = null)
+ {
+ return _entries.Values
+ .Where((entry) => entry.ParentProcessId == processId && entry.ProcessId != processId)
+ .Where((entry) => imageName is null || string.Equals(entry.ImageName, imageName, StringComparison.OrdinalIgnoreCase))
+ .Select((entry) => entry.ProcessId)
+ .ToArray();
+ }
+}
diff --git a/src/Models/PBIDesktopReport.cs b/src/Models/PBIDesktopReport.cs
index 915a8ab5..df9abbb9 100644
--- a/src/Models/PBIDesktopReport.cs
+++ b/src/Models/PBIDesktopReport.cs
@@ -9,6 +9,8 @@
using Sqlbi.Bravo.Infrastructure.Extensions;
using Sqlbi.Bravo.Infrastructure.Helpers;
using Sqlbi.Bravo.Infrastructure.Models;
+using Sqlbi.Bravo.Infrastructure.Services.PowerBI;
+using Sqlbi.Bravo.Infrastructure.Windows;
using SSAS = Microsoft.AnalysisServices;
using TOM = Microsoft.AnalysisServices.Tabular;
@@ -64,19 +66,24 @@ public override int GetHashCode()
if (process is not null)
{
- var report = CreateFrom(process, connectionModeEnabled);
+ var report = CreateFrom(process, processTree: null, connectionModeEnabled);
return report;
}
return null;
}
- internal static PBIDesktopReport CreateFrom(Process process, bool connectionModeEnabled = true)
+ /// The Power BI Desktop process.
+ ///
+ /// A snapshot shared by the reports created together; takes one when it is needed.
+ ///
+ /// Whether to connect to the model to find its connection mode.
+ internal static PBIDesktopReport CreateFrom(Process process, ProcessTree? processTree = null, bool connectionModeEnabled = true)
{
var report = new PBIDesktopReport
{
ProcessId = process.Id,
- ReportName = process.GetPBIDesktopMainWindowTitle(),
+ ReportName = PowerBIDesktop.GetReportName(process.GetMainWindowTitle()),
ServerName = null,
DatabaseName = null,
CompatibilityMode = SSAS.CompatibilityMode.Unknown,
@@ -107,8 +114,9 @@ void GetConnectionMode(out string? serverName, out string? databaseName, out SSA
databaseName = null;
compatibilityMode = SSAS.CompatibilityMode.Unknown;
- var ssasPIDs = process.GetChildrenPIDs(childProcessImageName: AppEnvironment.PBIDesktopSSASProcessImageName).ToArray();
- if (ssasPIDs.Length != 1)
+ var tree = processTree ?? ProcessTree.Capture();
+ var ssasPIDs = process.GetChildProcessIds(tree, childProcessImageName: PowerBIDesktop.AnalysisServicesImageName);
+ if (ssasPIDs.Count != 1)
{
connectionMode = PBIDesktopReportConnectionMode.UnsupportedAnalysisServicesProcessNotFound;
return;
diff --git a/src/NativeMethods.txt b/src/NativeMethods.txt
index 0b3ca252..efbd74b9 100644
--- a/src/NativeMethods.txt
+++ b/src/NativeMethods.txt
@@ -1,2 +1,10 @@
+// Windows.Win32.Foundation
+WIN32_ERROR
+
+// Windows.Win32.System.Diagnostics.ToolHelp
+CreateToolhelp32Snapshot
+Process32FirstW
+Process32NextW
+
// Windows.Win32.System.Pipes
DisconnectNamedPipe
diff --git a/src/packages.lock.json b/src/packages.lock.json
index cdbb7785..5d132fa8 100644
--- a/src/packages.lock.json
+++ b/src/packages.lock.json
@@ -147,12 +147,6 @@
"resolved": "2.0.0-beta4.22272.1",
"contentHash": "1uqED/q2H0kKoLJ4+hI2iPSBSEdTuhfCYADeJrAqERmiGQ2NNacYKRNEQ+gFbU4glgVyK8rxI+ZOe1onEtr/Pg=="
},
- "System.Management": {
- "type": "Direct",
- "requested": "[7.0.0, )",
- "resolved": "7.0.0",
- "contentHash": "A4jed4QUviDOm7fJNKAJObEAEkEUXmkGL/w0iyCYTzrl1rezTj8LGFHfsVst4Vb9JwFcTpboiDrvdST48avBpw=="
- },
"Antlr4.Runtime.Standard": {
"type": "Transitive",
"resolved": "4.13.1",
@@ -295,12 +289,6 @@
"resolved": "1.0.2739.15",
"contentHash": "hZg7vHWBg4RIiK3rhkNqX3hcBqqoVXVrrO2BVrVOiSmEgfLa7lY64G1tWh2VY84MfHIFLCAQNwn3Rwvu+ICHyw=="
},
- "System.Management": {
- "type": "Direct",
- "requested": "[7.0.0, )",
- "resolved": "7.0.0",
- "contentHash": "A4jed4QUviDOm7fJNKAJObEAEkEUXmkGL/w0iyCYTzrl1rezTj8LGFHfsVst4Vb9JwFcTpboiDrvdST48avBpw=="
- },
"Microsoft.AnalysisServices.AdomdClient": {
"type": "Transitive",
"resolved": "19.104.1",
diff --git a/test/Bravo.Tests/Bravo.Tests.csproj b/test/Bravo.Tests/Bravo.Tests.csproj
index 250e2637..7ec091b6 100644
--- a/test/Bravo.Tests/Bravo.Tests.csproj
+++ b/test/Bravo.Tests/Bravo.Tests.csproj
@@ -9,6 +9,7 @@
+
diff --git a/test/Bravo.Tests/Host/BravoSingleInstanceTests.cs b/test/Bravo.Tests/Host/BravoSingleInstanceTests.cs
index 98d908f5..776bde39 100644
--- a/test/Bravo.Tests/Host/BravoSingleInstanceTests.cs
+++ b/test/Bravo.Tests/Host/BravoSingleInstanceTests.cs
@@ -4,6 +4,8 @@
using System.Threading;
using Microsoft.Extensions.Logging;
using Sqlbi.Bravo.Host;
+using Sqlbi.Bravo.Infrastructure;
+using Sqlbi.Bravo.Infrastructure.Messages;
using Sqlbi.Bravo.Infrastructure.SingleInstance;
using Xunit;
@@ -75,4 +77,28 @@ public void ClaimOrRedirect_PrimaryUnreachable_LogsAnErrorAndReturnsNull()
Assert.Contains(nameof(SingleInstanceSendStatus.OwnerUnavailable), entry.Message);
Assert.Equal(1, loggerFactory.DisposeCount);
}
+
+ [Fact]
+ public void ClaimOrRedirect_DoesNotReferenceAppEnvironment()
+ {
+ // A secondary runs this path before it exits: the AppEnvironment static constructor probes the WebView2
+ // runtime, the deployment mode and the registry, which a process that only forwards its arguments must not do.
+ var claimOrRedirect = typeof(BravoSingleInstance).GetMethod(nameof(BravoSingleInstance.ClaimOrRedirect), Type.EmptyTypes);
+ Assert.NotNull(claimOrRedirect);
+
+ var referencedTypes = CallGraph.GetReferencedTypes(claimOrRedirect);
+
+ Assert.DoesNotContain(typeof(AppEnvironment), referencedTypes);
+ }
+
+ [Fact]
+ public void CallGraph_MethodThatReadsAppEnvironment_ReferencesIt()
+ {
+ var toJsonElement = typeof(AppInstanceStartupMessageExtensions).GetMethod(nameof(AppInstanceStartupMessageExtensions.ToJsonElement));
+ Assert.NotNull(toJsonElement);
+
+ var referencedTypes = CallGraph.GetReferencedTypes(toJsonElement);
+
+ Assert.Contains(typeof(AppEnvironment), referencedTypes);
+ }
}
diff --git a/test/Bravo.Tests/Host/CallGraph.cs b/test/Bravo.Tests/Host/CallGraph.cs
new file mode 100644
index 00000000..08a3404b
--- /dev/null
+++ b/test/Bravo.Tests/Host/CallGraph.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Reflection.Emit;
+
+namespace Bravo.Tests.Host;
+
+///
+/// Reads the IL of methods to find the types whose members they reference.
+///
+///
+/// Calls are followed only to methods of the same assembly, as declared: an override or an interface implementation
+/// reached through virtual dispatch is not followed. Constants do not appear, because the compiler copies their value.
+///
+internal static class CallGraph
+{
+ private static readonly Dictionary s_opCodes = typeof(OpCodes)
+ .GetFields(BindingFlags.Public | BindingFlags.Static)
+ .Select((field) => (OpCode)field.GetValue(null)!)
+ .ToDictionary((opCode) => (ushort)opCode.Value);
+
+ ///
+ /// Returns the types whose fields or methods are referenced by or by any method of its
+ /// assembly that it reaches.
+ ///
+ public static IReadOnlySet GetReferencedTypes(MethodBase root)
+ {
+ var assembly = root.Module.Assembly;
+ var visited = new HashSet();
+ var types = new HashSet();
+ var pending = new Stack();
+ pending.Push(root);
+
+ while (pending.TryPop(out var method))
+ {
+ if (!visited.Add(method))
+ continue;
+
+ foreach (var member in GetReferencedMembers(method))
+ {
+ if (member.DeclaringType is not null)
+ types.Add(member.DeclaringType);
+
+ if (member is MethodBase callee && callee.Module.Assembly == assembly)
+ pending.Push(callee);
+ }
+ }
+
+ return types;
+ }
+
+ private static IEnumerable GetReferencedMembers(MethodBase method)
+ {
+ var il = method.GetMethodBody()?.GetILAsByteArray();
+ if (il is null)
+ yield break;
+
+ var typeArguments = method.DeclaringType?.IsGenericType == true ? method.DeclaringType.GetGenericArguments() : null;
+ var methodArguments = method.IsGenericMethod ? method.GetGenericArguments() : null;
+
+ var position = 0;
+ while (position < il.Length)
+ {
+ var opCode = ReadOpCode(il, ref position);
+
+ if (opCode.OperandType is OperandType.InlineMethod or OperandType.InlineField)
+ {
+ var token = BitConverter.ToInt32(il, position);
+ var member = method.Module.ResolveMember(token, typeArguments, methodArguments);
+ if (member is not null)
+ yield return member;
+ }
+
+ position += GetOperandSize(opCode, il, position);
+ }
+ }
+
+ private static OpCode ReadOpCode(byte[] il, ref int position)
+ {
+ var value = (ushort)il[position++];
+ if (value == 0xFE)
+ value = (ushort)(0xFE00 | il[position++]);
+
+ return s_opCodes[value];
+ }
+
+ private static int GetOperandSize(OpCode opCode, byte[] il, int position)
+ {
+ return opCode.OperandType switch
+ {
+ OperandType.InlineNone => 0,
+ OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1,
+ OperandType.InlineVar => 2,
+ OperandType.InlineI8 or OperandType.InlineR => 8,
+ OperandType.InlineSwitch => 4 + (4 * BitConverter.ToInt32(il, position)),
+ _ => 4,
+ };
+ }
+}
diff --git a/test/Bravo.Tests/Infrastructure/Configuration/Settings/StartupCommandLineTests.cs b/test/Bravo.Tests/Infrastructure/Configuration/Settings/StartupCommandLineTests.cs
new file mode 100644
index 00000000..aa833e59
--- /dev/null
+++ b/test/Bravo.Tests/Infrastructure/Configuration/Settings/StartupCommandLineTests.cs
@@ -0,0 +1,111 @@
+using System.Diagnostics;
+using Sqlbi.Bravo.Infrastructure.Configuration.Settings;
+using Xunit;
+
+namespace Bravo.Tests.Infrastructure.Configuration.Settings;
+
+public class StartupCommandLineTests
+{
+ [Fact]
+ public void Parse_NoArguments_IsEmpty()
+ {
+ var commandLine = StartupCommandLine.Parse([]);
+
+ Assert.True(commandLine.IsEmpty);
+ Assert.False(commandLine.HasConnectionArguments);
+ Assert.Empty(commandLine.Errors);
+ }
+
+ [Theory]
+ [InlineData("--server", "--database")]
+ [InlineData("-s", "-d")]
+ public void Parse_ServerAndDatabase_ReturnsBoth(string serverOption, string databaseOption)
+ {
+ var commandLine = StartupCommandLine.Parse([serverOption, "localhost:12345", databaseOption, "db"]);
+
+ Assert.False(commandLine.IsEmpty);
+ Assert.True(commandLine.HasConnectionArguments);
+ Assert.Equal("localhost:12345", commandLine.ServerName);
+ Assert.Equal("db", commandLine.DatabaseName);
+ Assert.Null(commandLine.ParentProcessId);
+ Assert.Empty(commandLine.Errors);
+ }
+
+ [Fact]
+ public void Parse_MissingDatabase_KeepsTheServerAndReportsAnError()
+ {
+ var commandLine = StartupCommandLine.Parse(["--server", "localhost:12345"]);
+
+ Assert.True(commandLine.HasConnectionArguments);
+ Assert.Equal("localhost:12345", commandLine.ServerName);
+ Assert.Null(commandLine.DatabaseName);
+ Assert.NotEmpty(commandLine.Errors);
+ }
+
+ [Fact]
+ public void Parse_ParentProcessId_ReturnsIt()
+ {
+ var commandLine = StartupCommandLine.Parse(["--server", "localhost:12345", "--database", "db", "--ppid", "42"]);
+
+ Assert.Equal(42, commandLine.ParentProcessId);
+ Assert.Empty(commandLine.Errors);
+ }
+
+ [Theory]
+ [InlineData("--ppid", "abc")]
+ [InlineData("--ppid", "")]
+ [InlineData("--ppid")]
+ public void Parse_InvalidParentProcessId_ReportsAnErrorWithoutAValue(params string[] parentProcessIdArguments)
+ {
+ string[] args = ["--server", "localhost:12345", "--database", "db", .. parentProcessIdArguments];
+
+ var commandLine = StartupCommandLine.Parse(args);
+
+ Assert.Null(commandLine.ParentProcessId);
+ Assert.Equal("localhost:12345", commandLine.ServerName);
+ Assert.NotEmpty(commandLine.Errors);
+ }
+
+ [Fact]
+ public void Parse_UnknownArgument_ReportsErrorsWithoutConnectionArguments()
+ {
+ var commandLine = StartupCommandLine.Parse([@"C:\Reports\Contoso.pbix"]);
+
+ Assert.False(commandLine.IsEmpty);
+ Assert.False(commandLine.HasConnectionArguments);
+ Assert.Null(commandLine.ServerName);
+ Assert.NotEmpty(commandLine.Errors);
+ }
+
+ [Fact]
+ public void FromCommandLine_EmptyCommandLine_SetsOnlyIsEmpty()
+ {
+ // The activation payload of a launch without arguments carries null for every other value.
+ var settings = new StartupSettings();
+ var commandLine = StartupCommandLine.Parse([]);
+ using var currentProcess = Process.GetCurrentProcess();
+
+ settings.FromCommandLine(commandLine, currentProcess);
+
+ Assert.True(settings.IsEmpty);
+ Assert.Null(settings.CommandLineErrors);
+ Assert.Null(settings.ParentProcessId);
+ }
+
+ [Fact]
+ public void FromCommandLine_ConnectionArguments_SetsTheArguments()
+ {
+ var settings = new StartupSettings();
+ var commandLine = StartupCommandLine.Parse(["--server", "localhost:12345", "--database", "db"]);
+ using var currentProcess = Process.GetCurrentProcess();
+
+ settings.FromCommandLine(commandLine, currentProcess);
+
+ Assert.False(settings.IsEmpty);
+ Assert.True(settings.IsExternalTool);
+ Assert.Equal("localhost:12345", settings.ArgumentServerName);
+ Assert.Equal("db", settings.ArgumentDatabaseName);
+ Assert.NotNull(settings.CommandLineErrors);
+ Assert.Empty(settings.CommandLineErrors);
+ }
+}
diff --git a/test/Bravo.Tests/Infrastructure/Extensions/ProcessExtensionsTests.cs b/test/Bravo.Tests/Infrastructure/Extensions/ProcessExtensionsTests.cs
new file mode 100644
index 00000000..2ce1fa09
--- /dev/null
+++ b/test/Bravo.Tests/Infrastructure/Extensions/ProcessExtensionsTests.cs
@@ -0,0 +1,76 @@
+using System.Diagnostics;
+using Sqlbi.Bravo.Infrastructure.Extensions;
+using Xunit;
+
+namespace Bravo.Tests.Infrastructure.Extensions;
+
+public class ProcessExtensionsTests
+{
+ [Fact]
+ public void TryGetById_RunningProcessInTheSession_ReturnsTheProcess()
+ {
+ using var currentProcess = Process.GetCurrentProcess();
+
+ Assert.True(Process.TryGetById(currentProcess.Id, currentProcess.SessionId, out var process));
+
+ using (process)
+ {
+ Assert.Equal(currentProcess.Id, process.Id);
+ }
+ }
+
+ [Fact]
+ public void TryGetById_ProcessInAnotherSession_ReturnsFalse()
+ {
+ using var currentProcess = Process.GetCurrentProcess();
+
+ Assert.False(Process.TryGetById(currentProcess.Id, currentProcess.SessionId + 1, out var process));
+ Assert.Null(process);
+ }
+
+ [Fact]
+ public void TryGetById_ProcessNotRunning_ReturnsFalse()
+ {
+ using var currentProcess = Process.GetCurrentProcess();
+ var startInfo = new ProcessStartInfo("cmd.exe", "/c exit")
+ {
+ CreateNoWindow = true,
+ UseShellExecute = false,
+ };
+ // The handle held until the end of the test keeps Windows from reusing the identifier for another process.
+ using var exitedProcess = Process.Start(startInfo);
+ Assert.NotNull(exitedProcess);
+ exitedProcess.WaitForExit();
+
+ Assert.False(Process.TryGetById(exitedProcess.Id, currentProcess.SessionId, out var process));
+ Assert.Null(process);
+ }
+
+ [Fact]
+ public void GetParentProcess_ChildProcess_ReturnsTheProcessThatStartedIt()
+ {
+ using var currentProcess = Process.GetCurrentProcess();
+ // Redirected input keeps "pause" waiting until the test ends the process.
+ var startInfo = new ProcessStartInfo("cmd.exe", "/c pause")
+ {
+ CreateNoWindow = true,
+ UseShellExecute = false,
+ RedirectStandardInput = true,
+ };
+ using var childProcess = Process.Start(startInfo);
+ Assert.NotNull(childProcess);
+
+ try
+ {
+ using var parentProcess = childProcess.GetParentProcess();
+
+ Assert.NotNull(parentProcess);
+ Assert.Equal(currentProcess.Id, parentProcess.Id);
+ }
+ finally
+ {
+ childProcess.Kill();
+ childProcess.WaitForExit();
+ }
+ }
+}
diff --git a/test/Bravo.Tests/Infrastructure/Extensions/StringExtensionsTests.cs b/test/Bravo.Tests/Infrastructure/Extensions/StringExtensionsTests.cs
index e636c999..89073494 100644
--- a/test/Bravo.Tests/Infrastructure/Extensions/StringExtensionsTests.cs
+++ b/test/Bravo.Tests/Infrastructure/Extensions/StringExtensionsTests.cs
@@ -1,25 +1,12 @@
-using System;
-using System.Collections;
-using System.IO;
-using System.Linq;
-using System.Reflection;
-using Sqlbi.Bravo.Infrastructure;
+using Sqlbi.Bravo.Infrastructure;
using Sqlbi.Bravo.Infrastructure.Extensions;
using Sqlbi.Bravo.Models.FormatDax;
using Xunit;
-using Xunit.Abstractions;
namespace Bravo.Tests.Infrastructure.Extensions;
public class StringExtensionsTests
{
- private readonly ITestOutputHelper _output;
-
- public StringExtensionsTests(ITestOutputHelper output)
- {
- _output = output;
- }
-
[Theory]
[InlineData("", "", DaxLineBreakStyle.None)]
[InlineData(null, null, DaxLineBreakStyle.None)]
@@ -66,55 +53,4 @@ public void AppendApplicationVersion_Test()
Assert.Equal($"Bravo for Power BI - v{AppVersion.SemanticVersion}", actual);
}
-
- [Fact]
- public void IsPBIDesktopMainWindowTitle_Test()
- {
- var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Microsoft Power BI Desktop", "bin");
-
- if (!Directory.Exists(path))
- {
- // Skip test if the BPIDesktop folder does not exist (i.e. build agent)
- return;
- }
-
- foreach (var file in Directory.EnumerateFiles(path, "Microsoft.PowerBI.Client.Windows.Resources.dll", SearchOption.AllDirectories))
- {
- _output.WriteLine("Resource file '{0}'", file);
-
- var assembly = Assembly.LoadFrom(file);
- var name = assembly.GetManifestResourceNames().SingleOrDefault((name) => name.StartsWith($"Microsoft.PowerBI.Client.Windows.PowerBIStringResources."));
-
- if (name is null)
- Assert.Fail($"Resource name not found in file '{file}'");
-
- using var stream = assembly.GetManifestResourceStream(name);
-
- if (name is null)
- Assert.Fail($"Resource stream not found in file '{file}'");
-
- using var reader = new System.Resources.ResourceReader(stream!);
-
- var found = false;
- foreach (DictionaryEntry entry in reader)
- {
- if (entry.Key is string key && key == "PowerBIWindowTitle" && entry.Value is string value)
- {
- var unicodeValue = string.Join(" ", value.EnumerateRunes().Select((rune) => $"U+{rune.Value:X4}"));
- _output.WriteLine("\t{0} > {1}", unicodeValue, value);
-
- var formattedTitle = string.Format(/*System.Globalization.CultureInfo.CurrentCulture,*/ value, "Contoso", "Power BI Desktop");
- var isSupported = AppEnvironment.PBIDesktopMainWindowTitleSuffixes.Any(formattedTitle.EndsWith);
-
- Assert.True(isSupported, $"Unsupported 'PowerBIWindowTitle' format string in resource file '{file}'");
-
- found = true;
- break;
- }
- }
-
- if (!found)
- Assert.Fail($"Resource key 'PowerBIWindowTitle' not found in file '{file}'");
- }
- }
}
diff --git a/test/Bravo.Tests/Infrastructure/Services/PowerBI/PowerBIDesktopInstallation.cs b/test/Bravo.Tests/Infrastructure/Services/PowerBI/PowerBIDesktopInstallation.cs
new file mode 100644
index 00000000..236839fc
--- /dev/null
+++ b/test/Bravo.Tests/Infrastructure/Services/PowerBI/PowerBIDesktopInstallation.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Bravo.Tests.Interop;
+
+namespace Bravo.Tests.Infrastructure.Services.PowerBI;
+
+///
+/// Finds the Power BI Desktop installations on this machine: the MSI one and the Microsoft Store package.
+///
+internal static class PowerBIDesktopInstallation
+{
+ private const string PackageFamilyName = "Microsoft.MicrosoftPowerBIDesktop_8wekyb3d8bbwe";
+
+ ///
+ /// Returns the bin folder of every installation.
+ ///
+ public static IEnumerable GetBinFolders()
+ {
+ var programFilesFolder = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+ var installerFolder = Path.Combine(programFilesFolder, "Microsoft Power BI Desktop", "bin");
+ if (Directory.Exists(installerFolder))
+ yield return installerFolder;
+
+ // The Store package of Power BI Desktop requires Windows 10.
+ if (!OperatingSystem.IsWindowsVersionAtLeast(10))
+ yield break;
+
+ // The package folder is under WindowsApps, which cannot be listed, so its path comes from the package.
+ foreach (var packageFolder in PackageInterop.GetPackageFolders(PackageFamilyName))
+ {
+ var binFolder = Path.Combine(packageFolder, "bin");
+ if (Directory.Exists(binFolder))
+ yield return binFolder;
+ }
+ }
+}
diff --git a/test/Bravo.Tests/Infrastructure/Services/PowerBI/PowerBIDesktopTests.cs b/test/Bravo.Tests/Infrastructure/Services/PowerBI/PowerBIDesktopTests.cs
new file mode 100644
index 00000000..b16fcb86
--- /dev/null
+++ b/test/Bravo.Tests/Infrastructure/Services/PowerBI/PowerBIDesktopTests.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Resources;
+using Sqlbi.Bravo.Infrastructure.Services.PowerBI;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Bravo.Tests.Infrastructure.Services.PowerBI;
+
+public class PowerBIDesktopTests
+{
+ private readonly ITestOutputHelper _output;
+
+ public PowerBIDesktopTests(ITestOutputHelper output)
+ {
+ _output = output;
+ }
+
+ [Theory]
+ [InlineData("Contoso \u002D Power BI Desktop")]
+ [InlineData("Contoso \u2013 Power BI Desktop")]
+ [InlineData("Contoso \u2014 Power BI Desktop")]
+ [InlineData("Contoso\u2013Power BI Desktop")]
+ [InlineData("Contoso\u00A0\u2014 Power BI Desktop")]
+ public void GetReportName_TitleWithApplicationName_ReturnsTheReportName(string windowTitle)
+ {
+ Assert.Equal("Contoso", PowerBIDesktop.GetReportName(windowTitle));
+ }
+
+ [Fact]
+ public void GetReportName_TitleWithoutApplicationName_ReturnsTheTitle()
+ {
+ Assert.Equal("Contoso", PowerBIDesktop.GetReportName("Contoso"));
+ }
+
+ [Fact]
+ public void GetReportName_ReportNameEndingWithApplicationName_RemovesOnlyTheLastOne()
+ {
+ var reportName = PowerBIDesktop.GetReportName("Contoso - Power BI Desktop - Power BI Desktop");
+
+ Assert.Equal("Contoso - Power BI Desktop", reportName);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void GetReportName_EmptyTitle_ReturnsNull(string windowTitle)
+ {
+ Assert.Null(PowerBIDesktop.GetReportName(windowTitle));
+ }
+
+ [Fact]
+ public void GetReportName_InstalledTitleFormats_ReturnsTheReportName()
+ {
+ var files = PowerBIDesktopInstallation.GetBinFolders()
+ .SelectMany((folder) => Directory.EnumerateFiles(folder, "Microsoft.PowerBI.Client.Windows.Resources.dll", SearchOption.AllDirectories))
+ .ToArray();
+
+ if (files.Length == 0)
+ {
+ // Skip test if Power BI Desktop is not installed (i.e. build agent)
+ return;
+ }
+
+ foreach (var file in files)
+ {
+ _output.WriteLine("Resource file '{0}'", file);
+
+ // From bytes: the Store package folder can be read but its files cannot be loaded as images. Each load also gets
+ // its own context, so the MSI installation and the Store package can contain the same assembly identity.
+ var assembly = Assembly.Load(File.ReadAllBytes(file));
+ var name = assembly.GetManifestResourceNames().SingleOrDefault((name) => name.StartsWith("Microsoft.PowerBI.Client.Windows.PowerBIStringResources."));
+
+ if (name is null)
+ Assert.Fail($"Resource name not found in file '{file}'");
+
+ using var stream = assembly.GetManifestResourceStream(name);
+
+ if (stream is null)
+ Assert.Fail($"Resource stream not found in file '{file}'");
+
+ using var reader = new ResourceReader(stream);
+
+ var found = false;
+ foreach (DictionaryEntry entry in reader)
+ {
+ if (entry.Key is string key && key == "PowerBIWindowTitle" && entry.Value is string value)
+ {
+ var unicodeValue = string.Join(" ", value.EnumerateRunes().Select((rune) => $"U+{rune.Value:X4}"));
+ _output.WriteLine("\t{0} > {1}", unicodeValue, value);
+
+ var formattedTitle = string.Format(/*System.Globalization.CultureInfo.CurrentCulture,*/ value, "Contoso", "Power BI Desktop");
+
+ var reportName = PowerBIDesktop.GetReportName(formattedTitle);
+
+ Assert.True(reportName == "Contoso", $"Unsupported 'PowerBIWindowTitle' format string in resource file '{file}'");
+
+ found = true;
+ break;
+ }
+ }
+
+ if (!found)
+ Assert.Fail($"Resource key 'PowerBIWindowTitle' not found in file '{file}'");
+ }
+ }
+}
diff --git a/test/Bravo.Tests/Infrastructure/Windows/ProcessTreeTests.cs b/test/Bravo.Tests/Infrastructure/Windows/ProcessTreeTests.cs
new file mode 100644
index 00000000..63ae2a99
--- /dev/null
+++ b/test/Bravo.Tests/Infrastructure/Windows/ProcessTreeTests.cs
@@ -0,0 +1,76 @@
+using System;
+using Sqlbi.Bravo.Infrastructure.Windows;
+using Xunit;
+
+namespace Bravo.Tests.Infrastructure.Windows;
+
+public class ProcessTreeTests
+{
+ private static readonly ProcessTree s_tree = new(
+ [
+ new ProcessEntry(ProcessId: 0, ParentProcessId: 0, "[System Process]"),
+ new ProcessEntry(ProcessId: 100, ParentProcessId: 10, "PBIDesktop.exe"),
+ new ProcessEntry(ProcessId: 200, ParentProcessId: 100, "msmdsrv.exe"),
+ new ProcessEntry(ProcessId: 300, ParentProcessId: 100, "Bravo.exe"),
+ new ProcessEntry(ProcessId: 400, ParentProcessId: 300, "msedgewebview2.exe"),
+ ]);
+
+ [Fact]
+ public void GetParentId_ProcessInTheSnapshot_ReturnsItsParent()
+ {
+ Assert.Equal(100, s_tree.GetParentId(300));
+ }
+
+ [Fact]
+ public void GetParentId_ParentNotInTheSnapshot_ReturnsTheRecordedId()
+ {
+ // The parent exited: its identifier is still recorded on the child.
+ Assert.Equal(10, s_tree.GetParentId(100));
+ }
+
+ [Fact]
+ public void GetParentId_ProcessNotInTheSnapshot_ReturnsNull()
+ {
+ Assert.Null(s_tree.GetParentId(999));
+ }
+
+ [Fact]
+ public void GetChildIds_WithoutImageName_ReturnsEveryChild()
+ {
+ Assert.Equal([200, 300], s_tree.GetChildIds(100));
+ }
+
+ [Theory]
+ [InlineData("msmdsrv.exe")]
+ [InlineData("MSMDSRV.EXE")]
+ public void GetChildIds_WithImageName_ReturnsTheMatchingChildren(string imageName)
+ {
+ Assert.Equal([200], s_tree.GetChildIds(100, imageName));
+ }
+
+ [Fact]
+ public void GetChildIds_ProcessThatIsItsOwnParent_IsNotItsOwnChild()
+ {
+ Assert.Empty(s_tree.GetChildIds(0));
+ }
+
+ [Fact]
+ public void Constructor_DuplicateProcessId_KeepsTheFirstEntry()
+ {
+ var tree = new ProcessTree(
+ [
+ new ProcessEntry(ProcessId: 1, ParentProcessId: 2, "a.exe"),
+ new ProcessEntry(ProcessId: 1, ParentProcessId: 3, "b.exe"),
+ ]);
+
+ Assert.Equal(2, tree.GetParentId(1));
+ }
+
+ [Fact]
+ public void Capture_ContainsTheCurrentProcess()
+ {
+ var tree = ProcessTree.Capture();
+
+ Assert.NotNull(tree.GetParentId(Environment.ProcessId));
+ }
+}
diff --git a/test/Bravo.Tests/Interop/PackageInterop.cs b/test/Bravo.Tests/Interop/PackageInterop.cs
new file mode 100644
index 00000000..b5c7457e
--- /dev/null
+++ b/test/Bravo.Tests/Interop/PackageInterop.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Runtime.Versioning;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+
+namespace Bravo.Tests.Interop;
+
+[SupportedOSPlatform("windows8.1")]
+internal static class PackageInterop
+{
+ ///
+ /// Returns the installation folders of the packages of a family installed for the current user.
+ ///
+ /// The packages cannot be read.
+ public static IReadOnlyList GetPackageFolders(string packageFamilyName)
+ {
+ uint count = 0;
+ uint bufferLength = 0;
+ var result = TestPInvoke.GetPackagesByPackageFamily(packageFamilyName, ref count, null, ref bufferLength, null);
+ if (result == WIN32_ERROR.ERROR_SUCCESS)
+ return []; // No package of the family is installed
+
+ if (result != WIN32_ERROR.ERROR_INSUFFICIENT_BUFFER)
+ throw new Win32Exception((int)result);
+
+ var packageFullNames = new PWSTR[count];
+ var buffer = new char[bufferLength];
+ result = TestPInvoke.GetPackagesByPackageFamily(packageFamilyName, ref count, packageFullNames, ref bufferLength, buffer);
+ if (result != WIN32_ERROR.ERROR_SUCCESS)
+ throw new Win32Exception((int)result);
+
+ var folders = new List();
+ foreach (var packageFullName in new string(buffer).Split('\0', StringSplitOptions.RemoveEmptyEntries))
+ folders.Add(GetPackagePath(packageFullName));
+
+ return folders;
+ }
+
+ private static string GetPackagePath(string packageFullName)
+ {
+ uint pathLength = 0;
+ var result = TestPInvoke.GetPackagePathByFullName(packageFullName, ref pathLength, null);
+ if (result != WIN32_ERROR.ERROR_INSUFFICIENT_BUFFER)
+ throw new Win32Exception((int)result);
+
+ var path = new char[pathLength];
+ result = TestPInvoke.GetPackagePathByFullName(packageFullName, ref pathLength, path);
+ if (result != WIN32_ERROR.ERROR_SUCCESS)
+ throw new Win32Exception((int)result);
+
+ return new string(path, 0, (int)pathLength - 1); // The length includes the null terminator
+ }
+}
diff --git a/test/Bravo.Tests/NativeMethods.json b/test/Bravo.Tests/NativeMethods.json
new file mode 100644
index 00000000..1c96c39f
--- /dev/null
+++ b/test/Bravo.Tests/NativeMethods.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://aka.ms/CsWin32.schema.json",
+ "className": "TestPInvoke"
+}
diff --git a/test/Bravo.Tests/NativeMethods.txt b/test/Bravo.Tests/NativeMethods.txt
new file mode 100644
index 00000000..abde6e4e
--- /dev/null
+++ b/test/Bravo.Tests/NativeMethods.txt
@@ -0,0 +1,3 @@
+// Windows.Win32.Storage.Packaging.Appx
+GetPackagePathByFullName
+GetPackagesByPackageFamily