diff --git a/src/PostIt.Tests/FakeAuthorizingBrowser.cs b/src/PostIt.Tests/FakeAuthorizingBrowser.cs
index fca1726c..10311500 100644
--- a/src/PostIt.Tests/FakeAuthorizingBrowser.cs
+++ b/src/PostIt.Tests/FakeAuthorizingBrowser.cs
@@ -15,24 +15,24 @@ namespace PostIt.Tests;
///
public sealed class FakeAuthorizingBrowser
{
- private readonly string _loopbackRedirectUri;
+ private readonly string _redirectUri;
private readonly HttpClient _http = new();
- public FakeAuthorizingBrowser(string loopbackRedirectUri)
+ public FakeAuthorizingBrowser(string redirectUri)
{
- _loopbackRedirectUri = loopbackRedirectUri;
+ _redirectUri = redirectUri;
}
- public IdentityModel.OidcClient.Browser.IBrowser CreateBrowser() => new Impl(_loopbackRedirectUri, _http);
+ public IdentityModel.OidcClient.Browser.IBrowser CreateBrowser() => new Impl(_redirectUri, _http);
private sealed class Impl : IdentityModel.OidcClient.Browser.IBrowser
{
- private readonly string _loopbackRedirectUri;
+ private readonly string _redirectUri;
private readonly HttpClient _http;
- public Impl(string loopbackRedirectUri, HttpClient http)
+ public Impl(string redirectUri, HttpClient http)
{
- _loopbackRedirectUri = loopbackRedirectUri;
+ _redirectUri = redirectUri;
_http = http;
}
@@ -64,8 +64,14 @@ public sealed class FakeAuthorizingBrowser
};
}
+ // Synthesize the redirect that the OIDC server would have
+ // sent back. The scheme and path match whatever the test
+ // configured (loopback for the historical test harness,
+ // postit://callback for the custom-scheme path).
+ var baseUri = _redirectUri;
+ if (!baseUri.EndsWith("/")) baseUri += "/";
var redirectUri =
- $"{_loopbackRedirectUri.TrimEnd('/')}/?code=test-auth-code&state={Uri.EscapeDataString(state)}";
+ $"{baseUri}?code=test-auth-code&state={Uri.EscapeDataString(state)}";
return new BrowserResult
{
@@ -88,4 +94,4 @@ public sealed class FakeAuthorizingBrowser
return dict;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/PostIt.Tests/LoopbackBrowserTests.cs b/src/PostIt.Tests/LoopbackBrowserTests.cs
deleted file mode 100644
index de6ebb9d..00000000
--- a/src/PostIt.Tests/LoopbackBrowserTests.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-using System.Net;
-using System.Net.Sockets;
-using PostIt.Services;
-
-namespace PostIt.Tests;
-
-///
-/// Regression coverage for the loopback browser that PostIt uses to
-/// receive the OIDC authorization-code callback on a local port.
-/// Specifically: the listener must always be released, even when the
-/// flow is abandoned (timeout or caller cancellation). Without this,
-/// the next PostIt launch fails with "Failed to listen on prefix
-/// http://127.0.0.1:7890/ because it conflicts with an existing
-/// registration on the machine."
-///
-public class LoopbackBrowserTests
-{
- [Fact]
- public async Task InvokeAsync_releases_listener_when_no_browser_responds_within_timeout()
- {
- // Pick a free port for this test (don't reuse 7890 — it could be
- // bound by a real PostIt running on the developer's machine).
- var port = GetFreePort();
- var prefix = $"http://127.0.0.1:{port}/";
-
- var browser = new LoopbackBrowser();
- var options = new IdentityModel.OidcClient.Browser.BrowserOptions(
- "http://127.0.0.1:1/", // never reached
- prefix);
-
- // The internal wait timeout is 5 minutes; we don't want the test
- // to actually wait that long. Instead we cancel via the outer
- // token and verify the listener is released.
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
-
- var result = await browser.InvokeAsync(options, cts.Token);
-
- // The cancellation propagates as Timeout because the outer token
- // fires first (the test is faster than the 5-minute internal wait).
- // We don't care which BrowserResultType is returned here — only
- // that the port is free afterwards.
- Assert.NotNull(result);
-
- // Critical assertion: the port is free. If the listener leaked,
- // a TcpListener binding to the same port would throw.
- using var probe = new TcpListener(IPAddress.Loopback, port);
- probe.Start();
- probe.Stop();
- }
-
- [Fact]
- public async Task InvokeAsync_releases_listener_when_browser_actually_responds()
- {
- var port = GetFreePort();
- var prefix = $"http://127.0.0.1:{port}/";
-
- var browser = new LoopbackBrowser();
- var options = new IdentityModel.OidcClient.Browser.BrowserOptions(
- "http://127.0.0.1:1/", // never reached (we respond directly below)
- prefix);
-
- // Race the listener against a fake browser callback.
- var browserTask = browser.InvokeAsync(options);
-
- // Give the listener a moment to bind.
- await Task.Delay(50);
-
- // Simulate the browser returning the redirect with code + state.
- using var http = new HttpClient();
- var response = await http.GetAsync($"{prefix.TrimEnd('/')}/?code=***&state=***");
- // We don't care about the response body; just that the request
- // was accepted (otherwise the listener hadn't bound yet).
- Assert.Equal(HttpStatusCode.OK, response.StatusCode);
-
- var result = await browserTask;
- Assert.Equal(IdentityModel.OidcClient.Browser.BrowserResultType.Success, result.ResultType);
-
- // Listener should be released now.
- using var probe = new TcpListener(IPAddress.Loopback, port);
- probe.Start();
- probe.Stop();
- }
-
- private static int GetFreePort()
- {
- var l = new TcpListener(IPAddress.Loopback, 0);
- l.Start();
- var port = ((IPEndPoint)l.LocalEndpoint).Port;
- l.Stop();
- return port;
- }
-}
diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs
index 8e895196..45b775a2 100644
--- a/src/PostIt/PostIt/App.axaml.cs
+++ b/src/PostIt/PostIt/App.axaml.cs
@@ -1,6 +1,8 @@
+using System;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
+using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
@@ -11,7 +13,7 @@ public partial class App : Application
public App()
{
}
-
+
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
@@ -19,6 +21,17 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
+ // Single-instance hand-off: if we were launched with a
+ // custom-scheme URL on the command line, we are a 2nd
+ // instance whose job is to forward the OAuth2 callback
+ // URL to the running PostIt process and exit. The first
+ // instance is parked inside CustomSchemeBrowser.InvokeAsync
+ // waiting on the named pipe for exactly this message.
+ if (TryHandOffCustomSchemeUrl())
+ {
+ return;
+ }
+
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
@@ -40,4 +53,34 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted();
}
+
+ private bool TryHandOffCustomSchemeUrl()
+ {
+ var args = Environment.GetCommandLineArgs();
+ var scheme = Platform.CustomScheme;
+ foreach (var arg in args)
+ {
+ if (arg.StartsWith(scheme + "://", StringComparison.OrdinalIgnoreCase))
+ {
+ // Best-effort: try to send the URL to the running
+ // instance via the named pipe. If the pipe isn't
+ // answering, just exit — there's no 1st instance
+ // to forward to (e.g. user double-clicked the link
+ // after closing PostIt). Falling through with a
+ // normal startup would be confusing.
+ SingleInstance.TryHandOffAsync(arg).GetAwaiter().GetResult();
+
+ if (ApplicationLifetime is IControlledApplicationLifetime lifetime)
+ {
+ lifetime.Shutdown(0);
+ }
+ else
+ {
+ Environment.Exit(0);
+ }
+ return true;
+ }
+ }
+ return false;
+ }
}
diff --git a/src/PostIt/PostIt/Services/CustomSchemeBrowser.cs b/src/PostIt/PostIt/Services/CustomSchemeBrowser.cs
new file mode 100644
index 00000000..d93b73ef
--- /dev/null
+++ b/src/PostIt/PostIt/Services/CustomSchemeBrowser.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using IdentityModel.OidcClient.Browser;
+
+namespace PostIt.Services;
+
+///
+/// Browser implementation that uses an OS-registered custom URI
+/// scheme (e.g. postit://callback) instead of a loopback
+/// HTTP listener. The redirect URI never has to be served: when the
+/// browser hits the scheme, the OS launches a fresh PostIt process
+/// with the URL on the command line. That process hands the URL off
+/// to the running instance via and
+/// exits.
+///
+/// Reference: RFC 8252 §7.1 (OAuth 2.0 for Native Apps — Custom URI
+/// Scheme Redirect).
+///
+public class CustomSchemeBrowser : IBrowser
+{
+ private readonly string _customScheme;
+ private readonly TimeSpan _timeout;
+
+ public CustomSchemeBrowser(string customScheme, TimeSpan? timeout = null)
+ {
+ _customScheme = customScheme;
+ _timeout = timeout ?? TimeSpan.FromMinutes(5);
+ }
+
+ public async Task InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default)
+ {
+ // The browser is told to come back to our scheme with the
+ // auth code. We do not need a network listener.
+ var endScheme = options.EndUrl;
+ if (string.IsNullOrEmpty(endScheme) || !endScheme.StartsWith(_customScheme, StringComparison.OrdinalIgnoreCase))
+ {
+ return new BrowserResult
+ {
+ ResultType = BrowserResultType.UnknownError,
+ Error = $"EndUrl must use the {_customScheme}:// scheme; got '{endScheme}'.",
+ };
+ }
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ cts.CancelAfter(_timeout);
+
+ // Spawn the server loop that waits for a 2nd-instance hand-off.
+ // When the callback URL arrives, complete the TCS so InvokeAsync
+ // returns it as the BrowserResult.Response.
+ using var serverCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
+ var serverTask = SingleInstance.StartServerAsync(
+ url => tcs.TrySetResult(url),
+ serverCts.Token);
+
+ try
+ {
+ // Open the system browser. UseShellExecute=true is the
+ // right shape for both Windows and Linux: the OS resolves
+ // the custom scheme by launching our app.
+ Process.Start(new ProcessStartInfo(options.StartUrl) { UseShellExecute = true });
+
+ // Race the callback against the timeout/caller
+ // cancellation. When the TCS completes the result IS
+ // the URL we want; when the timer fires first, we treat
+ // the wait as cancelled.
+ var delay = Task.Delay(Timeout.Infinite, cts.Token);
+ var completed = await Task.WhenAny(tcs.Task, delay).ConfigureAwait(false);
+ if (completed != tcs.Task)
+ {
+ return new BrowserResult
+ {
+ ResultType = BrowserResultType.Timeout,
+ Error = "Timed out waiting for the custom-scheme callback.",
+ };
+ }
+
+ var url = await tcs.Task.ConfigureAwait(false);
+ return new BrowserResult
+ {
+ ResultType = BrowserResultType.Success,
+ Response = url,
+ };
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return new BrowserResult
+ {
+ ResultType = BrowserResultType.UserCancel,
+ };
+ }
+ catch (Exception ex)
+ {
+ return new BrowserResult
+ {
+ ResultType = BrowserResultType.UnknownError,
+ Error = ex.Message,
+ };
+ }
+ finally
+ {
+ serverCts.Cancel();
+ try { await serverTask.ConfigureAwait(false); } catch { }
+ }
+ }
+}
diff --git a/src/PostIt/PostIt/Services/LoopbackBrowser.cs b/src/PostIt/PostIt/Services/LoopbackBrowser.cs
deleted file mode 100644
index c49aec83..00000000
--- a/src/PostIt/PostIt/Services/LoopbackBrowser.cs
+++ /dev/null
@@ -1,80 +0,0 @@
-using System;
-using System.Diagnostics;
-using System.Net;
-using System.Net.Sockets;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using IdentityModel.OidcClient.Browser;
-
-namespace PostIt.Services;
-
- public class LoopbackBrowser : IBrowser
- {
- public async Task InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default)
- {
- if (!Uri.TryCreate(options.EndUrl, UriKind.Absolute, out var endUri))
- {
- return new BrowserResult { ResultType = BrowserResultType.UnknownError, Error = "Invalid end URL" };
- }
-
- var prefix = endUri.GetLeftPart(UriPartial.Path);
- if (!prefix.EndsWith("/")) prefix += "/";
-
- using var listener = new HttpListener();
- listener.Prefixes.Add(prefix);
- listener.Start();
-
- try
- {
- Process.Start(new ProcessStartInfo(options.StartUrl) { UseShellExecute = true });
-
- // Bound the wait so the port is released even if the user
- // closes the browser without completing the flow. Without
- // this, a crashed/abandoned login keeps the HttpListener
- // bound and the next PostIt launch fails with
- // "Failed to listen on prefix … because it conflicts with
- // an existing registration".
- using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
- waitCts.CancelAfter(TimeSpan.FromMinutes(5));
-
- var context = await listener.GetContextAsync().WaitAsync(waitCts.Token).ConfigureAwait(false);
- var response = context.Response;
- var responseString = "Authentication complete. You can close this window.";
- var buffer = Encoding.UTF8.GetBytes(responseString);
- response.ContentLength64 = buffer.Length;
- await response.OutputStream.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
- response.OutputStream.Close();
-
- var raw = context.Request.Url!.ToString();
- return new BrowserResult
- {
- ResultType = BrowserResultType.Success,
- Response = raw
- };
- }
- catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
- {
- // Bound hit: user did not complete the flow within 5 minutes.
- return new BrowserResult
- {
- ResultType = BrowserResultType.Timeout,
- Error = "Timed out waiting for the browser to return the authorization code.",
- };
- }
- catch (Exception ex)
- {
- return new BrowserResult { ResultType = BrowserResultType.UnknownError, Error = ex.Message };
- }
- finally
- {
- // Stop() aborts GetContextAsync (releases the bound port);
- // Close() disposes the underlying socket. Both are idempotent
- // and safe to call after Stop() already succeeded, so calling
- // both covers cases where one path throws before the other
- // gets a chance (e.g. process-level socket cleanup on Linux).
- try { listener.Stop(); } catch { }
- try { listener.Close(); } catch { }
- }
- }
- }
diff --git a/src/PostIt/PostIt/Services/Platform.cs b/src/PostIt/PostIt/Services/Platform.cs
index 3e552be4..258cd5b5 100644
--- a/src/PostIt/PostIt/Services/Platform.cs
+++ b/src/PostIt/PostIt/Services/Platform.cs
@@ -13,16 +13,28 @@ namespace PostIt.Services;
public static class Platform
{
///
- /// Default redirect URI for the running platform. The desktop loopback
- /// default is set here; platform projects override this property at
- /// startup (e.g. PostIt.Android sets it to android://postit-signin).
+ /// Default redirect URI for the running platform. The shared
+ /// default uses a custom URI scheme (postit://callback)
+ /// so the OAuth2 redirect is delivered to the running instance
+ /// through the OS scheme handler — no loopback HTTP listener
+ /// required (see RFC 8252 §7.1). Platform projects may still
+ /// override this property at startup (e.g. PostIt.Android sets
+ /// it to android://postit-signin).
///
- public static string DefaultRedirectUri { get; set; } = "http://127.0.0.1:7890/";
+ public static string DefaultRedirectUri { get; set; } = "postit://callback";
+
+ ///
+ /// Scheme prefix the matches
+ /// against BrowserOptions.EndUrl. Overridable for apps
+ /// that want to register their own scheme.
+ ///
+ public static string CustomScheme { get; set; } = "postit";
///
/// Constructs a fresh for the running platform.
/// May return null if no browser is wired up; in that case
/// LoginAsync will surface a clear error.
///
- public static System.Func? CreateBrowser { get; set; }
+ public static System.Func? CreateBrowser { get; set; } =
+ () => new CustomSchemeBrowser(CustomScheme);
}
\ No newline at end of file
diff --git a/src/PostIt/PostIt/Services/SingleInstance.cs b/src/PostIt/PostIt/Services/SingleInstance.cs
new file mode 100644
index 00000000..329bf352
--- /dev/null
+++ b/src/PostIt/PostIt/Services/SingleInstance.cs
@@ -0,0 +1,98 @@
+using System;
+using System.IO;
+using System.IO.Pipes;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PostIt.Services;
+
+///
+/// Single-instance coordinator for PostIt. The first process claims a
+/// well-known named pipe and starts a server that listens for
+/// callback URLs from any subsequent launch. Subsequent launches
+/// (triggered by the OS registering the custom URI scheme and the
+/// browser opening the scheme after the OIDC redirect) detect the
+/// existing instance, write the URL to the pipe, and exit.
+///
+/// This is the standard RFC 8252 §7.1 pattern for native apps that
+/// use a custom URI scheme: the OS launches a fresh process for each
+/// callback, and the running app must hand the URL back to itself.
+///
+public static class SingleInstance
+{
+ public const string PipeName = "PostIt.OidcCallback";
+
+ ///
+ /// Tries to open the well-known pipe. If a server is already
+ /// listening (another PostIt instance), the URL is written and
+ /// the call returns true to signal the caller to exit. If
+ /// nothing is listening, returns false and the caller
+ /// should start its own server via .
+ ///
+ public static async Task TryHandOffAsync(string callbackUrl, TimeSpan? timeout = null)
+ {
+ try
+ {
+ using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
+ await client.ConnectAsync((int)(timeout ?? TimeSpan.FromSeconds(2)).TotalMilliseconds).ConfigureAwait(false);
+ var bytes = Encoding.UTF8.GetBytes(callbackUrl);
+ await client.WriteAsync(bytes).ConfigureAwait(false);
+ await client.FlushAsync().ConfigureAwait(false);
+ return true;
+ }
+ catch (TimeoutException)
+ {
+ return false;
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Starts the single-instance server loop. Yields each received
+ /// callback URL on the returned channel. The server runs until
+ /// the cancellation token is tripped (typically when the
+ /// owning returns from
+ /// InvokeAsync or the process shuts down).
+ ///
+ public static async Task StartServerAsync(
+ Action onCallback,
+ CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ NamedPipeServerStream? server = null;
+ try
+ {
+ server = new NamedPipeServerStream(
+ PipeName,
+ PipeDirection.In,
+ NamedPipeServerStream.MaxAllowedServerInstances,
+ PipeTransmissionMode.Byte,
+ PipeOptions.Asynchronous);
+
+ await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
+
+ using var ms = new MemoryStream();
+ await server.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
+ var url = Encoding.UTF8.GetString(ms.ToArray());
+ onCallback(url);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (IOException)
+ {
+ // Client disconnected mid-write; loop and wait for the next.
+ }
+ finally
+ {
+ server?.Dispose();
+ }
+ }
+ }
+}