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
1 change: 0 additions & 1 deletion src/GlobalSuppressions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@

[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "<Pending>", Scope = "member", Target = "~M:Sqlbi.Bravo.Startup.ConfigureServices(Microsoft.Extensions.DependencyInjection.IServiceCollection)")]
[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "<Pending>", Scope = "member", Target = "~M:Sqlbi.Bravo.Startup.Configure(Microsoft.AspNetCore.Builder.IApplicationBuilder,Microsoft.AspNetCore.Hosting.IWebHostEnvironment)")]
[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "<Pending>", Scope = "member", Target = "~M:Sqlbi.Bravo.Infrastructure.AppInstance.NotifyOwner")]
[assembly: SuppressMessage("Style", "IDE0037:Use inferred member name", Justification = "<Pending>", Scope = "member", Target = "~M:Sqlbi.Bravo.Infrastructure.Messages.UnknownWebMessage.CreateFrom(System.Exception)~Sqlbi.Bravo.Infrastructure.Messages.UnknownWebMessage")]
[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "<Pending>", Scope = "member", Target = "~M:Sqlbi.Bravo.Infrastructure.Services.ExportData.ExportDataJobMap`1.Remove(`0)")]
[assembly: SuppressMessage("Style", "IDE0042:Deconstruct variable declaration", Justification = "<Pending>", Scope = "member", Target = "~M:Sqlbi.Bravo.Models.ManageDates.DateConfiguration.CreateFrom(Dax.Template.Package)~Sqlbi.Bravo.Models.ManageDates.DateConfiguration")]
Expand Down
17 changes: 17 additions & 0 deletions src/Host/ActivationRequestedEventArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;
using Sqlbi.Bravo.Infrastructure.Messages;

namespace Sqlbi.Bravo.Host;

/// <summary>
/// Carries the startup arguments of the secondary instance that requested the activation.
/// </summary>
internal class ActivationRequestedEventArgs(AppInstanceStartupMessage? startupMessage) : EventArgs
{
/// <summary>
/// The decoded startup message, or <see langword="null"/> when the payload could not be read.
/// The event is raised either way: the user asked for Bravo, so the window is brought to the
/// foreground even when there is nothing to open.
/// </summary>
public AppInstanceStartupMessage? StartupMessage { get; } = startupMessage;
}
149 changes: 149 additions & 0 deletions src/Host/BravoApplicationInstance.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
using System;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Sqlbi.Bravo.Infrastructure.Configuration.Settings;
using Sqlbi.Bravo.Infrastructure.Helpers;
using Sqlbi.Bravo.Infrastructure.Messages;
using Sqlbi.Bravo.Infrastructure.SingleInstance;
using Sqlbi.Bravo.Infrastructure.Telemetry;

namespace Sqlbi.Bravo.Host;

/// <summary>
/// Binds the generic single-instance component to Bravo: it owns the instance name, the wire format
/// of the activation message and the reporting of failures. The component underneath knows none of
/// these things.
/// </summary>
internal sealed class BravoApplicationInstance : IDisposable
{
private readonly SingleInstanceOptions _options;
private readonly SingleInstanceServer? _server;

private bool _disposed;

// Not a primary constructor: it has to stay private (Create is the only entry point) and it
// subscribes to the component's events, which a primary constructor cannot do.
private BravoApplicationInstance(SingleInstanceOptions options, SingleInstanceServer? server)
{
_options = options;
_server = server;

if (_server is not null)
{
_server.Activated += OnActivated;
_server.Error += OnError;
}
}

/// <summary>
/// Determines whether this process is the primary instance — the one that runs the application.
/// When it is not, another instance is already running and <see cref="RequestActivation"/>
/// should be called before exiting.
/// </summary>
public bool IsPrimary => _server is not null;

/// <summary>
/// Occurs on the primary instance when another instance asks it to come forward, carrying that
/// instance's startup arguments. Raised on a thread pool thread: handlers that touch the UI must
/// marshal. Nothing has been activated yet when this fires — honouring the request is up to the
/// subscribers.
/// </summary>
/// <remarks>
/// Not buffered. Requests that arrive before the subscribers exist are dropped. The pipe accepts
/// connections before the UI is ready to process them, so early requests can be lost even though
/// the secondary instance is told the payload was delivered. Buffering and replaying these requests
/// was considered but rejected because the added complexity is not justified by the short startup
/// window.
/// </remarks>
public event EventHandler<ActivationRequestedEventArgs>? ActivationRequested;

/// <summary>
/// Takes the role of primary instance if no other process holds it. The returned object is
/// valid either way: inspect <see cref="IsPrimary"/> to know which role this process got.
/// </summary>
public static BravoApplicationInstance Create()
{
var options = new SingleInstanceOptions
{
PipeName = InstancePipeName.Create(),
};

_ = SingleInstanceServer.TryStart(options, out var server);

return new BravoApplicationInstance(options, server);
}

/// <summary>
/// Asks the primary instance to come forward, handing it the startup arguments of this process.
/// Called by a secondary instance, which then exits.
/// </summary>
public void RequestActivation()
{
var startupSettings = StartupSettings.CreateFromCommandLineArguments();
var startupMessage = AppInstanceStartupMessage.CreateFrom(startupSettings);
var json = JsonSerializer.Serialize(startupMessage);
var payload = Encoding.UTF8.GetBytes(json);

var result = SingleInstanceClient.Send(_options, payload);
if (!result.IsDelivered && result.Exception is not null)
{
// The primary instance cannot be reached (for example, an elevated "Run as administrator"
// launch against a non-elevated instance), activation fails silently. This can happen because
// PipeOptions.CurrentUserOnly compares WindowsIdentity.Owner, which differs when elevation changes
// the identity to BUILTIN\Administrators. The failure is recorded in telemetry and the event log
// (BravoApplicationInstance.RequestActivation), but no user-facing error is shown.

// TODO: Consider surfacing activation failures to the user.
Report(result.Exception);
}
}

private void OnActivated(object? sender, SingleInstanceActivatedEventArgs e)
{
var startupMessage = default(AppInstanceStartupMessage?);
try
{
var json = Encoding.UTF8.GetString(e.Payload);
startupMessage = JsonSerializer.Deserialize<AppInstanceStartupMessage>(json);
}
catch (JsonException)
{
// An unreadable payload still activates the window, with no document to open.
}

// ACCEPTED LIMITATION — requests arriving while this instance is still starting up are
// dropped here, on purpose. The pipe answers from the moment the process starts, but the
// subscribers appear later: AppWindow attaches the bring-to-front handler in OnLoad, and the
// one that forwards the startup message only in OnWebViewDOMContentLoaded, seconds later.
// So a request that lands before the window exists is lost entirely, and one that lands
// between the window and a loaded WebView brings Bravo to the front but does not open what
// was asked for. The secondary instance is told the payload was delivered either way, so
// nothing records the loss.
// TODO: Consider buffering the requests and replaying them once the UI is ready.
ActivationRequested?.Invoke(this, new ActivationRequestedEventArgs(startupMessage));
}

private void OnError(object? sender, SingleInstanceErrorEventArgs e) => Report(e.Exception);

private static void Report(Exception exception)
{
ExceptionHelper.WriteToEventLog(exception, EventLogEntryType.Warning);
TelemetryService.Instance.TrackException(exception);
}

public void Dispose()
{
if (_disposed)
return;

_disposed = true;

if (_server is not null)
{
_server.Activated -= OnActivated;
_server.Error -= OnError;
_server.Dispose();
}
}
}
61 changes: 61 additions & 0 deletions src/Host/InstancePipeName.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System;
using System.Security.Principal;
using Sqlbi.Bravo.Infrastructure;

namespace Sqlbi.Bravo.Host;

/// <summary>
/// Builds the named-pipe name that identifies a running Bravo instance.
/// </summary>
/// <remarks>
/// <para>
/// THE RULE: one instance per Windows session and per account, across elevation levels, and
/// regardless of how Bravo was installed — Portable, both MSI variants and the MSIX/Store package
/// are the same application to the user, and must not run side by side just because one of them
/// happens to come from a different install mechanism.
/// </para>
/// <para>
/// The format is a frozen contract: changing it makes a new build unable to see an instance started
/// by an older one. It is kept as a pure function so that it can be pinned by tests without touching
/// the registry, the process or the current Windows identity.
/// </para>
/// </remarks>
internal static class InstancePipeName
{
/// <summary>
/// Builds the name for the current user and session.
/// </summary>
public static string Create()
{
using var identity = WindowsIdentity.GetCurrent();

// User, not the token's default object owner. A "run as" with a different user gets its own
// instance — that account has its own %LOCALAPPDATA%, MSAL cache and HKCU, so nothing is
// shared with it. A "run as administrator" must not get its own instance: UAC elevation keeps
// the same Windows account, so usersettings.json, the WebView2 user data folder and
// .msalcache are the same files, and they need a single writer. Owner would get this backwards
// — elevation changes it to BUILTIN\Administrators, while User stays stable across elevation.
var userSid = identity.User?.Value
?? throw new InvalidOperationException("The current Windows identity has no user SID.");

return Create(AppEnvironment.SessionId, userSid);
}

/// <param name="sessionId">
/// Identifies the Windows session. Every Terminal Services session needs its own window and its
/// own Bravo instance, so a different session is always a different instance.
/// </param>
/// <param name="userSid">
/// Identifies the Windows account and must remain stable across UAC elevation — see
/// <see cref="Create()"/> for why this is <c>User</c> rather than the token owner.
/// </param>
public static string Create(int sessionId, string userSid)
{
// Both constants below are hardcoded; they are part of the frozen
// contract that makes the instance name stable across releases.
const string ApplicationName = "Bravo";
const string ScopeId = "8D4D9F1D39F94C7789D84729480D8198";

return $"{ApplicationName}.{ScopeId}.{sessionId}.{userSid}";
}
}
Loading
Loading