diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4008b23..2d71fd8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,7 +44,7 @@ jobs:
shell: pwsh
run: >-
dotnet test tests/MarkdownViewr.Windows.UiTests --no-build
- --filter 'FullyQualifiedName~SourceHighlighting|FullyQualifiedName~Settings_NewWindowPanelDefaults|FullyQualifiedName~Settings_ContentsPresentation|FullyQualifiedName~Settings_ContentsTooltip|FullyQualifiedName~Themes_PalettePreviewAndDefaultChoice|FullyQualifiedName~Settings_PrintingDefaults|FullyQualifiedName~Settings_PrintingDraft|FullyQualifiedName~ThemeTransfer_NativePickerCancels|FullyQualifiedName~Print_UserWidthAndBorders|FullyQualifiedName~ReadingKeys_ScrollCommands|FullyQualifiedName~ReadingKeys_FindAndTextFocus|FullyQualifiedName~Source_ShowReloadAndHide|FullyQualifiedName~ThemeEditor_AllTypography|FullyQualifiedName~ThemeEditor_NewCustomTheme|FullyQualifiedName~Appearance_WidthCssAndPrint|FullyQualifiedName~Toolbar_ReadingCommandsAndMenus|FullyQualifiedName~Toolbar_EditorAndPrint|FullyQualifiedName~ThemeTransfer_BatchFutureInvalidAndDisabledExport|FullyQualifiedName~ReadingKeys_HeadingSequences|FullyQualifiedName~Source_BidirectionalScroll|FullyQualifiedName~Source_RawHtmlHeadingProvenance|FullyQualifiedName~Extensions_NativePreferences|FullyQualifiedName~Windows_LocalDocumentLinks|FullyQualifiedName~ActivationInboxTests|FullyQualifiedName~Outline_NestedCollapse'
+ --filter 'FullyQualifiedName~SourceHighlighting|FullyQualifiedName~Settings_NewWindowPanelDefaults|FullyQualifiedName~Settings_ContentsPresentation|FullyQualifiedName~Settings_ContentsTooltip|FullyQualifiedName~Themes_PalettePreviewAndDefaultChoice|FullyQualifiedName~Settings_PrintingDefaults|FullyQualifiedName~Settings_PrintingDraft|FullyQualifiedName~ThemeTransfer_NativePickerCancels|FullyQualifiedName~Print_UserWidthAndBorders|FullyQualifiedName~ReadingKeys_ScrollCommands|FullyQualifiedName~ReadingKeys_FindAndTextFocus|FullyQualifiedName~Source_ShowReloadAndHide|FullyQualifiedName~ThemeEditor_AllTypography|FullyQualifiedName~ThemeEditor_NewCustomTheme|FullyQualifiedName~Appearance_WidthCssAndPrint|FullyQualifiedName~Toolbar_ReadingCommandsAndMenus|FullyQualifiedName~Toolbar_EditorAndPrint|FullyQualifiedName~ThemeTransfer_BatchFutureInvalidAndDisabledExport|FullyQualifiedName~ReadingKeys_HeadingSequences|FullyQualifiedName~Source_BidirectionalScroll|FullyQualifiedName~Source_RawHtmlHeadingProvenance|FullyQualifiedName~Extensions_NativePreferences|FullyQualifiedName~Windows_LocalDocumentLinks|FullyQualifiedName~Outline_NestedCollapse'
--logger 'trx;LogFileName=reader-regressions.trx'
--results-directory artifacts/reader-regressions
- name: Retain native regression evidence
diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml
index f02dc3f..2ad4bab 100644
--- a/.github/workflows/fuzz.yml
+++ b/.github/workflows/fuzz.yml
@@ -41,21 +41,12 @@ jobs:
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
dotnet build tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 -warnaserror --no-restore
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- - name: Restore and build activation adapter targets
- shell: pwsh
- run: |
- dotnet restore tests/MarkdownViewr.Windows.UiTests -p:Platform=x64 --locked-mode
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- dotnet build tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 -warnaserror --no-restore
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Verify seeded parser security properties
shell: pwsh
run: |
Write-Output "Fuzz seeds22023/99173, five targets, $env:K22_FUZZ_ITERATIONS iterations per seed/target."
dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz --logger 'trx;LogFileName=fuzz.trx' --results-directory artifacts/fuzz
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- dotnet test tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz --logger 'trx;LogFileName=activation-fuzz.trx' --results-directory artifacts/fuzz
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Retain synthetic campaign failures and results
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
diff --git a/docs/adr/0005-windows-file-registration-and-activation.md b/docs/adr/0005-windows-file-registration-and-activation.md
index 1aafe63..7109bef 100644
--- a/docs/adr/0005-windows-file-registration-and-activation.md
+++ b/docs/adr/0005-windows-file-registration-and-activation.md
@@ -33,10 +33,22 @@ crossing processes; the public SDK launch envelope cannot carry sender CWD.
Deliver requests through a current-user-only .NET named pipe. Accept only a
source-generated bounded schema: at most128 absolute paths, a1MiB frame and an
optional fixed invalid-path notification. Reject unknown fields; no command or
-script operation exists. Connection/read/acknowledgement waits are bounded to
-10seconds, with at most16 requests queued before XAML attaches its dispatcher.
-Malformed/disconnected peers cannot stop later valid requests. App shutdown
-cancels and disposes the listener. No dependencies or generic RPC layer are added.
+script operation exists. A sender waits at most2 seconds for the reader's pipe
+and each side at most10 seconds for one exchange, with at most16 requests held
+before XAML attaches its dispatcher. Malformed/disconnected peers cannot stop
+later valid requests. App shutdown cancels and disposes the listener, releasing
+the pipe name before the instance key. No dependencies or generic RPC layer are added.
+
+The request, the schema and the inbox live in the core library
+(`Core/Activation`), where the unit tests and the coverage gate reach them.
+
+Forwarding is best effort; opening the document is not. A launch whose request
+the running reader did not take (no listener, a refusal, or a deadline) opens
+its documents in its own process. A reader that cannot create its inbox, because
+the pipe name is already held by a reader that is still closing or by another
+program, runs without one; later launches then open their own documents. An
+argument that cannot name a document is dropped and reported in the reader
+window while the usable arguments still open.
The foreground launcher permits the primary to focus an existing document.
Dispatch all window creation on the native UI thread; duplicate paths include
diff --git a/docs/security/assurance.json b/docs/security/assurance.json
index 3a35dac..197daa9 100644
--- a/docs/security/assurance.json
+++ b/docs/security/assurance.json
@@ -11,7 +11,7 @@
"reason": "Untrusted Markdown/HTML/frontmatter, source ranges/folds, resource URLs, imported theme JSON and activation JSON are parser boundaries. Seeded mutation targets verify unsafe acceptance as well as exceptions.",
"targets": [
"tests/MarkdownViewr.Windows.Core.Tests/SecurityFuzzTests.cs",
- "tests/MarkdownViewr.Windows.UiTests/ActivationFuzzTests.cs"
+ "tests/MarkdownViewr.Windows.Core.Tests/ActivationFuzzTests.cs"
],
"command": "dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz",
"workflow": ".github/workflows/fuzz.yml",
diff --git a/docs/security/fuzzing.md b/docs/security/fuzzing.md
index fd735b2..a1e98d0 100644
--- a/docs/security/fuzzing.md
+++ b/docs/security/fuzzing.md
@@ -30,8 +30,7 @@ Run from the repository root with the pinned .NET 10 SDK on Windows:
dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode
$env:K22_FUZZ_ITERATIONS = '500'
dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --filter Category=Fuzz
-dotnet test tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 --filter Category=Fuzz
-# Weekly and before every MINOR/MAJOR release, repeat both commands with 10000.
+# Weekly and before every MINOR/MAJOR release, repeat the command with 10000.
$env:K22_FUZZ_ITERATIONS = '10000'
```
diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md
index 37506ff..bc77b72 100644
--- a/docs/security/threat-model.md
+++ b/docs/security/threat-model.md
@@ -324,6 +324,13 @@ relative/control-containing/null/overlong paths and more than128 paths; frames
are bounded to1MiB, connection/read waits to10seconds and pre-XAML queuing to16
requests. Normal document size/resource/sanitizer boundaries still apply.
+The pipe name is derived from the executable and data folders, so another
+program running as any user on the machine can create that name first. That
+cannot expose a document or redirect one: both ends require a pipe owned by the
+current user. It could only deny forwarding, so a reader that cannot create its
+inbox starts without one, and a launch whose request is not taken opens its own
+documents.
+
Malformed/disconnected requests are rejected and the listener continues; owned
shutdown cancels the pipe. Fixed notifications and failure kind/HRESULT logging
do not expose paths or payloads. UI-dispatched duplicate activation preserves
diff --git a/src/MarkdownViewr.Windows.Core/Activation/ActivationInbox.cs b/src/MarkdownViewr.Windows.Core/Activation/ActivationInbox.cs
new file mode 100644
index 0000000..9ccb448
--- /dev/null
+++ b/src/MarkdownViewr.Windows.Core/Activation/ActivationInbox.cs
@@ -0,0 +1,214 @@
+/*
+ * Copyright (c) 2026 James Maes
+ * SPDX-License-Identifier: MIT
+ */
+
+using System.Buffers.Binary;
+using System.IO.Pipes;
+using System.Text.Json;
+
+
+namespace MarkdownViewr.Windows.Core.Activation;
+
+///
+/// The running reader's inbox for documents a later launch asks it to open. One named pipe,
+/// open only to the current Windows user, carries one length-prefixed
+/// per connection and answers with one byte: accepted or not.
+///
+public sealed class ActivationInbox : IDisposable
+{
+ /// Requests held while the reader is still starting; a further one is refused so the sender can report it.
+ public const int MaximumPendingRequests = 16;
+
+ /// How long a sender waits for the reader's pipe to exist and be free.
+ public static readonly TimeSpan ConnectDeadline = TimeSpan.FromSeconds(2);
+
+ /// How long either side waits for the other to finish one request.
+ public static readonly TimeSpan ExchangeDeadline = TimeSpan.FromSeconds(10);
+
+ private readonly Lock _gate = new();
+ private readonly Queue _pending = new();
+ private readonly CancellationTokenSource _closed = new();
+ private readonly NamedPipeServerStream _pipe;
+ private readonly Action _report;
+ private readonly TimeProvider _time;
+ private readonly Task _listener;
+ private Func? _dispatch;
+ private int _disposed;
+
+
+
+ ///
+ /// Creates the pipe and starts listening. Throws when the pipe name is already taken, by a
+ /// reader that has not finished closing or by another program; the caller then runs without
+ /// an inbox. Deadlines run on , the system clock unless a test supplies its own.
+ ///
+ public ActivationInbox(string key, Action report, TimeProvider? time = null)
+ {
+ ArgumentNullException.ThrowIfNull(report);
+ _report = report;
+ _time = time ?? TimeProvider.System;
+ _pipe = new NamedPipeServerStream(key, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+ _listener = ListenAsync();
+ }
+
+
+
+ /// Completes when the listener has stopped after .
+ public Task Completion => _listener;
+
+
+
+ ///
+ /// Connects the inbox to the reader once its windows can open documents, delivering first the
+ /// requests that arrived while it was starting. The dispatcher returns false when it cannot
+ /// take a request.
+ ///
+ public void Attach(Func dispatch)
+ {
+ ArgumentNullException.ThrowIfNull(dispatch);
+ lock(_gate)
+ {
+ _dispatch = dispatch;
+ while(_pending.TryDequeue(out ActivationRequest? request))
+ {
+ if(!dispatch(request))
+ {
+ _report(new IOException("The reader could not accept activation."));
+ }
+ }
+ }
+ }
+
+
+
+ /// Sends one request to the running reader and returns whether it accepted it.
+ public static Task SendAsync(string key, ActivationRequest request, CancellationToken cancellationToken)
+ => SendAsync(key, request, TimeProvider.System, cancellationToken);
+
+
+
+ /// Sends one request with its deadlines on the given clock. Throws when no reader answers in time or the request is too large to send.
+ internal static async Task SendAsync(string key, ActivationRequest request, TimeProvider time, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ request.Validate();
+ byte[] payload = JsonSerializer.SerializeToUtf8Bytes(request, ActivationJsonContext.Default.ActivationRequest);
+ if(payload.Length > ActivationRequest.MaximumPayloadBytes)
+ {
+ throw new ArgumentException("The activation payload is too large.", nameof(request));
+ }
+ await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+ using(var deadline = new CancellationTokenSource(ConnectDeadline, time))
+ using(var connecting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token))
+ {
+ await client.ConnectAsync(connecting.Token);
+ }
+ using var exchangeDeadline = new CancellationTokenSource(ExchangeDeadline, time);
+ using var exchanging = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, exchangeDeadline.Token);
+ byte[] length = new byte[4];
+ BinaryPrimitives.WriteInt32LittleEndian(length, payload.Length);
+ await client.WriteAsync(length, exchanging.Token);
+ await client.WriteAsync(payload, exchanging.Token);
+ await client.FlushAsync(exchanging.Token);
+ byte[] response = new byte[1];
+ await client.ReadExactlyAsync(response, exchanging.Token);
+ return response[0] == 1;
+ }
+
+
+
+ /// Serves one connection at a time until disposed; a peer that disconnects, stalls or sends an invalid request is reported and does not stop the next one.
+ private async Task ListenAsync()
+ {
+ while(!_closed.IsCancellationRequested)
+ {
+ bool connected = false;
+ try
+ {
+ await _pipe.WaitForConnectionAsync(_closed.Token);
+ connected = true;
+ using var deadline = new CancellationTokenSource(ExchangeDeadline, _time);
+ using var exchanging = CancellationTokenSource.CreateLinkedTokenSource(_closed.Token, deadline.Token);
+ bool accepted = Accept(await ReadRequestAsync(exchanging.Token));
+ await _pipe.WriteAsync(new byte[] { accepted ? (byte)1 : (byte)0 }, exchanging.Token);
+ await _pipe.FlushAsync(exchanging.Token);
+ }
+ catch(Exception exception) when(exception is IOException or InvalidDataException or JsonException or OperationCanceledException or ObjectDisposedException)
+ {
+ if(!_closed.IsCancellationRequested)
+ {
+ _report(exception);
+ }
+ }
+ finally
+ {
+ // Under the lock Dispose takes, so the pipe cannot be disposed between the check and the call.
+ lock(_gate)
+ {
+ if(connected && !_closed.IsCancellationRequested)
+ {
+ // A failed read clears IsConnected, but the connection still has to be released before the next peer.
+ _pipe.Disconnect();
+ }
+ }
+ }
+ }
+ }
+
+
+
+ /// Reads one bounded, length-prefixed request and checks it against the schema and the path rules.
+ private async Task ReadRequestAsync(CancellationToken cancellationToken)
+ {
+ byte[] length = new byte[4];
+ await _pipe.ReadExactlyAsync(length, cancellationToken);
+ int size = BinaryPrimitives.ReadInt32LittleEndian(length);
+ if(size <= 0 || size > ActivationRequest.MaximumPayloadBytes)
+ {
+ throw new InvalidDataException("Invalid activation length.");
+ }
+ byte[] payload = new byte[size];
+ await _pipe.ReadExactlyAsync(payload, cancellationToken);
+ ActivationRequest request = JsonSerializer.Deserialize(payload, ActivationJsonContext.Default.ActivationRequest) ?? throw new InvalidDataException("Missing activation request.");
+ request.Validate();
+ return request;
+ }
+
+
+
+ /// Hands the request to the reader, or holds it until the reader attaches; false when neither is possible.
+ private bool Accept(ActivationRequest request)
+ {
+ lock(_gate)
+ {
+ if(_dispatch is not null)
+ {
+ return _dispatch(request);
+ }
+ if(_pending.Count >= MaximumPendingRequests)
+ {
+ return false;
+ }
+ _pending.Enqueue(request);
+ return true;
+ }
+ }
+
+
+
+ /// Stops listening and releases the pipe name.
+ public void Dispose()
+ {
+ if(Interlocked.Exchange(ref _disposed, 1) != 0)
+ {
+ return;
+ }
+ lock(_gate)
+ {
+ _closed.Cancel();
+ _pipe.Dispose();
+ }
+ _closed.Dispose();
+ }
+}
diff --git a/src/MarkdownViewr.Windows/ActivationJsonContext.cs b/src/MarkdownViewr.Windows.Core/Activation/ActivationJsonContext.cs
similarity index 61%
rename from src/MarkdownViewr.Windows/ActivationJsonContext.cs
rename to src/MarkdownViewr.Windows.Core/Activation/ActivationJsonContext.cs
index 380f3e2..6566f17 100644
--- a/src/MarkdownViewr.Windows/ActivationJsonContext.cs
+++ b/src/MarkdownViewr.Windows.Core/Activation/ActivationJsonContext.cs
@@ -6,10 +6,9 @@
using System.Text.Json.Serialization;
-namespace MarkdownViewr.Windows;
+namespace MarkdownViewr.Windows.Core.Activation;
-
-/// Activation JSON is a fixed generated schema with unknown fields rejected, rather than a general command protocol.
+/// The fixed schema of an activation message; a field it does not define is an error, so the message cannot grow into a command protocol.
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)]
[JsonSerializable(typeof(ActivationRequest))]
internal sealed partial class ActivationJsonContext : JsonSerializerContext;
diff --git a/src/MarkdownViewr.Windows.Core/Activation/ActivationRequest.cs b/src/MarkdownViewr.Windows.Core/Activation/ActivationRequest.cs
new file mode 100644
index 0000000..81e3d64
--- /dev/null
+++ b/src/MarkdownViewr.Windows.Core/Activation/ActivationRequest.cs
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2026 James Maes
+ * SPDX-License-Identifier: MIT
+ */
+
+using System.Security.Cryptography;
+using System.Text;
+
+
+namespace MarkdownViewr.Windows.Core.Activation;
+
+///
+/// The documents one launch asks to open: bounded, literal, absolute paths. This is the whole
+/// message a second launch sends to the running reader; it carries no commands.
+///
+public sealed record ActivationRequest(string[] Paths, bool HasInvalidPath = false)
+{
+ /// The most documents one launch may open.
+ public const int MaximumPaths = 128;
+
+ /// The largest serialized request the running reader accepts.
+ public const int MaximumPayloadBytes = 1024 * 1024;
+
+ private const int MaximumPathCharacters = 32767;
+
+
+
+ ///
+ /// Resolves each argument against the launching process's folder, so a relative path keeps
+ /// its meaning when the running reader has a different working folder. An argument that is
+ /// not a usable path is dropped and flagged; the usable ones still open.
+ ///
+ public static ActivationRequest Create(IEnumerable paths, string workingDirectory)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ string[] candidates = paths.Take(MaximumPaths + 1).ToArray();
+ if(candidates.Length > MaximumPaths)
+ {
+ throw new ArgumentException("Too many document paths.", nameof(paths));
+ }
+ string?[] resolved = candidates.Select(path => Resolve(path, workingDirectory)).ToArray();
+ return new ActivationRequest(resolved.OfType().Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), resolved.Contains(null));
+ }
+
+
+
+ /// Rejects a request that did not come from : a relative, empty, overlong or control-bearing path, or too many of them.
+ public void Validate()
+ {
+ if(Paths is null || Paths.Length > MaximumPaths || !Paths.All(IsUsable))
+ {
+ throw new InvalidDataException("Invalid document activation paths.");
+ }
+ }
+
+
+
+ ///
+ /// Names the single-instance group: one per executable folder and data folder, so stable and
+ /// development editions, loose builds and test profiles never forward to each other.
+ ///
+ public static string InstanceKey(string executableDirectory, string dataDirectory)
+ {
+ string identity = Path.TrimEndingDirectorySeparator(Path.GetFullPath(executableDirectory)).ToUpperInvariant() + "\0" + Path.TrimEndingDirectorySeparator(Path.GetFullPath(dataDirectory)).ToUpperInvariant();
+ return "k22-markdownviewr-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
+ }
+
+
+
+ /// The absolute form of one argument, or null when it cannot name a document.
+ private static string? Resolve(string? path, string workingDirectory)
+ {
+ if(string.IsNullOrWhiteSpace(path))
+ {
+ return null;
+ }
+ try
+ {
+ string full = Path.GetFullPath(path, workingDirectory);
+ return IsUsable(full) ? full : null;
+ }
+ catch(Exception exception) when(exception is ArgumentException or NotSupportedException or IOException)
+ {
+ return null;
+ }
+ }
+
+
+
+ /// A path the reader may be asked to open: absolute, bounded, and free of control characters.
+ private static bool IsUsable(string? path)
+ => !string.IsNullOrEmpty(path) && path.Length <= MaximumPathCharacters && Path.IsPathFullyQualified(path) && !path.Any(char.IsControl);
+}
diff --git a/src/MarkdownViewr.Windows/ActivationInbox.cs b/src/MarkdownViewr.Windows/ActivationInbox.cs
deleted file mode 100644
index 78b35d6..0000000
--- a/src/MarkdownViewr.Windows/ActivationInbox.cs
+++ /dev/null
@@ -1,167 +0,0 @@
-/*
- * Copyright (c) 2026 James Maes
- * SPDX-License-Identifier: MIT
- */
-
-using System.Buffers.Binary;
-using System.IO.Pipes;
-using System.Text.Json;
-
-
-namespace MarkdownViewr.Windows;
-
-/// Bounded current-user IPC delivers sender-canonicalized document paths to the SDK's chosen primary instance.
-internal sealed class ActivationInbox : IDisposable
-{
- private readonly Lock _gate = new();
- private readonly Queue _pending = new();
- private readonly CancellationTokenSource _closed = new();
- private readonly NamedPipeServerStream _pipe;
- private readonly Action _report;
- private readonly Task _listener;
- private Func? _dispatch;
- private int _disposed;
-
-
-
- /// The primary owns the pipe before XAML starts; only the same Windows user can connect.
- internal ActivationInbox(string key, Action report)
- {
- _report = report;
- _pipe = new NamedPipeServerStream(key, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
- _listener = ListenAsync();
- }
-
-
-
- /// The owned listener completes on disposal; tests and shutdown diagnostics can observe failures instead of losing a task.
- internal Task Completion => _listener;
-
-
-
- /// Attaches the native UI dispatcher after launch, delivering bounded requests received while XAML initialized.
- internal void Attach(Func dispatch)
- {
- lock(_gate)
- {
- _dispatch = dispatch;
- while(_pending.TryDequeue(out ActivationRequest? request))
- {
- if(!dispatch(request))
- {
- _report(new IOException("The reader could not accept activation."));
- }
- }
- }
- }
-
-
-
- /// Sends one fixed request with a bounded connection/acknowledgement wait and preserves the real server acknowledgement.
- internal static async Task SendAsync(string key, ActivationRequest request, CancellationToken cancellationToken)
- {
- request.Validate();
- byte[] payload = JsonSerializer.SerializeToUtf8Bytes(request, ActivationJsonContext.Default.ActivationRequest);
- if(payload.Length > ActivationRequest.MaximumPayloadBytes)
- {
- throw new ArgumentException("The activation payload is too large.", nameof(request));
- }
- using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
- timeout.CancelAfter(TimeSpan.FromSeconds(10));
- await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
- await client.ConnectAsync(timeout.Token);
- byte[] length = new byte[4];
- BinaryPrimitives.WriteInt32LittleEndian(length, payload.Length);
- await client.WriteAsync(length, timeout.Token);
- await client.WriteAsync(payload, timeout.Token);
- await client.FlushAsync(timeout.Token);
- byte[] response = new byte[1];
- await client.ReadExactlyAsync(response, timeout.Token);
- return response[0] == 1;
- }
-
-
-
- /// A disconnected or malformed peer cannot stop later activations; each request has a bounded read deadline.
- private async Task ListenAsync()
- {
- while(!_closed.IsCancellationRequested)
- {
- bool connected = false;
- try
- {
- await _pipe.WaitForConnectionAsync(_closed.Token);
- connected = true;
- using var timeout = CancellationTokenSource.CreateLinkedTokenSource(_closed.Token);
- timeout.CancelAfter(TimeSpan.FromSeconds(10));
- byte[] length = new byte[4];
- await _pipe.ReadExactlyAsync(length, timeout.Token);
- int size = BinaryPrimitives.ReadInt32LittleEndian(length);
- if(size <= 0 || size > ActivationRequest.MaximumPayloadBytes)
- {
- throw new InvalidDataException("Invalid activation length.");
- }
- byte[] payload = new byte[size];
- await _pipe.ReadExactlyAsync(payload, timeout.Token);
- ActivationRequest request = JsonSerializer.Deserialize(payload, ActivationJsonContext.Default.ActivationRequest) ?? throw new InvalidDataException("Missing activation request.");
- request.Validate();
- bool accepted;
- lock(_gate)
- {
- accepted = _dispatch is null ? QueueBeforeLaunch(request) : _dispatch(request);
- }
- await _pipe.WriteAsync(new byte[] { accepted ? (byte)1 : (byte)0 }, timeout.Token);
- await _pipe.FlushAsync(timeout.Token);
- }
- catch(Exception exception) when(exception is IOException or InvalidDataException or JsonException or ArgumentException or OperationCanceledException or ObjectDisposedException)
- {
- if(!_closed.IsCancellationRequested)
- {
- _report(exception);
- }
- }
- finally
- {
- if(connected && !_closed.IsCancellationRequested)
- {
- // A broken read clears IsConnected, but the owned native connection still requires Disconnect.
- try
- {
- _pipe.Disconnect();
- }
- catch(ObjectDisposedException) when(_closed.IsCancellationRequested)
- {
- // Shutdown may release the handle after the cancellation check.
- }
- }
- }
- }
- }
-
-
-
- /// A startup burst can queue at most16 requests; the peer is told to retry if the native reader is busy.
- private bool QueueBeforeLaunch(ActivationRequest request)
- {
- if(_pending.Count >= 16)
- {
- return false;
- }
- _pending.Enqueue(request);
- return true;
- }
-
-
-
- /// Application shutdown cancels pending connections and releases the owned current-user pipe.
- public void Dispose()
- {
- if(Interlocked.Exchange(ref _disposed, 1) != 0)
- {
- return;
- }
- _closed.Cancel();
- _pipe.Dispose();
- _closed.Dispose();
- }
-}
diff --git a/src/MarkdownViewr.Windows/ActivationRequest.cs b/src/MarkdownViewr.Windows/ActivationRequest.cs
deleted file mode 100644
index 31682fc..0000000
--- a/src/MarkdownViewr.Windows/ActivationRequest.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * Copyright (c) 2026 James Maes
- * SPDX-License-Identifier: MIT
- */
-
-using System.Security.Cryptography;
-using System.Text;
-
-
-namespace MarkdownViewr.Windows;
-
-/// Only bounded literal absolute document paths cross the current-user activation pipe; no native commands are accepted.
-internal sealed record ActivationRequest(string[] Paths, bool HasInvalidPath = false)
-{
- internal const int MaximumPaths = 128;
- internal const int MaximumPayloadBytes = 1024 * 1024;
-
-
-
- /// Canonicalizes at the sender, preserving relative-path meaning even when the existing reader has a different working directory.
- internal static ActivationRequest Create(IEnumerable paths, string workingDirectory)
- {
- string[] candidates = paths.Take(MaximumPaths + 1).ToArray();
- if(candidates.Length > MaximumPaths)
- {
- throw new ArgumentException("Too many document paths.", nameof(paths));
- }
- var request = new ActivationRequest(candidates.Select(path => Path.GetFullPath(path, workingDirectory)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray());
- request.Validate();
- return request;
- }
-
-
-
- /// Malformed, oversized or relative pipe input cannot become an unbounded launch or be interpreted using the receiver's directory.
- internal void Validate()
- {
- if(Paths is null || Paths.Length > MaximumPaths || Paths.Any(path => string.IsNullOrEmpty(path) || path.Length > 32767 || !Path.IsPathFullyQualified(path) || path.Any(char.IsControl)))
- {
- throw new InvalidDataException("Invalid document activation paths.");
- }
- }
-
-
-
- /// Profile and executable directories define distinct stable/dev, development-build and review/test instance identities.
- internal static string InstanceKey(string executableDirectory, string dataDirectory)
- {
- string identity = Path.TrimEndingDirectorySeparator(Path.GetFullPath(executableDirectory)).ToUpperInvariant() + "\0" + Path.TrimEndingDirectorySeparator(Path.GetFullPath(dataDirectory)).ToUpperInvariant();
- return "k22-markdownviewr-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
- }
-}
diff --git a/src/MarkdownViewr.Windows/App.Activation.cs b/src/MarkdownViewr.Windows/App.Activation.cs
index e46812e..89b77e3 100644
--- a/src/MarkdownViewr.Windows/App.Activation.cs
+++ b/src/MarkdownViewr.Windows/App.Activation.cs
@@ -3,6 +3,8 @@
* SPDX-License-Identifier: MIT
*/
+using MarkdownViewr.Windows.Core.Activation;
+
namespace MarkdownViewr.Windows;
diff --git a/src/MarkdownViewr.Windows/DesktopActivation.cs b/src/MarkdownViewr.Windows/DesktopActivation.cs
index 64161a4..046e9e9 100644
--- a/src/MarkdownViewr.Windows/DesktopActivation.cs
+++ b/src/MarkdownViewr.Windows/DesktopActivation.cs
@@ -4,6 +4,7 @@
*/
using System.Runtime.InteropServices;
+using MarkdownViewr.Windows.Core.Activation;
using Microsoft.Windows.AppLifecycle;
using Windows.ApplicationModel.Activation;
diff --git a/src/MarkdownViewr.Windows/Program.cs b/src/MarkdownViewr.Windows/Program.cs
index 2aba1d2..6eac9cf 100644
--- a/src/MarkdownViewr.Windows/Program.cs
+++ b/src/MarkdownViewr.Windows/Program.cs
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
+using MarkdownViewr.Windows.Core.Activation;
using Microsoft.UI.Dispatching;
using Microsoft.Windows.AppLifecycle;
using Velopack;
@@ -16,17 +17,21 @@ namespace MarkdownViewr.Windows;
///
public static class Program
{
- /// The primary's bounded current-user inbox is attached by App only after the native reader initializes.
+ /// The running reader's inbox; null in a process that could not create one or that opened its documents itself.
internal static ActivationInbox? Inbox { get; private set; }
- /// The initial canonical request preserves sender-relative paths and SDK rich file activation.
+ /// The documents this launch was asked to open, resolved against its own working folder.
internal static ActivationRequest InitialRequest { get; private set; } = new([]);
- /// Runs install hooks before XAML, chooses a profile/install-specific primary, then starts or forwards the native document request.
+ ///
+ /// Runs the installer hooks, then either hands this launch's documents to the reader that is
+ /// already running or becomes a reader. A launch whose documents the running reader could not
+ /// take opens them itself, so a double-clicked file always opens.
+ ///
[STAThread]
private static int Main()
{
@@ -39,12 +44,12 @@ public static class Program
WinRT.ComWrappersSupport.InitializeComWrappers();
InitialRequest = DesktopActivation.ReadInitialRequest();
string key = ActivationRequest.InstanceKey(AppContext.BaseDirectory, Path.GetDirectoryName(AppIdentity.Paths.SettingsFile)!);
- var primary = AppInstance.FindOrRegisterForKey(key);
- if(!primary.IsCurrent)
+ var instance = AppInstance.FindOrRegisterForKey(key);
+ if(!instance.IsCurrent && Forward(key, instance.ProcessId))
{
- return ForwardToPrimary(key, primary.ProcessId);
+ return 0;
}
- using var inbox = new ActivationInbox(key, DesktopActivation.ReportFailure);
+ ActivationInbox? inbox = instance.IsCurrent ? OpenInbox(key) : null;
Inbox = inbox;
try
{
@@ -57,30 +62,54 @@ public static class Program
finally
{
Inbox = null;
- primary.UnregisterKey();
+ // The pipe name is released before the instance key, so the next reader to register can create its own inbox.
+ inbox?.Dispose();
+ if(instance.IsCurrent)
+ {
+ instance.UnregisterKey();
+ }
}
return 0;
}
- /// Current-user .NET pipe delivery avoids the SDK STA redirection deadlock and resolves relative paths before crossing processes.
- private static int ForwardToPrimary(string key, uint processId)
+ ///
+ /// Creates the inbox, or returns null when the pipe name is taken. The reader then runs
+ /// without one: later launches find nobody listening and open their documents themselves.
+ ///
+ private static ActivationInbox? OpenInbox(string key)
+ {
+ try
+ {
+ return new ActivationInbox(key, DesktopActivation.ReportFailure);
+ }
+ catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
+ {
+ DesktopActivation.ReportFailure(exception);
+ return null;
+ }
+ }
+
+
+
+ /// Hands this launch's documents to the running reader; false when it did not take them.
+ private static bool Forward(string key, uint processId)
{
try
{
DesktopActivation.AllowPrimaryForeground(processId);
- bool delivered = ActivationInbox.SendAsync(key, InitialRequest, CancellationToken.None).GetAwaiter().GetResult();
- if(!delivered)
+ // A plain pipe is used because the SDK's own redirection deadlocks an STA entry point.
+ if(ActivationInbox.SendAsync(key, InitialRequest, CancellationToken.None).GetAwaiter().GetResult())
{
- DesktopActivation.ReportFailure(new IOException("The running reader is busy."));
+ return true;
}
- return delivered ? 0 : 1;
+ DesktopActivation.ReportFailure(new IOException("The running reader is busy."));
}
catch(Exception exception) when(exception is IOException or ArgumentException or InvalidDataException or OperationCanceledException or UnauthorizedAccessException)
{
DesktopActivation.ReportFailure(exception);
- return 1;
}
+ return false;
}
}
diff --git a/tests/MarkdownViewr.Windows.UiTests/ActivationFuzzTests.cs b/tests/MarkdownViewr.Windows.Core.Tests/ActivationFuzzTests.cs
similarity index 97%
rename from tests/MarkdownViewr.Windows.UiTests/ActivationFuzzTests.cs
rename to tests/MarkdownViewr.Windows.Core.Tests/ActivationFuzzTests.cs
index a1ddc5b..a1b7979 100644
--- a/tests/MarkdownViewr.Windows.UiTests/ActivationFuzzTests.cs
+++ b/tests/MarkdownViewr.Windows.Core.Tests/ActivationFuzzTests.cs
@@ -6,9 +6,10 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
+using MarkdownViewr.Windows.Core.Activation;
-namespace MarkdownViewr.Windows.UiTests;
+namespace MarkdownViewr.Windows.Core.Tests;
/// Fuzzes the real generated activation schema and path validator without creating a window, pipe, process or profile.
[Trait("Category", "Fuzz")]
diff --git a/tests/MarkdownViewr.Windows.Core.Tests/ActivationInboxTests.cs b/tests/MarkdownViewr.Windows.Core.Tests/ActivationInboxTests.cs
new file mode 100644
index 0000000..f47a860
--- /dev/null
+++ b/tests/MarkdownViewr.Windows.Core.Tests/ActivationInboxTests.cs
@@ -0,0 +1,343 @@
+/*
+ * Copyright (c) 2026 James Maes
+ * SPDX-License-Identifier: MIT
+ */
+
+using System.Buffers.Binary;
+using System.Collections.Concurrent;
+using System.IO.Pipes;
+using System.Text;
+using MarkdownViewr.Windows.Core.Activation;
+
+
+namespace MarkdownViewr.Windows.Core.Tests;
+
+///
+/// Real current-user pipes between a real inbox and real senders. Deadlines run on a manual
+/// clock, so no test waits for one and none can be reached early by a slow machine.
+///
+public sealed class ActivationInboxTests
+{
+ private static readonly TimeSpan s_safetyNet = TimeSpan.FromSeconds(30);
+
+
+
+ /// A request sent while the reader is starting is held until it attaches; afterwards each request gets the reader's own answer.
+ [Fact]
+ public async Task Send_QueuedThenAttached_DeliversOnceAndHonorsAcknowledgement()
+ {
+ string key = NewKey();
+ var received = new ConcurrentQueue();
+ var errors = new ConcurrentQueue();
+ using var inbox = new ActivationInbox(key, errors.Enqueue);
+ var request = ActivationRequest.Create(["sender & café.md"], Path.GetTempPath());
+ using var safety = new CancellationTokenSource(s_safetyNet);
+ Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
+ Assert.Empty(received);
+ inbox.Attach(value =>
+ {
+ received.Enqueue(value);
+ return true;
+ });
+ Assert.Equal(request.Paths, Assert.Single(received).Paths);
+ Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
+ Assert.Equal(2, received.Count);
+ inbox.Attach(_ => false);
+ Assert.False(await ActivationInbox.SendAsync(key, request, safety.Token));
+ Assert.Equal(2, received.Count);
+ Assert.Empty(errors);
+ }
+
+
+
+ /// While the reader is starting it holds a bounded number of requests and refuses the next, which the sender is told.
+ [Fact]
+ public async Task Send_MoreThanTheBoundBeforeTheReaderAttaches_RefusesTheExtraRequest()
+ {
+ string key = NewKey();
+ var received = new ConcurrentQueue();
+ using var inbox = new ActivationInbox(key, _ => { });
+ using var safety = new CancellationTokenSource(s_safetyNet);
+ for(int index = 0; index < ActivationInbox.MaximumPendingRequests; index++)
+ {
+ Assert.True(await ActivationInbox.SendAsync(key, ActivationRequest.Create([$"held{index}.md"], Path.GetTempPath()), safety.Token));
+ }
+ Assert.False(await ActivationInbox.SendAsync(key, ActivationRequest.Create(["one too many.md"], Path.GetTempPath()), safety.Token));
+ inbox.Attach(value =>
+ {
+ received.Enqueue(value);
+ return true;
+ });
+ Assert.Equal(Enumerable.Range(0, ActivationInbox.MaximumPendingRequests).Select(index => $"held{index}.md"), received.Select(request => Path.GetFileName(Assert.Single(request.Paths))), StringComparer.Ordinal);
+ Assert.Equal(16, ActivationInbox.MaximumPendingRequests);
+ }
+
+
+
+ /// A held request the reader then cannot take is reported, not silently dropped.
+ [Fact]
+ public async Task Attach_HeldRequestTheReaderRefuses_IsReported()
+ {
+ string key = NewKey();
+ var errors = new ConcurrentQueue();
+ using var inbox = new ActivationInbox(key, errors.Enqueue);
+ using var safety = new CancellationTokenSource(s_safetyNet);
+ Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
+ inbox.Attach(_ => false);
+ Assert.IsType(Assert.Single(errors));
+ Assert.Throws(() => inbox.Attach(null!));
+ Assert.Throws(() => new ActivationInbox(NewKey(), null!));
+ }
+
+
+
+ ///
+ /// The pipe name belongs to one inbox at a time. A second one fails to start, so its process
+ /// can run without an inbox, and the name is free again once the first is disposed.
+ ///
+ [Fact]
+ public async Task Constructor_NameAlreadyTaken_ThrowsUntilTheOwnerIsDisposed()
+ {
+ string key = NewKey();
+ using var safety = new CancellationTokenSource(s_safetyNet);
+ Task stopped;
+ using(var owner = new ActivationInbox(key, _ => { }))
+ {
+ Assert.ThrowsAny(() => new ActivationInbox(key, _ => { }));
+ stopped = owner.Completion;
+ }
+ await stopped.WaitAsync(safety.Token);
+ using var successor = new ActivationInbox(key, _ => { });
+ successor.Attach(_ => true);
+ Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
+ }
+
+
+
+ /// A frame with a zero, negative or oversized length, an unknown field, a relative path or a null body is rejected; the next valid sender is still served.
+ [Fact]
+ public async Task Listen_MalformedPeers_RejectsAndRecovers()
+ {
+ string key = NewKey();
+ var received = new ConcurrentQueue();
+ var errors = new ConcurrentQueue();
+ using var inbox = new ActivationInbox(key, errors.Enqueue);
+ inbox.Attach(value =>
+ {
+ received.Enqueue(value);
+ return true;
+ });
+ using var safety = new CancellationTokenSource(s_safetyNet);
+ await SendFrame(key, ActivationRequest.MaximumPayloadBytes + 1, [], safety.Token);
+ await SendFrame(key, 0, [], safety.Token);
+ await SendFrame(key, -1, [], safety.Token);
+ foreach(string body in new[] { "{\"Paths\":[],\"Command\":\"execute\"}", "{\"Paths\":[\"relative.md\"]}", "null" })
+ {
+ byte[] payload = Encoding.UTF8.GetBytes(body);
+ await SendFrame(key, payload.Length, payload, safety.Token);
+ }
+ var request = ActivationRequest.Create(["valid.md"], Path.GetTempPath());
+ Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
+ Assert.Equal(request.Paths, Assert.Single(received).Paths);
+ // Each frame is refused for its own reason: the three lengths before any payload is read, then the schema, the path rule and the empty body.
+ Type[] expected = [typeof(InvalidDataException), typeof(InvalidDataException), typeof(InvalidDataException), typeof(System.Text.Json.JsonException), typeof(InvalidDataException), typeof(InvalidDataException)];
+ Assert.Equal(expected, errors.Select(error => error.GetType()));
+ Assert.Equal(1024 * 1024, ActivationRequest.MaximumPayloadBytes);
+ }
+
+
+
+ /// A frame of exactly the largest size is read and accepted.
+ [Fact]
+ public async Task Listen_FrameOfExactlyTheLargestSize_IsAccepted()
+ {
+ string key = NewKey();
+ var received = new ConcurrentQueue();
+ var errors = new ConcurrentQueue();
+ using var inbox = new ActivationInbox(key, errors.Enqueue);
+ inbox.Attach(value =>
+ {
+ received.Enqueue(value);
+ return true;
+ });
+ using var safety = new CancellationTokenSource(s_safetyNet);
+ byte[] payload = new byte[ActivationRequest.MaximumPayloadBytes];
+ Array.Fill(payload, (byte)' ');
+ "{\"Paths\":[\"C:\\\\largest.md\"]}"u8.CopyTo(payload);
+ Assert.Equal(1, await SendFrame(key, payload.Length, payload, safety.Token));
+ Assert.Equal([@"C:\largest.md"], Assert.Single(received).Paths);
+ Assert.Empty(errors);
+ }
+
+
+
+ /// A sender never puts an invalid or oversized request on the pipe.
+ [Fact]
+ public async Task Send_InvalidOrOversizedRequest_IsRefusedBeforeConnecting()
+ {
+ string key = NewKey();
+ using var safety = new CancellationTokenSource(s_safetyNet);
+ await Assert.ThrowsAsync(() => ActivationInbox.SendAsync(key, new ActivationRequest(["relative.md"]), safety.Token));
+ await Assert.ThrowsAsync(() => ActivationInbox.SendAsync(key, null!, safety.Token));
+ string longPath = @"C:\" + new string('a', 9000);
+ var oversized = new ActivationRequest(Enumerable.Range(0, ActivationRequest.MaximumPaths).Select(index => longPath + index.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToArray());
+ oversized.Validate();
+ await Assert.ThrowsAsync(() => ActivationInbox.SendAsync(key, oversized, safety.Token));
+ }
+
+
+
+ /// With no reader listening, a sender gives up at its connect deadline instead of waiting forever.
+ [Fact]
+ public async Task Send_NoReaderListening_GivesUpAtTheConnectDeadline()
+ {
+ var clock = new ManualTimeProvider();
+ using var safety = new CancellationTokenSource(s_safetyNet);
+ Task sending = ActivationInbox.SendAsync(NewKey(), new ActivationRequest([]), clock, safety.Token);
+ await clock.NextTimerAsync(safety.Token);
+ Assert.False(sending.IsCompleted);
+ clock.Elapse();
+ await Assert.ThrowsAnyAsync(() => sending);
+ Assert.False(safety.IsCancellationRequested);
+ Assert.Equal(TimeSpan.FromSeconds(2), ActivationInbox.ConnectDeadline);
+ }
+
+
+
+ /// A peer that connects and sends nothing is dropped at the exchange deadline, and the next sender is served.
+ [Fact]
+ public async Task Listen_PeerThatSendsNothing_IsDroppedAtTheDeadline()
+ {
+ string key = NewKey();
+ var clock = new ManualTimeProvider();
+ var reported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var inbox = new ActivationInbox(key, exception => reported.TrySetResult(exception), clock);
+ inbox.Attach(_ => true);
+ using var safety = new CancellationTokenSource(s_safetyNet);
+ await using(var silent = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
+ {
+ await silent.ConnectAsync(safety.Token);
+ await clock.NextTimerAsync(safety.Token);
+ Assert.False(reported.Task.IsCompleted);
+ clock.Elapse();
+ Assert.IsAssignableFrom(await reported.Task.WaitAsync(safety.Token));
+ }
+ Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
+ Assert.Equal(TimeSpan.FromSeconds(10), ActivationInbox.ExchangeDeadline);
+ }
+
+
+
+ /// A sender that disconnects without a request is reported and does not block the next one; disposing twice is harmless and frees the name.
+ [Fact]
+ public async Task Listen_DisconnectedPeer_RecoversAndReleasesPipe()
+ {
+ string key = NewKey();
+ var errors = new ConcurrentQueue();
+ using var safety = new CancellationTokenSource(s_safetyNet);
+ Task completed;
+ using(var inbox = new ActivationInbox(key, errors.Enqueue))
+ {
+ inbox.Attach(_ => true);
+ await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
+ {
+ await client.ConnectAsync(safety.Token);
+ }
+ Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
+ Assert.Single(errors);
+ completed = inbox.Completion;
+ inbox.Dispose();
+ inbox.Dispose();
+ }
+ await completed.WaitAsync(safety.Token);
+ Assert.Single(errors);
+ using var replacement = new ActivationInbox(key, _ => { });
+ replacement.Attach(_ => true);
+ Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), safety.Token));
+ }
+
+
+
+ /// A peer that has already closed when the reader turns to it is released before the next sender connects.
+ [Fact]
+ public async Task Listen_AlreadyClosedPeer_RecoversAfterReportedDisconnect()
+ {
+ string key = NewKey();
+ var reported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var received = new ConcurrentQueue();
+ var connectionContext = new DeferredConnectionContext();
+ SynchronizationContext? previous = SynchronizationContext.Current;
+ ActivationInbox inbox;
+ try
+ {
+ SynchronizationContext.SetSynchronizationContext(connectionContext);
+ inbox = new ActivationInbox(key, exception => reported.TrySetResult(exception));
+ }
+ finally
+ {
+ SynchronizationContext.SetSynchronizationContext(previous);
+ }
+ using ActivationInbox ownedInbox = inbox;
+ inbox.Attach(value =>
+ {
+ received.Enqueue(value);
+ return true;
+ });
+ using var safety = new CancellationTokenSource(s_safetyNet);
+ await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
+ {
+ await client.ConnectAsync(safety.Token);
+ }
+ (SendOrPostCallback callback, object? state) = await connectionContext.Continuation.Task.WaitAsync(safety.Token);
+ // Resume the listener only after the peer has closed, so its first read finds a broken pipe.
+ var resume = Task.Run(() => callback(state), safety.Token);
+ Assert.IsAssignableFrom(await reported.Task.WaitAsync(safety.Token));
+ var request = ActivationRequest.Create(["after closed peer.md"], Path.GetTempPath());
+ Assert.True(await ActivationInbox.SendAsync(key, request, safety.Token));
+ Assert.Equal(request.Paths, Assert.Single(received).Paths);
+ await resume.WaitAsync(safety.Token);
+ }
+
+
+
+ /// A pipe name no other test or process uses.
+ private static string NewKey() => "k22-mv-tests-" + Guid.NewGuid().ToString("N");
+
+
+
+ /// Sends one raw frame and returns the reader's answer byte, or -1 when the reader closed the connection without answering.
+ private static async Task SendFrame(string key, int declaredLength, byte[] payload, CancellationToken cancellationToken)
+ {
+ await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+ await client.ConnectAsync(cancellationToken);
+ byte[] length = new byte[4];
+ BinaryPrimitives.WriteInt32LittleEndian(length, declaredLength);
+ byte[] response = new byte[1];
+ try
+ {
+ await client.WriteAsync(length, cancellationToken);
+ await client.WriteAsync(payload, cancellationToken);
+ await client.FlushAsync(cancellationToken);
+ return await client.ReadAsync(response, cancellationToken) == 1 ? response[0] : -1;
+ }
+ catch(IOException)
+ {
+ // Windows may report a broken pipe instead of end-of-stream once the reader has rejected the frame.
+ return -1;
+ }
+ }
+
+
+
+ /// Captures the listener's first continuation so a test decides when it resumes.
+ private sealed class DeferredConnectionContext : SynchronizationContext
+ {
+ /// The captured continuation and its state.
+ internal TaskCompletionSource<(SendOrPostCallback Callback, object? State)> Continuation { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+
+
+ /// Holds the first posted continuation instead of running it.
+ public override void Post(SendOrPostCallback callback, object? state) => Continuation.TrySetResult((callback, state));
+ }
+}
diff --git a/tests/MarkdownViewr.Windows.Core.Tests/ActivationRequestTests.cs b/tests/MarkdownViewr.Windows.Core.Tests/ActivationRequestTests.cs
new file mode 100644
index 0000000..96688df
--- /dev/null
+++ b/tests/MarkdownViewr.Windows.Core.Tests/ActivationRequestTests.cs
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2026 James Maes
+ * SPDX-License-Identifier: MIT
+ */
+
+using MarkdownViewr.Windows.Core.Activation;
+
+
+namespace MarkdownViewr.Windows.Core.Tests;
+
+/// What one launch may ask the running reader to open, and which launches share a reader.
+public sealed class ActivationRequestTests
+{
+ /// Relative arguments resolve against the launching folder; shell characters stay literal and duplicate paths collapse without regard to case.
+ [Fact]
+ public void Create_RelativeUnicodeAndDuplicates_UsesSenderDirectory()
+ {
+ string directory = Path.Combine(Path.GetTempPath(), "sender space");
+ var request = ActivationRequest.Create(["notes & café $HOME.md", "./notes & café $HOME.md", "SECOND.MD", "second.md"], directory);
+ Assert.Equal(new[] { Path.Combine(directory, "notes & café $HOME.md"), Path.Combine(directory, "SECOND.MD") }, request.Paths);
+ Assert.False(request.HasInvalidPath);
+ Assert.Empty(ActivationRequest.Create([], directory).Paths);
+ Assert.False(ActivationRequest.Create([], directory).HasInvalidPath);
+ }
+
+
+
+ /// An argument that cannot name a document is dropped and flagged; the documents beside it still open.
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("bad\0name.md")]
+ [InlineData("line\nbreak.md")]
+ public void Create_UnusableArgumentBesideUsableOnes_KeepsTheUsableOnesAndFlagsTheRest(string unusable)
+ {
+ string directory = Path.Combine(Path.GetTempPath(), "sender");
+ var request = ActivationRequest.Create(["first.md", unusable, "second.md"], directory);
+ Assert.Equal(new[] { Path.Combine(directory, "first.md"), Path.Combine(directory, "second.md") }, request.Paths);
+ Assert.True(request.HasInvalidPath);
+ request.Validate();
+ }
+
+
+
+ /// A launch may name at most the bounded number of documents; one more is refused outright.
+ [Fact]
+ public void Create_PathCountAtAndPastTheBound_IsBoundedExactly()
+ {
+ string directory = Path.GetTempPath();
+ IEnumerable names = Enumerable.Range(0, ActivationRequest.MaximumPaths + 1).Select(index => $"document{index}.md");
+ Assert.Equal(ActivationRequest.MaximumPaths, ActivationRequest.Create(names.Take(ActivationRequest.MaximumPaths), directory).Paths.Length);
+ Assert.Throws(() => ActivationRequest.Create(names, directory));
+ Assert.Throws(() => ActivationRequest.Create(null!, directory));
+ Assert.Equal(128, ActivationRequest.MaximumPaths);
+ }
+
+
+
+ /// A request that did not come from Create is refused: relative, empty, control-bearing, overlong or too many paths.
+ [Fact]
+ public void Validate_InvalidPaths_RejectsMalformedAndUnboundedRequests()
+ {
+ Assert.Throws(() => new ActivationRequest(null!).Validate());
+ Assert.Throws(() => new ActivationRequest(["relative.md"]).Validate());
+ Assert.Throws(() => new ActivationRequest([""]).Validate());
+ Assert.Throws(() => new ActivationRequest([null!]).Validate());
+ Assert.Throws(() => new ActivationRequest(["C:\\bad\nfile.md"]).Validate());
+ Assert.Throws(() => new ActivationRequest(Enumerable.Repeat(@"C:\one.md", ActivationRequest.MaximumPaths + 1).ToArray()).Validate());
+ new ActivationRequest(Enumerable.Repeat(@"C:\one.md", ActivationRequest.MaximumPaths).ToArray()).Validate();
+ }
+
+
+
+ /// A path is accepted up to the Windows limit and refused one character past it.
+ [Fact]
+ public void Validate_PathAtAndPastTheWindowsLimit_IsBoundedExactly()
+ {
+ string atLimit = @"C:\" + new string('a', 32767 - 3);
+ Assert.Equal(32767, atLimit.Length);
+ new ActivationRequest([atLimit]).Validate();
+ Assert.Throws(() => new ActivationRequest([atLimit + "a"]).Validate());
+ }
+
+
+
+ ///
+ /// One reader serves one executable folder and one data folder. Spelling does not matter:
+ /// case and a trailing separator name the same folders. Any other folder is another reader,
+ /// even one whose name has the same length.
+ ///
+ [Fact]
+ public void InstanceKey_ProfilesAndInstallations_IsolatesBoundaries()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "viewer");
+ string data = Path.Combine(Path.GetTempPath(), "profile");
+ string key = ActivationRequest.InstanceKey(root, data);
+ Assert.Equal(key, ActivationRequest.InstanceKey(root + Path.DirectorySeparatorChar, data.ToUpperInvariant()));
+ Assert.Equal(key, ActivationRequest.InstanceKey(root.ToUpperInvariant(), data + Path.DirectorySeparatorChar));
+ Assert.NotEqual(key, ActivationRequest.InstanceKey(root + "-dev", data), StringComparer.Ordinal);
+ Assert.NotEqual(key, ActivationRequest.InstanceKey(root, data + "-review"), StringComparer.Ordinal);
+ Assert.NotEqual(key, ActivationRequest.InstanceKey(root, Path.Combine(Path.GetTempPath(), "profilf")), StringComparer.Ordinal);
+ Assert.NotEqual(key, ActivationRequest.InstanceKey(data, root), StringComparer.Ordinal);
+ Assert.Matches("^k22-markdownviewr-[0-9A-F]{64}$", key);
+ }
+}
diff --git a/tests/MarkdownViewr.Windows.Core.Tests/ManualTimeProvider.cs b/tests/MarkdownViewr.Windows.Core.Tests/ManualTimeProvider.cs
new file mode 100644
index 0000000..0a6b220
--- /dev/null
+++ b/tests/MarkdownViewr.Windows.Core.Tests/ManualTimeProvider.cs
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2026 James Maes
+ * SPDX-License-Identifier: MIT
+ */
+
+
+namespace MarkdownViewr.Windows.Core.Tests;
+
+///
+/// A clock whose timers fire only when a test says so, so a deadline can be reached without
+/// waiting for it and can never be reached by a slow machine.
+///
+internal sealed class ManualTimeProvider : TimeProvider
+{
+ private readonly Lock _gate = new();
+ private readonly List _timers = [];
+ private TaskCompletionSource _created = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+
+
+ /// Records the timer instead of scheduling it.
+ public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
+ {
+ var timer = new ManualTimer(callback, state);
+ lock(_gate)
+ {
+ _timers.Add(timer);
+ _created.TrySetResult();
+ }
+ return timer;
+ }
+
+
+
+ /// Completes once code under test has created a timer since the last call.
+ public Task NextTimerAsync(CancellationToken cancellationToken)
+ {
+ Task created;
+ lock(_gate)
+ {
+ created = _created.Task;
+ if(created.IsCompleted)
+ {
+ _created = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ }
+ }
+ return created.WaitAsync(cancellationToken);
+ }
+
+
+
+ /// Fires every timer created so far that has not been disposed, as if each deadline had passed.
+ public void Elapse()
+ {
+ ManualTimer[] pending;
+ lock(_gate)
+ {
+ pending = _timers.ToArray();
+ _timers.Clear();
+ }
+ foreach(ManualTimer timer in pending)
+ {
+ timer.Fire();
+ }
+ }
+
+
+
+ /// A timer that runs its callback once, on request, unless it was disposed first.
+ private sealed class ManualTimer(TimerCallback callback, object? state) : ITimer
+ {
+ private int _disposed;
+
+
+
+ /// Runs the callback unless the owner has already let go of the timer.
+ public void Fire()
+ {
+ if(Volatile.Read(ref _disposed) == 0)
+ {
+ callback(state);
+ }
+ }
+
+
+
+ /// Rescheduling is accepted and ignored: only fires a timer.
+ public bool Change(TimeSpan dueTime, TimeSpan period) => true;
+
+
+
+ /// Marks the timer dead so a later skips it.
+ public void Dispose() => Interlocked.Exchange(ref _disposed, 1);
+
+
+
+ /// Asynchronous disposal has nothing to wait for.
+ public ValueTask DisposeAsync()
+ {
+ Dispose();
+ return ValueTask.CompletedTask;
+ }
+ }
+}
diff --git a/tests/MarkdownViewr.Windows.UiTests/ActivationInboxTests.cs b/tests/MarkdownViewr.Windows.UiTests/ActivationInboxTests.cs
deleted file mode 100644
index 02ae6df..0000000
--- a/tests/MarkdownViewr.Windows.UiTests/ActivationInboxTests.cs
+++ /dev/null
@@ -1,164 +0,0 @@
-/*
- * Copyright (c) 2026 James Maes
- * SPDX-License-Identifier: MIT
- */
-
-using System.Buffers.Binary;
-using System.Collections.Concurrent;
-using System.IO.Pipes;
-using System.Text;
-
-
-namespace MarkdownViewr.Windows.UiTests;
-
-/// Real current-user pipes prove bounded native delivery without taking desktop input or launching an app.
-public sealed class ActivationInboxTests
-{
- /// A startup request survives until a reader dispatcher attaches; rejected dispatch returns a negative acknowledgement.
- [Fact]
- public async Task Send_QueuedThenAttached_DeliversOnceAndHonorsAcknowledgement()
- {
- string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
- var received = new ConcurrentQueue();
- var errors = new ConcurrentQueue();
- using var inbox = new ActivationInbox(key, errors.Enqueue);
- var request = ActivationRequest.Create(["sender & café.md"], Path.GetTempPath());
- using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
- Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
- Assert.Empty(received);
- inbox.Attach(value => { received.Enqueue(value); return true; });
- Assert.Equal(request.Paths, Assert.Single(received).Paths);
- Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
- Assert.Equal(2, received.Count);
- inbox.Attach(_ => false);
- Assert.False(await ActivationInbox.SendAsync(key, request, deadline.Token));
- Assert.Equal(2, received.Count);
- Assert.Empty(errors);
- }
-
-
-
- /// Oversized frames, unknown JSON fields and relative pipe paths are rejected, then a real valid peer still connects.
- [Fact]
- public async Task Listen_MalformedPeers_RejectsAndRecovers()
- {
- string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
- var received = new ConcurrentQueue();
- var errors = new ConcurrentQueue();
- using var inbox = new ActivationInbox(key, errors.Enqueue);
- inbox.Attach(value => { received.Enqueue(value); return true; });
- using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20));
- await SendMalformed(key, ActivationRequest.MaximumPayloadBytes + 1, [], deadline.Token);
- byte[] unknown = Encoding.UTF8.GetBytes("{\"Paths\":[],\"Command\":\"execute\"}");
- await SendMalformed(key, unknown.Length, unknown, deadline.Token);
- byte[] relative = Encoding.UTF8.GetBytes("{\"Paths\":[\"relative.md\"]}");
- await SendMalformed(key, relative.Length, relative, deadline.Token);
- var request = ActivationRequest.Create(["valid.md"], Path.GetTempPath());
- Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
- Assert.Equal(request.Paths, Assert.Single(received).Paths);
- Assert.Equal(3, errors.Count);
- }
-
-
-
- /// A disconnected sender never blocks the next activation and shutdown releases the pipe without leaking an owned connection.
- [Fact]
- public async Task Listen_DisconnectedPeer_RecoversAndReleasesPipe()
- {
- string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
- var errors = new ConcurrentQueue();
- using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
- Task completed;
- using(var inbox = new ActivationInbox(key, errors.Enqueue))
- {
- inbox.Attach(_ => true);
- await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
- {
- await client.ConnectAsync(deadline.Token);
- }
- Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), deadline.Token));
- Assert.Single(errors);
- completed = inbox.Completion;
- inbox.Dispose();
- inbox.Dispose();
- }
- await completed.WaitAsync(deadline.Token);
- using var replacement = new ActivationInbox(key, _ => { });
- replacement.Attach(_ => true);
- Assert.True(await ActivationInbox.SendAsync(key, new ActivationRequest([]), deadline.Token));
- }
-
-
-
- /// An already-closed peer must release its native connection before another real activation.
- [Fact]
- public async Task Listen_AlreadyClosedPeer_RecoversAfterReportedDisconnect()
- {
- string key = "k22-mv-tests-" + Guid.NewGuid().ToString("N");
- var reported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var received = new ConcurrentQueue();
- var connectionContext = new DeferredConnectionContext();
- SynchronizationContext? previous = SynchronizationContext.Current;
- ActivationInbox inbox;
- try
- {
- SynchronizationContext.SetSynchronizationContext(connectionContext);
- inbox = new ActivationInbox(key, exception => reported.TrySetResult(exception));
- }
- finally
- {
- SynchronizationContext.SetSynchronizationContext(previous);
- }
- using ActivationInbox ownedInbox = inbox;
- inbox.Attach(value => { received.Enqueue(value); return true; });
- using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(15));
- await using(var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly))
- {
- await client.ConnectAsync(deadline.Token);
- }
- (SendOrPostCallback callback, object? state) = await connectionContext.Continuation.Task.WaitAsync(deadline.Token);
- // Resume the real server after the real peer closes, exercising the synchronous broken-read path.
- var resume = Task.Run(() => callback(state), deadline.Token);
- Assert.IsAssignableFrom(await reported.Task.WaitAsync(deadline.Token));
- var request = ActivationRequest.Create(["after closed peer.md"], Path.GetTempPath());
- Assert.True(await ActivationInbox.SendAsync(key, request, deadline.Token));
- Assert.Equal(request.Paths, Assert.Single(received).Paths);
- await resume.WaitAsync(deadline.Token);
- }
-
-
-
- /// Defers only the first real server continuation, making the already-closed native peer schedule reproducible.
- private sealed class DeferredConnectionContext : SynchronizationContext
- {
- internal TaskCompletionSource<(SendOrPostCallback Callback, object? State)> Continuation { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
-
-
-
- /// Captures the actual asynchronous continuation without changing pipe bytes, handles or acknowledgements.
- public override void Post(SendOrPostCallback callback, object? state) => Continuation.TrySetResult((callback, state));
- }
-
-
-
- /// Sends an intentionally invalid frame and waits for the actual server to close it before starting the next peer.
- private static async Task SendMalformed(string key, int declaredLength, byte[] payload, CancellationToken cancellationToken)
- {
- await using var client = new NamedPipeClientStream(".", key, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
- await client.ConnectAsync(cancellationToken);
- byte[] length = new byte[4];
- BinaryPrimitives.WriteInt32LittleEndian(length, declaredLength);
- await client.WriteAsync(length, cancellationToken);
- await client.WriteAsync(payload, cancellationToken);
- await client.FlushAsync(cancellationToken);
- byte[] response = new byte[1];
- try
- {
- Assert.Equal(0, await client.ReadAsync(response, cancellationToken));
- }
- catch(IOException)
- {
- // Windows may report a broken pipe rather than EOF after the server rejects the malformed frame.
- }
- }
-}
diff --git a/tests/MarkdownViewr.Windows.UiTests/ActivationRequestTests.cs b/tests/MarkdownViewr.Windows.UiTests/ActivationRequestTests.cs
deleted file mode 100644
index f084576..0000000
--- a/tests/MarkdownViewr.Windows.UiTests/ActivationRequestTests.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * Copyright (c) 2026 James Maes
- * SPDX-License-Identifier: MIT
- */
-
-
-namespace MarkdownViewr.Windows.UiTests;
-
-/// Sender-relative paths, Unicode, duplicate launches and stable/dev/profile isolation retain their actual meanings.
-public sealed class ActivationRequestTests
-{
- /// Canonicalization belongs to the sender, including literal shell characters and case-insensitive duplicate paths.
- [Fact]
- public void Create_RelativeUnicodeAndDuplicates_UsesSenderDirectory()
- {
- string directory = Path.Combine(Path.GetTempPath(), "sender space");
- var request = ActivationRequest.Create(["notes & café $HOME.md", "./notes & café $HOME.md", "SECOND.MD", "second.md"], directory);
- Assert.Equal(new[] { Path.Combine(directory, "notes & café $HOME.md"), Path.Combine(directory, "SECOND.MD") }, request.Paths);
- Assert.False(request.HasInvalidPath);
- Assert.Empty(ActivationRequest.Create([], directory).Paths);
- }
-
-
-
- /// Malformed messages cannot open relative paths or allocate an unbounded number of document windows.
- [Fact]
- public void Validate_InvalidPaths_RejectsMalformedAndUnboundedRequests()
- {
- Assert.Throws(() => new ActivationRequest(null!).Validate());
- Assert.Throws(() => new ActivationRequest(["relative.md"]).Validate());
- Assert.Throws(() => new ActivationRequest([""]).Validate());
- Assert.Throws(() => new ActivationRequest(["C:\\bad\nfile.md"]).Validate());
- Assert.Throws(() => new ActivationRequest(["C:\\" + new string('a', 32768)]).Validate());
- Assert.Throws(() => ActivationRequest.Create(Enumerable.Repeat("one.md", ActivationRequest.MaximumPaths + 1), Path.GetTempPath()));
- }
-
-
-
- /// Equivalent directory spellings share an instance, while distinct review profiles and channel installation roots do not.
- [Fact]
- public void InstanceKey_ProfilesAndInstallations_IsolatesBoundaries()
- {
- string root = Path.Combine(Path.GetTempPath(), "viewer");
- string data = Path.Combine(Path.GetTempPath(), "profile");
- string key = ActivationRequest.InstanceKey(root, data);
- Assert.Equal(key, ActivationRequest.InstanceKey(root + Path.DirectorySeparatorChar, data.ToUpperInvariant()));
- Assert.NotEqual(key, ActivationRequest.InstanceKey(root + "-dev", data), StringComparer.Ordinal);
- Assert.NotEqual(key, ActivationRequest.InstanceKey(root, data + "-review"), StringComparer.Ordinal);
- Assert.StartsWith("k22-markdownviewr-", key, StringComparison.Ordinal);
- Assert.DoesNotContain(root, key, StringComparison.OrdinalIgnoreCase);
- }
-}
diff --git a/tests/MarkdownViewr.Windows.UiTests/MarkdownViewr.Windows.UiTests.csproj b/tests/MarkdownViewr.Windows.UiTests/MarkdownViewr.Windows.UiTests.csproj
index 6aa1a97..304f6e5 100644
--- a/tests/MarkdownViewr.Windows.UiTests/MarkdownViewr.Windows.UiTests.csproj
+++ b/tests/MarkdownViewr.Windows.UiTests/MarkdownViewr.Windows.UiTests.csproj
@@ -36,9 +36,6 @@
-
-
-