From d23fa8f151c3302dce1de8de8d5cb73354b0c0ed Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sun, 16 Aug 2026 01:36:04 +0200
Subject: [PATCH 1/9] Consolidate into one project
---
SecureFolderFS.Public.slnx | 1 -
SecureFolderFS.slnx | 1 -
.../SecureFolderFS.Cli/AppApiClient.cs | 362 ++++++++++++++++++
.../SecureFolderFS.Cli.csproj | 1 -
.../Enums/ApiTransportKind.cs | 18 -
.../SecureFolderFS.Api.csproj | 10 -
.../Enums/ApiActionOutcome.cs | 38 --
.../SecureFolderFS.Sdk.Api/Enums/ApiEnums.cs | 106 +++++
.../Enums/ApiRequestClass.cs | 23 --
.../Enums/ApiVaultChangeKind.cs | 33 --
.../SecureFolderFS.Sdk.Api.csproj | 2 +-
.../Models/IntegrationClientInfo.cs | 21 +
.../Services/ILocalIntegrationsService.cs | 38 ++
.../Overlays/ApiConsentOverlayViewModel.cs | 7 -
14 files changed, 528 insertions(+), 133 deletions(-)
create mode 100644 src/Platforms/SecureFolderFS.Cli/AppApiClient.cs
delete mode 100644 src/Sdk/SecureFolderFS.Api/Enums/ApiTransportKind.cs
delete mode 100644 src/Sdk/SecureFolderFS.Api/SecureFolderFS.Api.csproj
delete mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiActionOutcome.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiEnums.cs
delete mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiRequestClass.cs
delete mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiVaultChangeKind.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk/Models/IntegrationClientInfo.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs
diff --git a/SecureFolderFS.Public.slnx b/SecureFolderFS.Public.slnx
index d1db8ff8b..84f2aaa1d 100644
--- a/SecureFolderFS.Public.slnx
+++ b/SecureFolderFS.Public.slnx
@@ -36,7 +36,6 @@
-
diff --git a/SecureFolderFS.slnx b/SecureFolderFS.slnx
index 4c609c261..b73467008 100644
--- a/SecureFolderFS.slnx
+++ b/SecureFolderFS.slnx
@@ -93,7 +93,6 @@
-
diff --git a/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs
new file mode 100644
index 000000000..2bf0e9e0a
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs
@@ -0,0 +1,362 @@
+using System.IO.Pipes;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+
+namespace SecureFolderFS.Cli;
+
+///
+/// Talks to a running SecureFolderFS instance over the local integration API.
+///
+///
+/// Written against the BCL alone — no SecureFolderFS assemblies, no shared protocol library. It exists
+/// to prove that the published protocol is implementable from the specification by itself: a third party
+/// reading docs/local-integration-api.md can reproduce everything here in an afternoon.
+///
+internal sealed class AppApiClient : IAsyncDisposable
+{
+ private const string CLIENT_NAME = "SecureFolderFS CLI";
+ private const int PROTOCOL_VERSION = 0;
+
+ private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(30);
+
+ private readonly Stream _stream;
+ private readonly StreamReader _reader;
+ private readonly StreamWriter _writer;
+ private long _nextId = 1;
+
+ private AppApiClient(Stream stream)
+ {
+ _stream = stream;
+ _reader = new StreamReader(stream, Encoding.UTF8);
+ // Newline-delimited JSON: one compact object per line, terminated by '\n'.
+ _writer = new StreamWriter(stream, new UTF8Encoding(false)) { AutoFlush = true, NewLine = "\n" };
+ }
+
+ ///
+ /// Connects to the running application, pairing if this is the first use.
+ ///
+ /// The API was unreachable, or pairing was refused.
+ public static async Task ConnectAsync(CancellationToken cancellationToken = default)
+ {
+ var endpoint = ApiEndpoint.TryRead()
+ ?? throw new AppApiException("SecureFolderFS does not appear to be installed for this user.");
+
+ if (!endpoint.Enabled)
+ throw new AppApiException("App integrations are turned off. Enable them in SecureFolderFS under Settings > Preferences.");
+
+ if (PROTOCOL_VERSION < endpoint.ProtocolMin || PROTOCOL_VERSION > endpoint.ProtocolMax)
+ throw new AppApiException("SecureFolderFS speaks a protocol version this build does not support.");
+
+ var stream = await endpoint.OpenAsync(ConnectTimeout, cancellationToken)
+ ?? throw new AppApiException("SecureFolderFS is not running.");
+
+ var client = new AppApiClient(stream);
+ try
+ {
+ await client.HandshakeAsync(cancellationToken);
+ return client;
+ }
+ catch
+ {
+ await client.DisposeAsync();
+ throw;
+ }
+ }
+
+ private async Task HandshakeAsync(CancellationToken cancellationToken)
+ {
+ var token = TokenStore.TryRead();
+
+ var hello = await CallAsync("hello", new
+ {
+ protocolMin = PROTOCOL_VERSION,
+ protocolMax = PROTOCOL_VERSION,
+ clientName = CLIENT_NAME,
+ token
+ }, cancellationToken);
+
+ if (hello.GetProperty("state").GetString() == "paired")
+ return;
+
+ // A stored token that no longer authenticates means the user revoked us.
+ if (token is not null)
+ TokenStore.Clear();
+
+ // 'pair' raises a consent dialog in SecureFolderFS; only ever call it in response to a user action.
+ var pair = await CallAsync("pair", new { scopes = new[] { "vaults.read", "vaults.trigger" } }, cancellationToken);
+ TokenStore.Write(pair.GetProperty("token").GetString()!);
+ }
+
+ ///
+ /// Lists every vault visible to integrations.
+ ///
+ public async Task> ListVaultsAsync(CancellationToken cancellationToken = default)
+ {
+ var result = await CallAsync("vaults.list", null, cancellationToken);
+
+ var vaults = new List();
+ foreach (var vault in result.GetProperty("vaults").EnumerateArray())
+ {
+ vaults.Add(new ApiVault(
+ vault.GetProperty("id").GetString() ?? string.Empty,
+ vault.GetProperty("name").GetString() ?? string.Empty,
+ vault.GetProperty("state").GetString() ?? string.Empty,
+ vault.TryGetProperty("mountPath", out var mountPath) ? mountPath.GetString() : null));
+ }
+
+ return vaults;
+ }
+
+ ///
+ /// Asks the application to show its unlock prompt for a vault. Returns the reported status.
+ ///
+ public Task RequestUnlockAsync(string vaultId, CancellationToken cancellationToken = default)
+ => CallForStatusAsync("vaults.requestUnlock", vaultId, cancellationToken);
+
+ ///
+ /// Locks an unlocked vault. Returns the reported status.
+ ///
+ public Task LockAsync(string vaultId, CancellationToken cancellationToken = default)
+ => CallForStatusAsync("vaults.lock", vaultId, cancellationToken);
+
+ private async Task CallForStatusAsync(string method, string vaultId, CancellationToken cancellationToken)
+ {
+ var result = await CallAsync(method, new { vaultId }, cancellationToken);
+ return result.TryGetProperty("status", out var status) ? status.GetString() ?? "ok" : "ok";
+ }
+
+ private async Task CallAsync(string method, object? parameters, CancellationToken cancellationToken)
+ {
+ var id = _nextId++;
+ await _writer.WriteLineAsync(JsonSerializer.Serialize(new { id, method, @params = parameters }));
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(RequestTimeout);
+
+ while (true)
+ {
+ var line = await _reader.ReadLineAsync(timeout.Token)
+ ?? throw new AppApiException($"SecureFolderFS closed the connection during '{method}'.");
+
+ if (line.Length == 0)
+ continue;
+
+ using var document = JsonDocument.Parse(line);
+ var root = document.RootElement;
+
+ // Notifications carry no id, and replies to other requests may interleave with this one.
+ if (!root.TryGetProperty("id", out var replyId) || replyId.ValueKind == JsonValueKind.Null || replyId.GetInt64() != id)
+ continue;
+
+ if (root.TryGetProperty("error", out var error))
+ throw new AppApiException(DescribeError(method, error));
+
+ return root.TryGetProperty("result", out var result) ? result.Clone() : default;
+ }
+ }
+
+ private static string DescribeError(string method, JsonElement error)
+ {
+ var code = error.TryGetProperty("code", out var c) ? c.GetString() : null;
+ var message = error.TryGetProperty("message", out var m) ? m.GetString() : null;
+ var retryAfterMs = error.TryGetProperty("retryAfterMs", out var r) && r.ValueKind == JsonValueKind.Number ? r.GetInt32() : 0;
+
+ return code switch
+ {
+ "pairing_denied" => "SecureFolderFS declined the connection.",
+ "pairing_unavailable" => "SecureFolderFS is not accepting a pairing request right now. Try again shortly.",
+ "forbidden_scope" => "This tool was not granted permission for that action.",
+ "rate_limited" => $"Rate limited; retry in {retryAfterMs / 1000}s.",
+ "not_found" => "No such vault.",
+ _ => $"'{method}' failed ({code}): {message}"
+ };
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ _reader.Dispose();
+ await _writer.DisposeAsync();
+ await _stream.DisposeAsync();
+ }
+
+ ///
+ /// A vault as reported by the API.
+ ///
+ internal sealed record ApiVault(string Id, string Name, string State, string? MountPath);
+
+ ///
+ /// Locates and connects to the endpoint by reading the persistent endpoint file, exactly as the
+ /// protocol specification describes. No secrets live here — the file is only a map.
+ ///
+ private sealed record ApiEndpoint(string Transport, string Address, bool Enabled, int ProtocolMin, int ProtocolMax)
+ {
+ public static ApiEndpoint? TryRead()
+ {
+ try
+ {
+ var path = Path.Combine(GetPersistentDirectory(), "api-endpoint.json");
+ if (!File.Exists(path))
+ return null;
+
+ using var document = JsonDocument.Parse(File.ReadAllText(path));
+ var root = document.RootElement;
+
+ return new ApiEndpoint(
+ root.GetProperty("transport").GetString() ?? string.Empty,
+ root.GetProperty("address").GetString() ?? string.Empty,
+ root.TryGetProperty("enabled", out var enabled) && enabled.GetBoolean(),
+ root.TryGetProperty("protocolMin", out var min) ? min.GetInt32() : 0,
+ root.TryGetProperty("protocolMax", out var max) ? max.GetInt32() : 0);
+ }
+ catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Opens the transport, or when nothing is listening. Liveness is only ever
+ /// determined by attempting the connection, never inferred from the file's presence.
+ ///
+ public async Task OpenAsync(TimeSpan connectTimeout, CancellationToken cancellationToken)
+ {
+ try
+ {
+ return Transport switch
+ {
+ "namedPipe" => await ConnectPipeAsync(Address, connectTimeout, cancellationToken),
+ "unixSocket" => await ConnectSocketAsync(Address, cancellationToken),
+ _ => null
+ };
+ }
+ catch (Exception ex) when (ex is SocketException or IOException or TimeoutException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private static async Task ConnectPipeAsync(string address, TimeSpan connectTimeout, CancellationToken cancellationToken)
+ {
+ // On Windows the advertised address is the bare pipe name; tolerate a \\.\pipe\ prefix too.
+ var pipeName = address.StartsWith(@"\\.\pipe\", StringComparison.OrdinalIgnoreCase)
+ ? address[@"\\.\pipe\".Length..]
+ : address;
+
+ var pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
+ try
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(connectTimeout);
+
+ await pipe.ConnectAsync(timeout.Token);
+ return pipe;
+ }
+ catch
+ {
+ await pipe.DisposeAsync();
+ throw;
+ }
+ }
+
+ private static async Task ConnectSocketAsync(string address, CancellationToken cancellationToken)
+ {
+ if (!File.Exists(address))
+ return null;
+
+ var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+ try
+ {
+ await socket.ConnectAsync(new UnixDomainSocketEndPoint(address), cancellationToken);
+ return new NetworkStream(socket, ownsSocket: true);
+ }
+ catch
+ {
+ socket.Dispose();
+ throw;
+ }
+ }
+
+ private static string GetPersistentDirectory()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "SecureFolderFS");
+ }
+
+ if (OperatingSystem.IsMacOS())
+ {
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Library", "Application Support", "SecureFolderFS");
+ }
+
+ var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME");
+ if (string.IsNullOrEmpty(configHome))
+ configHome = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config");
+
+ return Path.Combine(configHome, "securefolderfs");
+ }
+ }
+
+ ///
+ /// Persists the pairing token for this tool.
+ ///
+ private static class TokenStore
+ {
+ private static string FilePath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".securefolderfs",
+ "cli-api-token");
+
+ public static string? TryRead()
+ {
+ try
+ {
+ return File.Exists(FilePath) ? File.ReadAllText(FilePath).Trim() : null;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ public static void Write(string token)
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
+ File.WriteAllText(FilePath, token);
+
+ // The token authorizes this tool, so keep it readable only by its owner.
+ if (!OperatingSystem.IsWindows())
+ File.SetUnixFileMode(FilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Losing the token only means pairing again next time.
+ }
+ }
+
+ public static void Clear()
+ {
+ try
+ {
+ File.Delete(FilePath);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Best effort.
+ }
+ }
+ }
+}
+
+///
+/// Represents a failure talking to the local integration API.
+///
+internal sealed class AppApiException(string message) : Exception(message);
diff --git a/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj
index 7b079eb36..59f753706 100644
--- a/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj
+++ b/src/Platforms/SecureFolderFS.Cli/SecureFolderFS.Cli.csproj
@@ -15,7 +15,6 @@
-
diff --git a/src/Sdk/SecureFolderFS.Api/Enums/ApiTransportKind.cs b/src/Sdk/SecureFolderFS.Api/Enums/ApiTransportKind.cs
deleted file mode 100644
index 6642fa071..000000000
--- a/src/Sdk/SecureFolderFS.Api/Enums/ApiTransportKind.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace SecureFolderFS.Api.Enums
-{
- ///
- /// Identifies the local IPC mechanism carrying a connection.
- ///
- public enum ApiTransportKind
- {
- ///
- /// A Windows named pipe, restricted by an explicit DACL.
- ///
- NamedPipe,
-
- ///
- /// A Unix domain socket, restricted by filesystem permissions.
- ///
- UnixSocket
- }
-}
diff --git a/src/Sdk/SecureFolderFS.Api/SecureFolderFS.Api.csproj b/src/Sdk/SecureFolderFS.Api/SecureFolderFS.Api.csproj
deleted file mode 100644
index c9ea32713..000000000
--- a/src/Sdk/SecureFolderFS.Api/SecureFolderFS.Api.csproj
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
- net10.0
- latest
- enable
- disable
-
-
-
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiActionOutcome.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiActionOutcome.cs
deleted file mode 100644
index f408e20d6..000000000
--- a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiActionOutcome.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-namespace SecureFolderFS.Sdk.Api.Enums
-{
- ///
- /// Describes the outcome of an action requested through the API.
- ///
- public enum ApiActionOutcome
- {
- ///
- /// The action was performed, or the requested user interface was shown.
- ///
- Ok,
-
- ///
- /// A prompt for this vault was already open, so no additional one was raised.
- ///
- AlreadyPending,
-
- ///
- /// The vault was already in the requested state.
- ///
- NoChange,
-
- ///
- /// No vault with the supplied identifier is visible to integrations.
- ///
- NotFound,
-
- ///
- /// The action does not apply while the vault is in its current state.
- ///
- InvalidState,
-
- ///
- /// The application is not ready to service the request.
- ///
- Unavailable
- }
-}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiEnums.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiEnums.cs
new file mode 100644
index 000000000..adddae560
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiEnums.cs
@@ -0,0 +1,106 @@
+namespace SecureFolderFS.Sdk.Api.Enums
+{
+ ///
+ /// Identifies the local IPC mechanism carrying a connection.
+ ///
+ public enum ApiTransportKind
+ {
+ ///
+ /// A Windows named pipe, restricted by an explicit DACL.
+ ///
+ NamedPipe,
+
+ ///
+ /// A Unix domain socket, restricted by filesystem permissions.
+ ///
+ UnixSocket
+ }
+
+ ///
+ /// Describes the outcome of an action requested through the API.
+ ///
+ public enum ApiActionOutcome
+ {
+ ///
+ /// The action was performed, or the requested user interface was shown.
+ ///
+ Ok,
+
+ ///
+ /// A prompt for this vault was already open, so no additional one was raised.
+ ///
+ AlreadyPending,
+
+ ///
+ /// The vault was already in the requested state.
+ ///
+ NoChange,
+
+ ///
+ /// No vault with the supplied identifier is visible to integrations.
+ ///
+ NotFound,
+
+ ///
+ /// The action does not apply while the vault is in its current state.
+ ///
+ InvalidState,
+
+ ///
+ /// The application is not ready to service the request.
+ ///
+ Unavailable
+ }
+
+ ///
+ /// Classifies a request by its rate-limit budget.
+ ///
+ public enum ApiRequestType
+ {
+ ///
+ /// Reads application state.
+ ///
+ Read,
+
+ ///
+ /// Raises a window or changes vault state.
+ ///
+ Trigger,
+
+ ///
+ /// Asks for consent.
+ ///
+ Pairing
+ }
+
+ ///
+ /// Describes the kind of change that occurred to a vault.
+ ///
+ public enum ApiVaultChangeKind
+ {
+ ///
+ /// A vault became visible to integrations.
+ ///
+ Added,
+
+ ///
+ /// A vault stopped being visible to integrations, whether removed or hidden.
+ ///
+ Removed,
+
+ ///
+ /// A vault's display name changed.
+ ///
+ Renamed,
+
+ ///
+ /// A vault was mounted.
+ ///
+ Unlocked,
+
+ ///
+ /// A vault was unmounted.
+ ///
+ Locked
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiRequestClass.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiRequestClass.cs
deleted file mode 100644
index dfbb995a6..000000000
--- a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiRequestClass.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace SecureFolderFS.Sdk.Api.Enums
-{
- ///
- /// Classifies a request by its expensiveness.
- ///
- public enum ApiRequestClass
- {
- ///
- /// Reads application state.
- ///
- Read,
-
- ///
- /// Raises a window or changes vault state.
- ///
- Trigger,
-
- ///
- /// Asks for consent.
- ///
- Pairing
- }
-}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiVaultChangeKind.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiVaultChangeKind.cs
deleted file mode 100644
index caf5ce0a4..000000000
--- a/src/Sdk/SecureFolderFS.Sdk.Api/Enums/ApiVaultChangeKind.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-namespace SecureFolderFS.Sdk.Api.Enums
-{
- ///
- /// Describes the kind of change that occurred to a vault.
- ///
- public enum ApiVaultChangeKind
- {
- ///
- /// A vault became visible to integrations.
- ///
- Added,
-
- ///
- /// A vault stopped being visible to integrations, whether removed or hidden.
- ///
- Removed,
-
- ///
- /// A vault's display name changed.
- ///
- Renamed,
-
- ///
- /// A vault was mounted.
- ///
- Unlocked,
-
- ///
- /// A vault was unmounted.
- ///
- Locked
- }
-}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj b/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj
index cad01a01d..fe26f5b96 100644
--- a/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj
@@ -8,7 +8,7 @@
-
+
diff --git a/src/Sdk/SecureFolderFS.Sdk/Models/IntegrationClientInfo.cs b/src/Sdk/SecureFolderFS.Sdk/Models/IntegrationClientInfo.cs
new file mode 100644
index 000000000..7e383c476
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk/Models/IntegrationClientInfo.cs
@@ -0,0 +1,21 @@
+using System;
+
+namespace SecureFolderFS.Sdk.Models
+{
+ ///
+ /// Describes an application the user has authorized to use the local integration API.
+ ///
+ /// The identifier of the pairing, used to revoke it.
+ /// The name shown to the user.
+ /// Whether the platform verified the publisher when it was authorized.
+ /// The verified publisher, when one was established.
+ /// Where the application lived when it was authorized.
+ /// When it last connected, or if it never has.
+ public sealed record IntegrationClientInfo(
+ string Id,
+ string DisplayName,
+ bool IsIdentityVerified,
+ string? Signer,
+ string? ExecutablePath,
+ DateTimeOffset? LastUsedAt);
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs b/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs
new file mode 100644
index 000000000..7f3d41074
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs
@@ -0,0 +1,38 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Sdk.Models;
+
+namespace SecureFolderFS.Sdk.Services
+{
+ ///
+ /// Manages the local integration API on behalf of the settings interface.
+ ///
+ public interface ILocalIntegrationsService
+ {
+ ///
+ /// Starts or stops the API endpoint and records the choice.
+ ///
+ /// Whether other applications may connect.
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation.
+ Task SetEnabledAsync(bool isEnabled, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets every application currently authorized to connect.
+ ///
+ IReadOnlyList GetClients();
+
+ ///
+ /// Withdraws an application's authorization. It must ask the user again to reconnect.
+ ///
+ /// The identifier from .
+ void RevokeClient(string clientId);
+
+ ///
+ /// Withdraws every application's authorization and forgets every recorded refusal, so a mistaken
+ /// denial does not leave the application waiting out the cooldown.
+ ///
+ void RevokeAllClients();
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/ApiConsentOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/ApiConsentOverlayViewModel.cs
index 971a1a86d..4c9f97793 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/ApiConsentOverlayViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/ApiConsentOverlayViewModel.cs
@@ -37,11 +37,6 @@ public sealed partial class ApiConsentOverlayViewModel : OverlayViewModel
///
[ObservableProperty] private string _IdentityDescription;
- ///
- /// Gets the scopes being requested, as identifiers.
- ///
- public ObservableCollection RequestedScopes { get; }
-
///
/// Gets the human-readable description of what the caller is asking to do.
///
@@ -52,14 +47,12 @@ public ApiConsentOverlayViewModel(
string? executablePath,
string? signer,
bool isIdentityVerified,
- IEnumerable requestedScopes,
IEnumerable requestedPermissions)
{
ClientName = clientName;
ExecutablePath = executablePath;
Signer = signer;
IsIdentityVerified = isIdentityVerified;
- RequestedScopes = new(requestedScopes);
RequestedPermissions = new(requestedPermissions);
Title = "ApiConsentTitle".ToLocalized();
From cbe972e4bcced90b3c3cf72ae8a8924afeedd9b7 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sun, 16 Aug 2026 01:43:22 +0200
Subject: [PATCH 2/9] Added AppVaultsCommand
---
.../SecureFolderFS.Cli/AppApiClient.cs | 15 +--
.../SecureFolderFS.Cli/CliCommandHelpers.cs | 4 +
.../Commands/AppVaultsCommand.cs | 103 ++++++++++++++++++
.../Services/IApiHost.cs | 34 ++++++
4 files changed, 145 insertions(+), 11 deletions(-)
create mode 100644 src/Platforms/SecureFolderFS.Cli/Commands/AppVaultsCommand.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiHost.cs
diff --git a/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs
index 2bf0e9e0a..eccdffc41 100644
--- a/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs
+++ b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs
@@ -8,11 +8,6 @@ namespace SecureFolderFS.Cli;
///
/// Talks to a running SecureFolderFS instance over the local integration API.
///
-///
-/// Written against the BCL alone — no SecureFolderFS assemblies, no shared protocol library. It exists
-/// to prove that the published protocol is implementable from the specification by itself: a third party
-/// reading docs/local-integration-api.md can reproduce everything here in an afternoon.
-///
internal sealed class AppApiClient : IAsyncDisposable
{
private const string CLIENT_NAME = "SecureFolderFS CLI";
@@ -30,7 +25,7 @@ private AppApiClient(Stream stream)
{
_stream = stream;
_reader = new StreamReader(stream, Encoding.UTF8);
- // Newline-delimited JSON: one compact object per line, terminated by '\n'.
+ // Newline-delimited JSON
_writer = new StreamWriter(stream, new UTF8Encoding(false)) { AutoFlush = true, NewLine = "\n" };
}
@@ -68,7 +63,6 @@ public static async Task ConnectAsync(CancellationToken cancellati
private async Task HandshakeAsync(CancellationToken cancellationToken)
{
var token = TokenStore.TryRead();
-
var hello = await CallAsync("hello", new
{
protocolMin = PROTOCOL_VERSION,
@@ -84,7 +78,7 @@ private async Task HandshakeAsync(CancellationToken cancellationToken)
if (token is not null)
TokenStore.Clear();
- // 'pair' raises a consent dialog in SecureFolderFS; only ever call it in response to a user action.
+ // 'pair' raises a consent dialog
var pair = await CallAsync("pair", new { scopes = new[] { "vaults.read", "vaults.trigger" } }, cancellationToken);
TokenStore.Write(pair.GetProperty("token").GetString()!);
}
@@ -189,7 +183,7 @@ internal sealed record ApiVault(string Id, string Name, string State, string? Mo
///
/// Locates and connects to the endpoint by reading the persistent endpoint file, exactly as the
- /// protocol specification describes. No secrets live here — the file is only a map.
+ /// protocol specification describes.
///
private sealed record ApiEndpoint(string Transport, string Address, bool Enabled, int ProtocolMin, int ProtocolMax)
{
@@ -218,8 +212,7 @@ private sealed record ApiEndpoint(string Transport, string Address, bool Enabled
}
///
- /// Opens the transport, or when nothing is listening. Liveness is only ever
- /// determined by attempting the connection, never inferred from the file's presence.
+ /// Opens the transport, or when nothing is listening.
///
public async Task OpenAsync(TimeSpan connectTimeout, CancellationToken cancellationToken)
{
diff --git a/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs
index 34aab586f..c8c4fb827 100644
--- a/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs
+++ b/src/Platforms/SecureFolderFS.Cli/CliCommandHelpers.cs
@@ -51,6 +51,10 @@ public static int HandleException(Exception ex, IConsole console, CliGlobalOptio
{
switch (ex)
{
+ case AppApiException:
+ CliOutput.Error(console, options, ex.Message);
+ return CliExitCodes.GeneralError;
+
case CryptographicException:
case FormatException:
CliOutput.Error(console, options, ex.Message);
diff --git a/src/Platforms/SecureFolderFS.Cli/Commands/AppVaultsCommand.cs b/src/Platforms/SecureFolderFS.Cli/Commands/AppVaultsCommand.cs
new file mode 100644
index 000000000..f13363eec
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Cli/Commands/AppVaultsCommand.cs
@@ -0,0 +1,103 @@
+using CliFx;
+using CliFx.Binding;
+using CliFx.Infrastructure;
+
+namespace SecureFolderFS.Cli.Commands;
+
+///
+/// Lists the vaults known to a running SecureFolderFS instance.
+///
+[Command("app vaults", Description = "List vaults from a running SecureFolderFS instance.")]
+public sealed partial class AppVaultsCommand : CliGlobalOptions, ICommand
+{
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ try
+ {
+ await using var client = await AppApiClient.ConnectAsync(console.RegisterCancellationHandler());
+ var vaults = await client.ListVaultsAsync();
+
+ if (vaults.Count == 0)
+ {
+ CliOutput.Info(console, this, "No vaults are visible to integrations.");
+ Environment.ExitCode = CliExitCodes.Success;
+ return;
+ }
+
+ foreach (var vault in vaults)
+ {
+ var suffix = vault.State == "unlocked" ? $" {vault.MountPath}" : string.Empty;
+ console.Output.WriteLine($"{vault.Id} {vault.State,-8} {vault.Name}{suffix}");
+ }
+
+ Environment.ExitCode = CliExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this);
+ }
+ }
+}
+
+///
+/// Asks a running SecureFolderFS instance to show its unlock prompt.
+///
+[Command("app unlock", Description = "Ask a running SecureFolderFS instance to prompt for unlocking a vault.")]
+public sealed partial class AppUnlockCommand : CliGlobalOptions, ICommand
+{
+ [CommandParameter(0, Name = "vaultId", Description = "Vault id as reported by 'app vaults'.")]
+ public required string VaultId { get; set; }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ try
+ {
+ await using var client = await AppApiClient.ConnectAsync(console.RegisterCancellationHandler());
+ var status = await client.RequestUnlockAsync(VaultId);
+
+ // The prompt is shown to the user; this command deliberately does not wait for the outcome,
+ // because the user may take any amount of time or cancel outright.
+ CliOutput.Info(console, this, status switch
+ {
+ "already_pending" => "An unlock prompt for this vault is already open.",
+ "no_change" => "The vault is already unlocked.",
+ _ => "SecureFolderFS is prompting for credentials."
+ });
+
+ Environment.ExitCode = CliExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this);
+ }
+ }
+}
+
+///
+/// Locks a vault in a running SecureFolderFS instance.
+///
+[Command("app lock", Description = "Lock a vault in a running SecureFolderFS instance.")]
+public sealed partial class AppLockCommand : CliGlobalOptions, ICommand
+{
+ [CommandParameter(0, Name = "vaultId", Description = "Vault id as reported by 'app vaults'.")]
+ public required string VaultId { get; set; }
+
+ public override async ValueTask ExecuteAsync(IConsole console)
+ {
+ try
+ {
+ await using var client = await AppApiClient.ConnectAsync(console.RegisterCancellationHandler());
+ var status = await client.LockAsync(VaultId);
+
+ CliOutput.Info(console, this, status == "no_change"
+ ? "The vault is already locked."
+ : "Lock requested.");
+
+ Environment.ExitCode = CliExitCodes.Success;
+ }
+ catch (Exception ex)
+ {
+ Environment.ExitCode = CliCommandHelpers.HandleException(ex, console, this);
+ }
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiHost.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiHost.cs
new file mode 100644
index 000000000..006a0d3b0
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiHost.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace SecureFolderFS.Sdk.Api.Services
+{
+ ///
+ /// Hosts the local integration API endpoint.
+ ///
+ public interface IApiHost : IAsyncDisposable
+ {
+ ///
+ /// Binds the endpoint and begins accepting clients.
+ ///
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation.
+ Task StartAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Stops accepting clients and disconnects existing ones.
+ ///
+ /// A that cancels this action.
+ ///
+ /// The endpoint file is left advertising the API as enabled, so a client can tell "turned off" from "not running".
+ ///
+ /// A that represents the asynchronous operation.
+ Task StopAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Records in the endpoint file that the user has disabled local integrations.
+ ///
+ void PublishDisabled();
+ }
+}
From cee554588916262a0a81c7fe76c8aa77db44d671 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Wed, 19 Aug 2026 00:25:46 +0200
Subject: [PATCH 3/9] Added IApiConsentService
---
.../SecureFolderFS.Cli/AppApiClient.cs | 2 +-
.../Helpers/SwipeSelectionManager.cs | 4 +-
.../UserControls/Common/GalleryView.cs | 2 +-
.../UnoApiConsentService.cs | 61 ++++++++++
src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs | 95 ++++++++++++++++
.../Models/PairingModels.cs | 38 +++++++
.../Protocol/ApiMessage.cs | 93 +++++++++++++++
.../Protocol/ApiModels.cs | 107 ++++++++++++++++++
.../Services/IApiConsentService.cs | 20 ++++
.../Services/ILocalIntegrationsService.cs | 8 +-
10 files changed, 424 insertions(+), 6 deletions(-)
create mode 100644 src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoApiConsentService.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiMessage.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiConsentService.cs
diff --git a/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs
index eccdffc41..81e0a7a19 100644
--- a/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs
+++ b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs
@@ -11,7 +11,7 @@ namespace SecureFolderFS.Cli;
internal sealed class AppApiClient : IAsyncDisposable
{
private const string CLIENT_NAME = "SecureFolderFS CLI";
- private const int PROTOCOL_VERSION = 0;
+ private const int PROTOCOL_VERSION = 1;
private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(30);
diff --git a/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs b/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs
index 8352e62dc..2fee405dc 100644
--- a/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs
+++ b/src/Platforms/SecureFolderFS.Maui/Helpers/SwipeSelectionManager.cs
@@ -40,13 +40,13 @@ public void UpdateFromRectangle(IList allItems, Func
+ internal sealed class UnoApiConsentService : IApiConsentService
+ {
+ ///
+ public async Task RequestConsentAsync(
+ ApiConsentRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var overlayService = DI.OptionalService();
+ if (overlayService is null) // Unlikely
+ return ApiConsentResult.Denied;
+
+ var viewModel = new ApiConsentOverlayViewModel(
+ request.ClientName,
+ request.Evidence.ExecutablePath,
+ request.Evidence.Signer,
+ request.Evidence.IsVerified,
+ DescribeScopes(request.RequestedScopes));
+
+ var isGranted = false;
+ await App.Instance?.MainWindowSynchronizationContext.PostOrExecuteAsync(async () =>
+ {
+ var result = await overlayService.ShowAsync(viewModel);
+ isGranted = result.Positive();
+ })!;
+
+ return isGranted
+ ? new ApiConsentResult(true, request.RequestedScopes)
+ : ApiConsentResult.Denied;
+ }
+
+ private static IReadOnlyList DescribeScopes(IReadOnlyList scopes)
+ {
+ return scopes.Select(scope => scope switch
+ {
+ ApiConstants.Scopes.VAULTS_READ => "ApiConsentScopeVaultsRead",
+ ApiConstants.Scopes.VAULTS_TRIGGER => "ApiConsentScopeVaultsTrigger",
+ ApiConstants.Scopes.APP_CONTROL => "ApiConsentScopeAppControl",
+ _ => null
+ })
+ .OfType()
+ .Select(key => $"• {key.ToLocalized()}")
+ .ToArray();
+ }
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs
new file mode 100644
index 000000000..2d23a6897
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs
@@ -0,0 +1,95 @@
+using System.Collections.Generic;
+
+namespace SecureFolderFS.Sdk.Api
+{
+ public static class Constants
+ {
+ public const int PROTOCOL_VERSION = 1;
+ public const int PROTOCOL_VERSION_MIN = 1;
+ public const string ENDPOINT_FILE_NAME = "api-endpoint.json";
+ public const string APP_DIRECTORY_NAME = "SecureFolderFS";
+ public const string XDG_DIRECTORY_NAME = "securefolderfs";
+
+ public static class Methods
+ {
+ public const string HELLO = "hello";
+ public const string PAIR = "pair";
+ public const string VAULTS_LIST = "vaults.list";
+ public const string VAULTS_SUBSCRIBE = "vaults.subscribe";
+ public const string VAULTS_UNSUBSCRIBE = "vaults.unsubscribe";
+ public const string VAULTS_REQUEST_UNLOCK = "vaults.requestUnlock";
+ public const string VAULTS_LOCK = "vaults.lock";
+ public const string VAULTS_REVEAL = "vaults.reveal";
+ public const string APP_SHOW = "app.show";
+ }
+
+ public static class Events
+ {
+ public const string VAULT_ADDED = "vault.added";
+ public const string VAULT_REMOVED = "vault.removed";
+ public const string VAULT_RENAMED = "vault.renamed";
+ public const string VAULT_UNLOCKED = "vault.unlocked";
+ public const string VAULT_LOCKED = "vault.locked";
+ }
+
+ public static class Scopes
+ {
+ /// Allows listing vaults and subscribing to changes.
+ public const string VAULTS_READ = "vaults.read";
+
+ /// Allows raising the unlock prompt, locking, and revealing mounted vaults.
+ public const string VAULTS_TRIGGER = "vaults.trigger";
+
+ /// Allows bringing the main window to the foreground.
+ public const string APP_CONTROL = "app.control";
+
+ public static IReadOnlyList All { get; } = [VAULTS_READ, VAULTS_TRIGGER, APP_CONTROL];
+ }
+
+ public static class ErrorCodes
+ {
+ public const string PARSE_ERROR = "parse_error";
+ public const string INVALID_REQUEST = "invalid_request";
+ public const string UNKNOWN_METHOD = "unknown_method";
+ public const string INVALID_PARAMS = "invalid_params";
+ public const string UNAUTHORIZED = "unauthorized";
+ public const string FORBIDDEN_SCOPE = "forbidden_scope";
+ public const string RATE_LIMITED = "rate_limited";
+ public const string PAIRING_REQUIRED = "pairing_required";
+ public const string PAIRING_DENIED = "pairing_denied";
+ public const string PAIRING_UNAVAILABLE = "pairing_unavailable";
+ public const string NOT_FOUND = "not_found";
+ public const string INVALID_STATE = "invalid_state";
+ public const string UNSUPPORTED_VERSION = "unsupported_version";
+ public const string INTERNAL_ERROR = "internal_error";
+ }
+
+ public static class VaultStates
+ {
+ public const string LOCKED = "locked";
+ public const string UNLOCKED = "unlocked";
+ }
+
+ public static class ActionStatus
+ {
+ public const string OK = "ok";
+ public const string ALREADY_PENDING = "already_pending";
+ public const string NO_CHANGE = "no_change";
+ }
+
+ public static class SessionStates
+ {
+ public const string PAIRED = "paired";
+ public const string PAIRING_REQUIRED = "pairing_required";
+ }
+
+ public static class Limits
+ {
+ public const int MAX_MESSAGE_BYTES = 64 * 1024;
+ public const int MAX_CONNECTIONS = 16;
+ public const int MAX_UNAUTHENTICATED_CONNECTIONS = 4;
+ public const int HANDSHAKE_TIMEOUT_SECONDS = 30;
+ public const int MAX_CLIENT_NAME_LENGTH = 64;
+ }
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs
new file mode 100644
index 000000000..f0c6e4de6
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace SecureFolderFS.Sdk.Api.Models
+{
+ ///
+ /// An application the user has authorized to use the local API.
+ ///
+ /// A stable internal identifier for this pairing.
+ /// The name shown in the paired-clients list.
+ /// The Base64 SHA-256 digest of the issued token.
+ /// Whether the platform verified the publisher at pairing time.
+ /// Where the client executable lived at pairing time.
+ /// The verified publisher, when one was established.
+ /// The permissions the user granted.
+ /// When the pairing was established.
+ /// When the pairing was last used to authenticate.
+ public sealed record PairedClient(
+ [property: JsonPropertyName("id")] string Id,
+ [property: JsonPropertyName("displayName")] string DisplayName,
+ [property: JsonPropertyName("tokenHash")] string TokenHash,
+ [property: JsonPropertyName("wasIdentityVerified")] bool WasIdentityVerified,
+ [property: JsonPropertyName("executablePath")] string? ExecutablePath,
+ [property: JsonPropertyName("signer")] string? Signer,
+ [property: JsonPropertyName("scopes")] IReadOnlyList Scopes,
+ [property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
+ [property: JsonPropertyName("lastUsedAt")] DateTimeOffset? LastUsedAt);
+
+ ///
+ /// Records that the user refused an application, so it cannot immediately ask again.
+ ///
+ /// A digest identifying the refused caller.
+ /// When the refusal happened.
+ public sealed record DeniedClient(
+ [property: JsonPropertyName("fingerprint")] string Fingerprint,
+ [property: JsonPropertyName("deniedAt")] DateTimeOffset DeniedAt);
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiMessage.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiMessage.cs
new file mode 100644
index 000000000..36ddc43b7
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiMessage.cs
@@ -0,0 +1,93 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using SecureFolderFS.Sdk.Api.Serialization;
+
+namespace SecureFolderFS.Sdk.Api.Protocol
+{
+ ///
+ /// A single API message that describes a request, response, or notification.
+ ///
+ public sealed record ApiMessage
+ {
+ ///
+ /// Gets the correlation identifier, or for a notification.
+ ///
+ [JsonPropertyName("id")]
+ public long? Id { get; init; }
+
+ ///
+ /// Gets the invoked method or the emitted event name.
+ ///
+ [JsonPropertyName("method")]
+ public string? Method { get; init; }
+
+ ///
+ /// Gets the request or notification payload.
+ ///
+ [JsonPropertyName("params")]
+ public object? Params { get; init; }
+
+ ///
+ /// Gets the successful response payload.
+ ///
+ [JsonPropertyName("result")]
+ public object? Result { get; init; }
+
+ ///
+ /// Gets the failure details, when the request did not succeed.
+ ///
+ [JsonPropertyName("error")]
+ public ApiError? Error { get; init; }
+
+ ///
+ /// Creates a request message.
+ ///
+ public static ApiMessage Request(long id, string method, object? parameters = null)
+ => new() { Id = id, Method = method, Params = parameters };
+
+ ///
+ /// Creates a successful response.
+ ///
+ public static ApiMessage Response(long id, object? result)
+ => new() { Id = id, Result = result };
+
+ ///
+ /// Creates a failure response.
+ ///
+ public static ApiMessage Failure(long? id, string code, string message, int? retryAfterMs = null)
+ => new() { Id = id, Error = new ApiError(code, message, retryAfterMs) };
+
+ ///
+ /// Creates a server-to-client notification, which carries no identifier.
+ ///
+ public static ApiMessage Notification(string method, object? parameters)
+ => new() { Method = method, Params = parameters };
+
+ ///
+ /// Deserializes into .
+ ///
+ /// The type to deserialize into.
+ /// The deserialized object, or when absent or not decodable.
+ public T? GetParams() where T : class
+ {
+ if (Params is not JsonElement element)
+ return Params as T;
+
+ if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
+ return null;
+
+ return element.Deserialize(ApiSerializer.Options);
+ }
+ }
+
+ ///
+ /// A structured failure returned in place of a result.
+ ///
+ /// A stable machine-readable code from .
+ /// A human-readable description.
+ /// When rate limited, how long to wait before retrying.
+ public sealed record ApiError(
+ [property: JsonPropertyName("code")] string Code,
+ [property: JsonPropertyName("message")] string Message,
+ [property: JsonPropertyName("retryAfterMs")] int? RetryAfterMs = null);
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs
new file mode 100644
index 000000000..67e9fd808
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace SecureFolderFS.Sdk.Api.Protocol
+{
+ ///
+ /// The first message a client sends after connecting.
+ ///
+ /// The lowest protocol version the client can speak.
+ /// The highest protocol version the client can speak.
+ /// A human-readable name shown in the consent prompt. Untrusted.
+ /// A token from a previous successful pairing, if the client has one.
+ public sealed record HelloRequest(
+ [property: JsonPropertyName("protocolMin")] int ProtocolMin,
+ [property: JsonPropertyName("protocolMax")] int ProtocolMax,
+ [property: JsonPropertyName("clientName")] string? ClientName,
+ [property: JsonPropertyName("token")] string? Token = null);
+
+ ///
+ /// The server's answer to .
+ ///
+ /// The protocol version that will be used for this session.
+ /// The SecureFolderFS application version, for diagnostics.
+ /// One of .
+ /// The optional features this server supports.
+ /// The scopes granted to this session, empty when not yet paired.
+ public sealed record HelloResponse(
+ [property: JsonPropertyName("protocolVersion")] int ProtocolVersion,
+ [property: JsonPropertyName("serverVersion")] string ServerVersion,
+ [property: JsonPropertyName("state")] string State,
+ [property: JsonPropertyName("capabilities")] IReadOnlyList Capabilities,
+ [property: JsonPropertyName("scopes")] IReadOnlyList Scopes);
+
+ ///
+ /// A request to raise the consent prompt and obtain a token.
+ ///
+ public sealed record PairRequest
+ {
+ ///
+ /// Gets the scopes being requested. When omitted, the default scopes are requested.
+ ///
+ [JsonPropertyName("scopes")]
+ public IReadOnlyList? Scopes { get; init; }
+ }
+
+ ///
+ /// A successful pairing.
+ ///
+ /// The bearer token to persist and present in future handshakes.
+ /// The scopes the user actually granted.
+ public sealed record PairResponse(
+ [property: JsonPropertyName("token")] string Token,
+ [property: JsonPropertyName("scopes")] IReadOnlyList Scopes);
+
+ ///
+ /// A vault as seen by an integration.
+ ///
+ /// The public, stable identifier.
+ /// The vault's display name.
+ /// One of .
+ /// The plaintext root, present only while unlocked.
+ /// When the vault was last opened, if known.
+ public sealed record VaultInfo(
+ [property: JsonPropertyName("id")] string Id,
+ [property: JsonPropertyName("name")] string Name,
+ [property: JsonPropertyName("state")] string State,
+ [property: JsonPropertyName("mountPath")] string? MountPath = null,
+ [property: JsonPropertyName("lastAccess")] DateTimeOffset? LastAccess = null);
+
+ ///
+ /// A snapshot of every vault visible to integrations.
+ ///
+ /// A monotonic revision. A client that observes a gap missed an event and should re-request a snapshot.
+ /// The vaults, in the order the user arranged them.
+ public sealed record VaultListResponse(
+ [property: JsonPropertyName("revision")] long Revision,
+ [property: JsonPropertyName("vaults")] IReadOnlyList Vaults);
+
+ ///
+ /// A request that targets a single vault.
+ ///
+ /// The public identifier from .
+ public sealed record VaultRequest(
+ [property: JsonPropertyName("vaultId")] string? VaultId);
+
+ ///
+ /// A notification carrying a vault's current state.
+ ///
+ public sealed record VaultChangedNotification(
+ [property: JsonPropertyName("revision")] long Revision,
+ [property: JsonPropertyName("vault")] VaultInfo Vault);
+
+ ///
+ /// A notification that a vault is no longer visible to integrations.
+ ///
+ public sealed record VaultRemovedNotification(
+ [property: JsonPropertyName("revision")] long Revision,
+ [property: JsonPropertyName("vaultId")] string VaultId);
+
+ ///
+ /// The outcome of a trigger method.
+ ///
+ /// One of .
+ public sealed record ActionResponse(
+ [property: JsonPropertyName("status")] string Status);
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiConsentService.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiConsentService.cs
new file mode 100644
index 000000000..007d3cda8
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IApiConsentService.cs
@@ -0,0 +1,20 @@
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Sdk.Api.Models;
+
+namespace SecureFolderFS.Sdk.Api.Services
+{
+ ///
+ /// Asks the user whether an application may connect.
+ ///
+ public interface IApiConsentService
+ {
+ ///
+ /// Shows a consent prompt and waits for the user's decision.
+ ///
+ /// The consent request.
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation. Value is the user's answer.'
+ Task RequestConsentAsync(ApiConsentRequest request, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs b/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs
index 7f3d41074..d027e634e 100644
--- a/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs
@@ -27,12 +27,16 @@ public interface ILocalIntegrationsService
/// Withdraws an application's authorization. It must ask the user again to reconnect.
///
/// The identifier from .
- void RevokeClient(string clientId);
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation.
+ Task RevokeClientAsync(string clientId, CancellationToken cancellationToken = default);
///
/// Withdraws every application's authorization and forgets every recorded refusal, so a mistaken
/// denial does not leave the application waiting out the cooldown.
///
- void RevokeAllClients();
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation.
+ Task RevokeAllClientsAsync(CancellationToken cancellationToken = default);
}
}
From 2868c1945e4c0d5c52ce0a0ad0042590dbbfc14f Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 22 Aug 2026 17:09:52 +0200
Subject: [PATCH 4/9] Connect services
---
src/Platforms/SecureFolderFS.UI/Constants.cs | 1 +
src/Platforms/SecureFolderFS.Uno/App.xaml.cs | 162 ++++++++++++------
.../Extensions/UnoIocExtensions.cs | 20 +++
.../UnoLocalIntegrationsService.cs | 67 ++++++++
.../ApiVaultChangedEventArgs.cs | 27 +++
.../Models/ConsentModels.cs | 45 +++++
.../Controls/IntegrationClientViewModel.cs | 43 +++++
7 files changed, 309 insertions(+), 56 deletions(-)
create mode 100644 src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoLocalIntegrationsService.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/EventArguments/ApiVaultChangedEventArgs.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Models/ConsentModels.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/IntegrationClientViewModel.cs
diff --git a/src/Platforms/SecureFolderFS.UI/Constants.cs b/src/Platforms/SecureFolderFS.UI/Constants.cs
index d15f23a5e..8dae42be0 100644
--- a/src/Platforms/SecureFolderFS.UI/Constants.cs
+++ b/src/Platforms/SecureFolderFS.UI/Constants.cs
@@ -19,6 +19,7 @@ public static class FileNames
public const string KEY_FILE_EXTENSION = ".key";
public const string VAULT_SHORTCUT_FILE_EXTENSION = ".sfvault";
public const string ICON_ASSET_PATH = "Assets/AppAssets/app_icon.ico";
+ public const string API_CLIENTS_FILENAME = "api_clients.json";
public static class Accounts
{
diff --git a/src/Platforms/SecureFolderFS.Uno/App.xaml.cs b/src/Platforms/SecureFolderFS.Uno/App.xaml.cs
index 9d1106d39..5413a6652 100644
--- a/src/Platforms/SecureFolderFS.Uno/App.xaml.cs
+++ b/src/Platforms/SecureFolderFS.Uno/App.xaml.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -14,10 +15,13 @@
using Microsoft.UI.Xaml.Media;
using Microsoft.Windows.AppLifecycle;
using OwlCore.Storage;
+using SecureFolderFS.Sdk.Api.Services;
using SecureFolderFS.Sdk.AppModels;
using SecureFolderFS.Sdk.DataModels;
using SecureFolderFS.Sdk.Messages;
using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Sdk.ViewModels;
+using SecureFolderFS.Sdk.ViewModels.Controls.VaultList;
using SecureFolderFS.Sdk.ViewModels.Views.Host;
using SecureFolderFS.Sdk.ViewModels.Views.Root;
using SecureFolderFS.Shared;
@@ -28,6 +32,7 @@
using SecureFolderFS.Storage.VirtualFileSystem;
using SecureFolderFS.UI;
using SecureFolderFS.UI.Helpers;
+using SecureFolderFS.Uno.ServiceImplementation;
using SecureFolderFS.Uno.UserControls.InterfaceRoot;
using Uno.Extensions;
using Uno.UI;
@@ -51,6 +56,15 @@ public partial class App : Application
{
public static App? Instance { get; private set; }
+ ///
+ /// Tracks the vault preview windows currently open, keyed by vault identifier.
+ ///
+ ///
+ /// Only ever touched on the UI thread. Used to collapse repeated unlock requests onto the window
+ /// that is already showing, rather than stacking a new one for each request.
+ ///
+ private readonly Dictionary _openPreviewWindows = new();
+
public bool UseForceClose { get; set; }
public IServiceProvider? ServiceProvider { get; private set; }
@@ -159,21 +173,23 @@ await SafetyHelpers.NoFailureAsync(async () =>
// Prepare MainWindow
EnsureMainWindow(MainWindow, MainViewModel);
+ // Connect the local integration API to live vault state
+ _ = StartLocalIntegrationApiAsync(MainViewModel);
+
#if WINDOWS
// Check if the app was launched via file activation (shortcut file)
var isShortcutActivation = IsShortcutFileActivation(Program.InitialActivationArgs);
- var isUriActivation = IsUriActivation(Program.InitialActivationArgs);
var isStartupActivation = IsStartupActivation(Program.InitialActivationArgs);
// Activate MainWindow (required for initialization)
MainWindow.Activate();
// If launched via shortcut file or on system startup, hide the main window immediately
- if (isShortcutActivation || isUriActivation || isStartupActivation)
+ if (isShortcutActivation || isStartupActivation)
MainWindow.Hide(enableEfficiencyMode: false);
// Show the auto-unlock vault prompt, unless another activation already presents vault UI
- if (!isShortcutActivation && !isUriActivation)
+ if (!isShortcutActivation)
_ = ShowAutoUnlockVaultAsync();
// Process initial file activation if the app was launched via file association
@@ -208,11 +224,6 @@ private static bool IsShortcutFileActivation(AppActivationArguments? args)
storageFile.Path.EndsWith(UI.Constants.FileNames.VAULT_SHORTCUT_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase);
}
- private static bool IsUriActivation(AppActivationArguments? args)
- {
- return args is { Kind: ExtendedActivationKind.Protocol, Data: IProtocolActivatedEventArgs };
- }
-
///
/// Checks if the app was launched on system startup, in which case it should start in the background (System Tray).
///
@@ -243,13 +254,6 @@ public async Task OnActivatedAsync(AppActivationArguments args)
await HandleVaultShortcutActivationAsync(storageFile.Path);
}
- else if (args.Kind == ExtendedActivationKind.Protocol)
- {
- if (args.Data is not IProtocolActivatedEventArgs protocolArgs)
- return;
-
- await HandleUriActivationAsync(protocolArgs.Uri);
- }
}
///
@@ -259,36 +263,56 @@ public async Task OnActivatedAsync(AppActivationArguments args)
public async Task HandleVaultShortcutActivationAsync(string filePath)
{
var shortcutFile = new SystemFileEx(filePath);
- await using var shortcutStream = await shortcutFile.OpenReadAsync(default);
+ await using var shortcutStream = await shortcutFile.OpenReadAsync();
- var shortcutData = await SerializationExtensions.DeserializeAsync(StreamSerializer.Instance, shortcutStream);
+ var shortcutData = await StreamSerializer.Instance.DeserializeAsync(shortcutStream);
if (shortcutData?.PersistableId is null)
return;
await HandleVaultPreviewActivationAsync(shortcutData.PersistableId);
}
- private async Task HandleVaultPreviewActivationAsync(string persistableId)
+ private async Task HandleVaultPreviewActivationAsync(string persistableId)
{
if (MainViewModel is null)
- return;
+ return false;
await MainWindowInitialized.Task;
var listItemViewModel = MainViewModel.VaultListViewModel.Items.FirstOrDefault(x =>
x.VaultViewModel.VaultModel.DataModel.PersistableId == persistableId);
if (listItemViewModel is null)
- return;
+ return false;
var vaultViewModel = listItemViewModel.VaultViewModel;
+ return await ShowVaultPreviewWindowAsync(persistableId, listItemViewModel, vaultViewModel);
+ }
+
+ ///
+ /// Shows the vault preview window, or surfaces the one already open for that vault.
+ ///
+ /// A that represents the asynchronous operation. Value is if a new window was shown; otherwise, .
+ private async Task ShowVaultPreviewWindowAsync(
+ string persistableId,
+ VaultListItemViewModel listItemViewModel,
+ VaultViewModel vaultViewModel)
+ {
+ var shown = false;
await MainWindowSynchronizationContext.PostOrExecuteAsync(async () =>
{
- if (MainViewModel.RootNavigationService.CurrentView is not MainHostViewModel mainHostViewModel)
+ if (MainViewModel?.RootNavigationService.CurrentView is not MainHostViewModel mainHostViewModel)
return;
+ // A prompt for this vault is already up. Bring it forward instead of opening another
+ if (_openPreviewWindows.TryGetValue(persistableId, out var existingWindow))
+ {
+ existingWindow.Activate();
+ return;
+ }
+
// Creating a window while the main window is still running its first layout/render pass
- // segfaults the macOS Skia host, so this must only ever run once the main window has settled
- // (see the remarks on MainWindowInitialized).
+ // segfaults the macOS Skia host, so this must only ever run once the main window has settled.
+ // (see the remarks on MainWindowInitialized)
var window = new Window();
window.Closed += PreviewWindow_Closed;
@@ -310,16 +334,22 @@ await MainWindowSynchronizationContext.PostOrExecuteAsync(async () =>
window.AppWindow.MoveAndResize(new(100, 100, 464, 640));
#endif
+ _openPreviewWindows[persistableId] = window;
+ shown = true;
+
await vaultPreviewViewModel.InitAsync();
window.Activate();
});
- static void PreviewWindow_Closed(object sender, WindowEventArgs args)
+ return shown;
+
+ void PreviewWindow_Closed(object sender, WindowEventArgs args)
{
if (sender is not Window window)
return;
window.Closed -= PreviewWindow_Closed;
+ _openPreviewWindows.Remove(persistableId);
(window.Content as VaultPreviewRootControl)?.ViewModel?.Dispose();
}
}
@@ -340,52 +370,72 @@ private async Task ShowAutoUnlockVaultAsync()
await HandleVaultPreviewActivationAsync(autoUnlockVaultId);
}
- private async Task HandleVaultLockActivationAsync(string persistableId)
+ ///
+ /// Connects the local integration API to live state, and starts it if the user enabled it.
+ ///
+ /// The main view model.
+ /// A that represents the asynchronous operation.
+ ///
+ /// The bridge is attached regardless, so that toggling integrations on later needs no restart.
+ /// Starting the endpoint is separate and strictly opt-in.
+ ///
+ private async Task StartLocalIntegrationApiAsync(MainViewModel mainViewModel)
{
- if (MainViewModel is null)
- return;
-
+ // The vault list must be populated before the first snapshot is taken
await MainWindowInitialized.Task;
- var listItemViewModel = MainViewModel.VaultListViewModel.Items.FirstOrDefault(x =>
- x.VaultViewModel.VaultModel.DataModel.PersistableId == persistableId);
- if (listItemViewModel is null)
- return;
+ try
+ {
+ var bridge = DI.Service();
+ if (bridge is not UnoVaultApiBridge implementation)
+ return;
- var vaultViewModel = listItemViewModel.VaultViewModel;
- if (!vaultViewModel.IsUnlocked)
- return;
+ // Load the pairing store before the bridge derives any public vault identifiers from it
+ await DI.Service().InitAsync();
- await MainWindowSynchronizationContext.PostOrExecuteAsync(async () =>
+ implementation.ShowUnlockPromptAsync = ShowUnlockPromptForApiAsync;
+ implementation.ShowMainWindow = ShowMainWindowForApiAsync;
+ implementation.Attach(mainViewModel, MainWindowSynchronizationContext);
+
+ var settingsService = DI.Service();
+ var apiHost = DI.Service();
+
+ if (settingsService.UserSettings.EnableLocalIntegrations)
+ await apiHost.StartAsync();
+ else
+ apiHost.PublishDisabled();
+ }
+ catch (Exception ex)
{
- WeakReferenceMessenger.Default.Send(new VaultLockRequestedMessage(vaultViewModel.VaultModel));
- });
+ // A failure here must never prevent the app from running normally
+ ApplicationLifecycle.LogException(ex);
+ }
}
///
- /// Handles URI protocol activation (e.g. sffs://vault/preview?id=...).
+ /// Shows the unlock prompt on behalf of an integration.
///
- public async Task HandleUriActivationAsync(Uri uri)
+ /// The view model to show the unlock window for.
+ /// A that represents the asynchronous operation. Value is if a prompt was raised; otherwise, .
+ private async Task ShowUnlockPromptForApiAsync(VaultViewModel vaultViewModel)
{
- if (!uri.Host.Equals("vault", StringComparison.OrdinalIgnoreCase))
- return;
+ var persistableId = vaultViewModel.VaultModel.DataModel.PersistableId;
+ if (string.IsNullOrEmpty(persistableId))
+ return false;
- var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
- var persistableId = query["id"];
- if (persistableId is null)
- return;
+ return await HandleVaultPreviewActivationAsync(persistableId);
+ }
- var action = uri.AbsolutePath.Trim('/');
- switch (action)
+ ///
+ /// Brings the main window to the foreground on behalf of an integration.
+ ///
+ private Task ShowMainWindowForApiAsync()
+ {
+ return MainWindowSynchronizationContext.PostOrExecuteAsync(() =>
{
- case "preview":
- await HandleVaultPreviewActivationAsync(persistableId);
- break;
-
- case "lock":
- await HandleVaultLockActivationAsync(persistableId);
- break;
- }
+ MainWindow?.Activate();
+ return Task.CompletedTask;
+ });
}
#region Window Configuration
diff --git a/src/Platforms/SecureFolderFS.Uno/Extensions/UnoIocExtensions.cs b/src/Platforms/SecureFolderFS.Uno/Extensions/UnoIocExtensions.cs
index cfa31d443..aa0f45461 100644
--- a/src/Platforms/SecureFolderFS.Uno/Extensions/UnoIocExtensions.cs
+++ b/src/Platforms/SecureFolderFS.Uno/Extensions/UnoIocExtensions.cs
@@ -1,7 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using OwlCore.Storage;
+using SecureFolderFS.Sdk.Api.Services;
using SecureFolderFS.Sdk.Services;
using SecureFolderFS.Shared.Extensions;
+using SecureFolderFS.Shared.Models;
using SecureFolderFS.UI.ServiceImplementation;
using SecureFolderFS.UI.ServiceImplementation.Settings;
using SecureFolderFS.Uno.ServiceImplementation;
@@ -14,6 +16,7 @@ internal static class UnoIocExtensions
public static IServiceCollection WithUnoServices(this IServiceCollection serviceCollection, IModifiableFolder settingsFolder)
{
return serviceCollection
+ .WithLocalIntegrationApi(settingsFolder)
.Foundation(AddService.AddSingleton, _ => new(new AppSettings(settingsFolder), new UserSettings(settingsFolder)))
.Foundation(AddService.AddSingleton)
.Foundation(AddService.AddSingleton)
@@ -24,5 +27,22 @@ public static IServiceCollection WithUnoServices(this IServiceCollection service
.Foundation(AddService.AddTransient)
;
}
+
+ private static IServiceCollection WithLocalIntegrationApi(this IServiceCollection serviceCollection, IModifiableFolder settingsFolder)
+ {
+ return serviceCollection
+ .Foundation(AddService.AddSingleton, _ => new PairingStore(UI.Constants.FileNames.API_CLIENTS_FILENAME, settingsFolder, StreamSerializer.Instance))
+ .Foundation(AddService.AddSingleton, sp => new UnoVaultApiBridge(sp.GetRequiredService()))
+ .Foundation(AddService.AddSingleton)
+ .Foundation(AddService.AddSingleton)
+ .Foundation(AddService.AddSingleton, sp => new ApiHost(
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService().AppVersion.ToString()))
+ .Foundation(AddService.AddSingleton)
+ ;
+ }
}
}
diff --git a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoLocalIntegrationsService.cs b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoLocalIntegrationsService.cs
new file mode 100644
index 000000000..0ce9822c2
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoLocalIntegrationsService.cs
@@ -0,0 +1,67 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Sdk.Api.Services;
+using SecureFolderFS.Sdk.Models;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Shared.Extensions;
+
+namespace SecureFolderFS.Uno.ServiceImplementation
+{
+ ///
+ public sealed class UnoLocalIntegrationsService : ILocalIntegrationsService
+ {
+ private readonly IApiHost _apiHost;
+ private readonly IPairingStore _pairingStore;
+ private readonly ISettingsService _settingsService;
+
+ public UnoLocalIntegrationsService(IApiHost apiHost, IPairingStore pairingStore, ISettingsService settingsService)
+ {
+ _apiHost = apiHost;
+ _pairingStore = pairingStore;
+ _settingsService = settingsService;
+ }
+
+ ///
+ public async Task SetEnabledAsync(bool isEnabled, CancellationToken cancellationToken = default)
+ {
+ _settingsService.UserSettings.EnableLocalIntegrations = isEnabled;
+ await _settingsService.UserSettings.TrySaveAsync(cancellationToken);
+
+ if (isEnabled)
+ {
+ await _apiHost.StartAsync(cancellationToken);
+ return;
+ }
+
+ await _apiHost.StopAsync(cancellationToken);
+
+ // Recorded in the endpoint file so a client can tell "switched off" apart from "not running",
+ // and explain to its user why nothing is showing.
+ _apiHost.PublishDisabled();
+ }
+
+ ///
+ public IReadOnlyList GetClients()
+ {
+ return _pairingStore.GetClients()
+ .Select(x => new IntegrationClientInfo(
+ x.Id,
+ x.DisplayName,
+ x.WasIdentityVerified,
+ x.Signer,
+ x.ExecutablePath,
+ x.LastUsedAt))
+ .ToArray();
+ }
+
+ ///
+ public Task RevokeClientAsync(string clientId, CancellationToken cancellationToken = default)
+ => _pairingStore.RevokeClientAsync(clientId, cancellationToken);
+
+ ///
+ public Task RevokeAllClientsAsync(CancellationToken cancellationToken = default)
+ => _pairingStore.RevokeAllAsync(cancellationToken);
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/EventArguments/ApiVaultChangedEventArgs.cs b/src/Sdk/SecureFolderFS.Sdk.Api/EventArguments/ApiVaultChangedEventArgs.cs
new file mode 100644
index 000000000..94a30048d
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/EventArguments/ApiVaultChangedEventArgs.cs
@@ -0,0 +1,27 @@
+using System;
+using SecureFolderFS.Sdk.Api.Protocol;
+using SecureFolderFS.Sdk.Api.Enums;
+
+namespace SecureFolderFS.Sdk.Api.EventArguments
+{
+ ///
+ /// Event arguments for a vault change.
+ ///
+ public sealed class ApiVaultChangedEventArgs(ApiVaultChangeKind kind, string vaultId, VaultInfo? vault) : EventArgs
+ {
+ ///
+ /// Gets the kind of change that occurred.
+ ///
+ public ApiVaultChangeKind Kind { get; } = kind;
+
+ ///
+ /// Gets the public identifier of the affected vault.
+ ///
+ public string VaultId { get; } = vaultId;
+
+ ///
+ /// Gets the vault's new state, or when it was removed.
+ ///
+ public VaultInfo? Vault { get; } = vault;
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Models/ConsentModels.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ConsentModels.cs
new file mode 100644
index 000000000..737c830ed
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ConsentModels.cs
@@ -0,0 +1,45 @@
+using System.Collections.Generic;
+
+namespace SecureFolderFS.Sdk.Api.Models
+{
+ ///
+ /// Describes what the operating system could actually prove about a connecting process.
+ ///
+ /// Whether the platform cryptographically verified the publisher. Always false on macOS and Linux.
+ /// The path of the connecting executable, when obtainable.
+ /// The verified publisher, present only when is true.
+ public sealed record PeerEvidence(
+ bool IsVerified,
+ string? ExecutablePath = null,
+ string? Signer = null)
+ {
+ ///
+ /// Gets an instance representing a peer about which nothing could be determined.
+ ///
+ public static PeerEvidence Unknown { get; } = new(IsVerified: false);
+ }
+
+ ///
+ /// A request to let an application connect.
+ ///
+ /// The client-supplied display name. Untrusted, so always shown next to .
+ /// What the OS could independently determine about the caller.
+ /// The permissions being asked for.
+ public sealed record ApiConsentRequest(
+ string ClientName,
+ PeerEvidence Evidence,
+ IReadOnlyList RequestedScopes);
+
+ ///
+ /// The user's answer to a consent prompt.
+ ///
+ /// Whether the user allowed the connection.
+ /// The permissions the user actually granted.
+ public sealed record ApiConsentResult(bool IsGranted, IReadOnlyList GrantedScopes)
+ {
+ ///
+ /// Gets a result representing a refusal.
+ ///
+ public static ApiConsentResult Denied { get; } = new(false, []);
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/IntegrationClientViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/IntegrationClientViewModel.cs
new file mode 100644
index 000000000..0d1f69345
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Controls/IntegrationClientViewModel.cs
@@ -0,0 +1,43 @@
+using System.ComponentModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using SecureFolderFS.Sdk.Attributes;
+using SecureFolderFS.Sdk.Extensions;
+using SecureFolderFS.Sdk.Models;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Shared;
+
+namespace SecureFolderFS.Sdk.ViewModels.Controls
+{
+ [Bindable(true)]
+ [Inject]
+ public sealed partial class IntegrationClientViewModel : ObservableObject
+ {
+ [ObservableProperty] private string? _DisplayName;
+ [ObservableProperty] private string? _ExecutablePath;
+ [ObservableProperty] private string? _IdentityDescription;
+ [ObservableProperty] private string? _LastUsedDescription;
+
+ public IAsyncRelayCommand RevokeCommand { get; }
+
+ ///
+ /// Gets the underlying pairing information.
+ ///
+ public IntegrationClientInfo ClientInfo { get; }
+
+ public IntegrationClientViewModel(IntegrationClientInfo clientInfo, IAsyncRelayCommand revokeCommand)
+ {
+ ServiceProvider = DI.Default;
+ ClientInfo = clientInfo;
+ RevokeCommand = revokeCommand;
+ DisplayName = ClientInfo.DisplayName;
+ ExecutablePath = ClientInfo.ExecutablePath;
+ IdentityDescription = ClientInfo.IsIdentityVerified && !string.IsNullOrEmpty(ClientInfo.Signer)
+ ? "ApiConsentVerifiedPublisher".ToLocalized(ClientInfo.Signer)
+ : "ApiClientUnverified".ToLocalized();
+ LastUsedDescription = ClientInfo.LastUsedAt is { } lastUsed
+ ? LocalizationService.LocalizeDate(lastUsed.LocalDateTime)
+ : "ApiClientNeverUsed".ToLocalized();
+ }
+ }
+}
From 500c5833b61e39c3916aa70c1577e649f31271ef Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 22 Aug 2026 18:51:05 +0200
Subject: [PATCH 5/9] Added property optionality parameter to SourceGen
---
.../Helpers/PublicVaultId.cs | 37 ++++++
.../Policy/ApiPolicy.cs | 117 ++++++++++++++++++
.../SecureFolderFS.Sdk.Api.csproj | 1 +
.../Services/IPeerEvidenceProvider.cs | 16 +++
.../Services/IVaultApiBridge.cs | 65 ++++++++++
.../Transport/IApiTransport.cs | 89 +++++++++++++
.../Attributes/InjectAttribute.cs | 5 +
.../Settings/PreferencesSettingsViewModel.cs | 82 +++++++++++-
.../Helpers/SourceGeneratorHelpers.cs | 18 +--
.../InjectGenerator.cs | 15 ++-
10 files changed, 429 insertions(+), 16 deletions(-)
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultId.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Policy/ApiPolicy.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/IPeerEvidenceProvider.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Transport/IApiTransport.cs
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultId.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultId.cs
new file mode 100644
index 000000000..13384431f
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultId.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace SecureFolderFS.Sdk.Api.Helpers
+{
+ ///
+ /// Derives the public identifier under which a vault is exposed to integrations, so third-party
+ /// configuration never comes to depend on the app's internal persistence identifier.
+ ///
+ public static class PublicVaultId
+ {
+ // 16 digest bytes equating to a 128-bit identifier
+ private const int ID_BYTE_LENGTH = 16;
+
+ ///
+ /// Computes the public identifier for a vault.
+ ///
+ /// The per-installation secret held by the pairing store.
+ /// The application's internal identifier for the vault.
+ [SkipLocalsInit]
+ public static string Compute(ReadOnlySpan installSecret, string persistableId)
+ {
+ Span digest = stackalloc byte[32];
+ HMACSHA256.HashData(installSecret, Encoding.UTF8.GetBytes(persistableId), digest);
+
+ return ToBase64Url(digest[..ID_BYTE_LENGTH]);
+ }
+
+ ///
+ /// Encodes bytes using the URL-safe Base64 alphabet without padding.
+ ///
+ internal static string ToBase64Url(ReadOnlySpan bytes)
+ => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Policy/ApiPolicy.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Policy/ApiPolicy.cs
new file mode 100644
index 000000000..87ab2783c
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Policy/ApiPolicy.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using SecureFolderFS.Sdk.Api.Enums;
+
+namespace SecureFolderFS.Sdk.Api.Policy
+{
+ ///
+ /// Enforces per-client request budgets using a token bucket per request class.
+ ///
+ public sealed class ApiRateLimiter
+ {
+ private readonly Lock _lock = new();
+ private readonly Dictionary> _clients = [];
+
+ ///
+ /// Consumes budget for a request, if any remains.
+ ///
+ /// Identifies the caller across connections.
+ /// Determines which budget applies.
+ /// Whether the request is allowed, and how long to wait when it is not.
+ public (bool IsAllowed, int RetryAfterMs) Check(string clientKey, ApiRequestType requestType)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var (capacity, refillInterval) = GetBudget(requestType);
+
+ lock (_lock)
+ {
+ if (!_clients.TryGetValue(clientKey, out var buckets))
+ _clients[clientKey] = buckets = [];
+
+ if (!buckets.TryGetValue(requestType, out var bucket))
+ buckets[requestType] = bucket = new Bucket(capacity, now);
+
+ bucket.Refill(now, capacity, refillInterval);
+
+ if (bucket.Tokens >= 1d)
+ {
+ bucket.Tokens--;
+ return (true, 0);
+ }
+
+ var retryAfter = refillInterval * (1d - bucket.Tokens);
+ return (false, (int)retryAfter.TotalMilliseconds);
+ }
+ }
+
+ private static (double Capacity, TimeSpan RefillInterval) GetBudget(ApiRequestType requestType) => requestType switch
+ {
+ // Enumeration is invisible to the user, so the budget only needs to stop runaway polling.
+ ApiRequestType.Read => (60d, TimeSpan.FromSeconds(1)),
+
+ // Roughly five prompts a minute, with a short burst allowance for legitimate rapid use.
+ ApiRequestType.Trigger => (5d, TimeSpan.FromSeconds(12)),
+
+ // One consent prompt at a time, and no rapid retries after a refusal.
+ ApiRequestType.Pairing => (1d, TimeSpan.FromSeconds(60)),
+
+ _ => throw new ArgumentOutOfRangeException(nameof(requestType))
+ };
+
+ private sealed class Bucket(double tokens, DateTimeOffset lastRefill)
+ {
+ private DateTimeOffset _lastRefill = lastRefill;
+
+ public double Tokens { get; set; } = tokens;
+
+ public void Refill(DateTimeOffset now, double capacity, TimeSpan refillInterval)
+ {
+ var elapsed = now - _lastRefill;
+ if (elapsed <= TimeSpan.Zero)
+ return;
+
+ Tokens = Math.Min(capacity, Tokens + elapsed / refillInterval);
+ _lastRefill = now;
+ }
+ }
+ }
+
+ ///
+ /// Collapses duplicate in-flight requests so identical work is never started twice. While a request
+ /// for a given key is outstanding, further requests for the same key are refused entry.
+ ///
+ public sealed class RequestCoalescer
+ {
+ private readonly Lock _lock = new();
+ private readonly HashSet _inFlight = [];
+
+ ///
+ /// Attempts to claim exclusive ownership of a unit of work.
+ ///
+ /// A scope that releases the claim when disposed, or when the
+ /// same work is already in flight.
+ public IDisposable? TryBeginScope(string key)
+ {
+ lock (_lock)
+ {
+ if (!_inFlight.Add(key))
+ return null;
+ }
+
+ return new Scope(this, key);
+ }
+
+ private void End(string key)
+ {
+ lock (_lock)
+ _inFlight.Remove(key);
+ }
+
+ private sealed class Scope(RequestCoalescer owner, string key) : IDisposable
+ {
+ ///
+ public void Dispose() => owner.End(key);
+ }
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj b/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj
index fe26f5b96..e6628d691 100644
--- a/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/SecureFolderFS.Sdk.Api.csproj
@@ -5,6 +5,7 @@
latest
enable
disable
+ true
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IPeerEvidenceProvider.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IPeerEvidenceProvider.cs
new file mode 100644
index 000000000..fecac9bc6
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IPeerEvidenceProvider.cs
@@ -0,0 +1,16 @@
+using SecureFolderFS.Sdk.Api.Transport;
+using SecureFolderFS.Sdk.Api.Models;
+
+namespace SecureFolderFS.Sdk.Api.Services
+{
+ ///
+ /// Gathers best-effort identity information about a connecting process.
+ ///
+ public interface IPeerEvidenceProvider
+ {
+ ///
+ /// Describes the process on the other end of a connection, or .
+ ///
+ PeerEvidence Describe(ApiPeerHandle peer);
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs
new file mode 100644
index 000000000..90bebe4ff
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Sdk.Api.Protocol;
+using SecureFolderFS.Sdk.Api.Enums;
+using SecureFolderFS.Sdk.Api.EventArguments;
+
+namespace SecureFolderFS.Sdk.Api.Services
+{
+ ///
+ /// The single seam through which the API observes and affects live application state, keeping the
+ /// protocol server free of any dependency on the app or its UI framework. Implementations marshal to
+ /// the UI thread.
+ ///
+ public interface IVaultApiBridge
+ {
+ ///
+ /// Gets whether the application has initialized to serve requests.
+ ///
+ bool IsAvailable { get; }
+
+ ///
+ /// Occurs when a vault is added, removed, renamed, unlocked or locked.
+ ///
+ event EventHandler? VaultChanged;
+
+ ///
+ /// Gets every vault the application currently knows about.
+ ///
+ /// A collection of available vaults.
+ IReadOnlyList GetVaults();
+
+ ///
+ /// Asks the application to show its own unlock prompt for a vault.
+ ///
+ /// The identifier for the vault to unlock.
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation. Value is the outcome of the unlock attempt.
+ Task RequestUnlockAsync(string vaultId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Locks an unlocked vault.
+ ///
+ /// The identifier for the vault to lock.
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation. Value is the outcome of the lock attempt.
+ Task LockAsync(string vaultId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Reveals the mounted root of an unlocked vault in the system file manager.
+ ///
+ /// The identifier for the vault to reveal.
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation. Value is the outcome of the reveal attempt.
+ Task RevealAsync(string vaultId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Brings the main application window to the foreground.
+ ///
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation. Value is the outcome of the bring-to-foreground attempt.
+ Task ShowMainWindowAsync(CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Transport/IApiTransport.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Transport/IApiTransport.cs
new file mode 100644
index 000000000..50faefc6b
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Transport/IApiTransport.cs
@@ -0,0 +1,89 @@
+using System;
+using System.IO;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Win32.SafeHandles;
+using SecureFolderFS.Sdk.Api.Enums;
+
+namespace SecureFolderFS.Sdk.Api.Transport
+{
+ ///
+ /// Carries the platform handles needed to identify the process on the other end of a connection.
+ ///
+ public sealed class ApiPeerHandle
+ {
+ ///
+ /// Gets the transport that produced this peer.
+ ///
+ public ApiTransportKind Kind { get; }
+
+ ///
+ /// Gets the server-side pipe handle, when the transport is a named pipe.
+ ///
+ public SafePipeHandle? PipeHandle { get; }
+
+ ///
+ /// Gets the accepted socket, when the transport is a Unix domain socket.
+ ///
+ public Socket? Socket { get; }
+
+ public ApiPeerHandle(SafePipeHandle pipeHandle)
+ {
+ Kind = ApiTransportKind.NamedPipe;
+ PipeHandle = pipeHandle;
+ }
+
+ public ApiPeerHandle(Socket socket)
+ {
+ Kind = ApiTransportKind.UnixSocket;
+ Socket = socket;
+ }
+ }
+
+ ///
+ /// One accepted client connection.
+ ///
+ public interface IApiConnection : IAsyncDisposable
+ {
+ ///
+ /// Gets the duplex byte stream carrying newline-delimited JSON.
+ ///
+ Stream Stream { get; }
+
+ ///
+ /// Gets the handles used to gather evidence about the connecting process.
+ ///
+ ApiPeerHandle Peer { get; }
+ }
+
+ ///
+ /// Listens for local client connections on one platform IPC mechanism.
+ ///
+ public interface IApiTransport : IAsyncDisposable
+ {
+ ///
+ /// Gets the transport mechanism this instance implements.
+ ///
+ ApiTransportKind Kind { get; }
+
+ ///
+ /// Gets the address clients connect to, as advertised in the endpoint file.
+ ///
+ string Address { get; }
+
+ ///
+ /// Binds the endpoint and begins listening.
+ ///
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation.
+ Task StartAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Waits for the next client to connect.
+ ///
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation. Value is the accepted connection.
+ Task AcceptAsync(CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk/Attributes/InjectAttribute.cs b/src/Sdk/SecureFolderFS.Sdk/Attributes/InjectAttribute.cs
index fe86395a7..a30ddab55 100644
--- a/src/Sdk/SecureFolderFS.Sdk/Attributes/InjectAttribute.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/Attributes/InjectAttribute.cs
@@ -15,5 +15,10 @@ public sealed class InjectAttribute : Attribute
/// Gets or sets the value that represents the visibility of the injected class.
///
public string? Visibility { get; set; }
+
+ ///
+ /// Gets or sets the value that represents the optionality of the injected class.
+ ///
+ public string? Optionality { get; set; }
}
}
diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PreferencesSettingsViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PreferencesSettingsViewModel.cs
index ef1e20c2d..044478c13 100644
--- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PreferencesSettingsViewModel.cs
+++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Settings/PreferencesSettingsViewModel.cs
@@ -1,28 +1,53 @@
-using SecureFolderFS.Sdk.Attributes;
+using CommunityToolkit.Mvvm.Input;
+using SecureFolderFS.Sdk.Attributes;
using SecureFolderFS.Sdk.Extensions;
using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Sdk.ViewModels.Controls;
using SecureFolderFS.Sdk.ViewModels.Controls.Banners;
using SecureFolderFS.Shared;
using SecureFolderFS.Shared.Helpers;
+using System;
+using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
namespace SecureFolderFS.Sdk.ViewModels.Views.Settings
{
- [Inject]
+ [Inject, Inject(Optionality = "optional")]
[Bindable(true)]
public sealed partial class PreferencesSettingsViewModel : BaseSettingsViewModel
{
public FileSystemBannerViewModel BannerViewModel { get; }
+ public bool AreIntegrationsSupported { get; }
+
+ ///
+ /// Gets the applications currently authorized to use the local integration API.
+ ///
+ public ObservableCollection ConnectedApps { get; }
+
public PreferencesSettingsViewModel()
{
ServiceProvider = DI.Default;
BannerViewModel = new();
+ ConnectedApps = new();
+ AreIntegrationsSupported = LocalIntegrationsService is not null;
Title = "SettingsPreferences".ToLocalized();
}
+ public bool EnableLocalIntegrations
+ {
+ get => UserSettings.EnableLocalIntegrations;
+ set
+ {
+ if (UserSettings.EnableLocalIntegrations == value)
+ return;
+
+ _ = ApplyIntegrationsAsync(value);
+ }
+ }
+
public bool StartOnSystemStartup
{
get => UserSettings.StartOnSystemStartup;
@@ -83,7 +108,9 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau
{
await BannerViewModel.InitAsync(cancellationToken);
- // Reflect auto start changes made outside the app (e.g. in system settings)
+ RefreshConnectedApps();
+
+ // Reflect auto-start changes made outside the app (e.g., in system settings)
var isAutoStartEnabled = await SafetyHelpers.NoFailureAsync(async () => await SystemService.IsAutoStartEnabledAsync(cancellationToken));
if (UserSettings.StartOnSystemStartup != isAutoStartEnabled)
{
@@ -92,6 +119,55 @@ public override async Task InitAsync(CancellationToken cancellationToken = defau
}
}
+ [RelayCommand]
+ private async Task RevokeAllAppsAsync()
+ {
+ if (LocalIntegrationsService is null)
+ return;
+
+ await LocalIntegrationsService.RevokeAllClientsAsync();
+ ConnectedApps.Clear();
+ }
+
+ [RelayCommand]
+ private async Task RevokeAppAsync(IntegrationClientViewModel client, CancellationToken cancellationToken)
+ {
+ if (LocalIntegrationsService is null)
+ return;
+
+ await LocalIntegrationsService.RevokeClientAsync(client.ClientInfo.Id, cancellationToken);
+ ConnectedApps.Remove(client);
+ }
+
+ private async Task ApplyIntegrationsAsync(bool isEnabled)
+ {
+ if (LocalIntegrationsService is null)
+ return;
+
+ try
+ {
+ await LocalIntegrationsService.SetEnabledAsync(isEnabled);
+ }
+ catch (Exception)
+ {
+ // The endpoint could not be bound, so leave the setting reflecting what actually happened
+ UserSettings.EnableLocalIntegrations = !isEnabled;
+ }
+
+ OnPropertyChanged(nameof(EnableLocalIntegrations));
+ RefreshConnectedApps();
+ }
+
+ private void RefreshConnectedApps()
+ {
+ ConnectedApps.Clear();
+ if (LocalIntegrationsService is null)
+ return;
+
+ foreach (var client in LocalIntegrationsService.GetClients())
+ ConnectedApps.Add(new IntegrationClientViewModel(client, RevokeAppCommand));
+ }
+
private async Task ApplyAutoStartAsync(bool isEnabled)
{
var isApplied = await SafetyHelpers.NoFailureAsync(async () => await SystemService.TrySetAutoStartAsync(isEnabled));
diff --git a/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs b/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs
index 52d1f650c..524b74504 100644
--- a/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs
+++ b/src/Shared/SecureFolderFS.SourceGenerator/Helpers/SourceGeneratorHelpers.cs
@@ -20,13 +20,15 @@ internal static class SourceGeneratorHelpers
///
///
///
- internal static ExpressionSyntax GetLoggerRegistration(string containingTypeName, string serviceProviderName)
+ internal static ExpressionSyntax GetLoggerRegistration(string containingTypeName, string serviceProviderName, bool isRequired)
{
// ServiceProviderServiceExtensions.GetRequiredService(this.ServiceProvider)
+ // or
+ // ServiceProviderServiceExtensions.GetService(this.ServiceProvider)
var getLoggerFactory = InvocationExpression(
MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
IdentifierName("global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions"),
- GenericName("GetRequiredService").WithTypeArgumentList(
+ GenericName(isRequired ? "GetRequiredService" : "GetService").WithTypeArgumentList(
TypeArgumentList(SeparatedList().Add(
ParseTypeName("global::Microsoft.Extensions.Logging.ILoggerFactory"))))))
.AddArgumentListArguments(Argument(GetThisMemberAccessExpression(serviceProviderName)));
@@ -58,8 +60,8 @@ internal static AccessorDeclarationSyntax GetGetter() =>
///
///
///
- internal static PropertyDeclarationSyntax GetPropertyDeclaration(SyntaxKind visibility, string propertyName, string type, params AccessorDeclarationSyntax[] accessors) =>
- PropertyDeclaration(ParseTypeName(type), propertyName)
+ internal static PropertyDeclarationSyntax GetPropertyDeclaration(SyntaxKind visibility, string propertyName, string type, bool isNullable, params AccessorDeclarationSyntax[] accessors) =>
+ PropertyDeclaration(ParseNullableType(type, isNullable), propertyName)
.AddModifiers(Token(visibility))
.AddAccessorListAccessors(accessors);
@@ -70,8 +72,8 @@ internal static PropertyDeclarationSyntax GetPropertyDeclaration(SyntaxKind visi
///
///
///
- internal static PropertyDeclarationSyntax GetPropertyDeclaration(SyntaxKind visibility, string propertyName, string type) =>
- PropertyDeclaration(ParseTypeName(type), propertyName)
+ internal static PropertyDeclarationSyntax GetPropertyDeclaration(SyntaxKind visibility, string propertyName, string type, bool isNullable) =>
+ PropertyDeclaration(ParseNullableType(type, isNullable), propertyName)
.AddModifiers(Token(visibility));
///
@@ -162,10 +164,10 @@ internal static CompilationUnitSyntax GetCompilationUnit(MemberDeclarationSyntax
///
///
///
- internal static ExpressionSyntax GetServiceRegistration(ITypeSymbol injectionType, string serviceProviderName) =>
+ internal static ExpressionSyntax GetServiceRegistration(ITypeSymbol injectionType, string serviceProviderName, bool isRequired) =>
InvocationExpression(MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
IdentifierName("global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions"),
- GenericName("GetRequiredService").WithTypeArgumentList(TypeArgumentList(SeparatedList().Add(ParseTypeName(injectionType.ToDisplayString()))))))
+ GenericName(isRequired ? "GetRequiredService" : "GetService").WithTypeArgumentList(TypeArgumentList(SeparatedList().Add(ParseTypeName(injectionType.ToDisplayString()))))))
.AddArgumentListArguments(Argument(GetThisMemberAccessExpression(serviceProviderName)));
///
diff --git a/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs b/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs
index 292a2b985..a212dc166 100644
--- a/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs
+++ b/src/Shared/SecureFolderFS.SourceGenerator/InjectGenerator.cs
@@ -22,7 +22,7 @@ public sealed class InjectGenerator : AttributeWithTypeGenerator
var members = new List();
var getter = GetGetter();
- var serviceProviderProperty = GetPropertyDeclaration(SyntaxKind.PrivateKeyword, Constants.ServiceProviderName, Constants.ServiceProviderNamespace, getter)
+ var serviceProviderProperty = GetPropertyDeclaration(SyntaxKind.PrivateKeyword, Constants.ServiceProviderName, Constants.ServiceProviderNamespace, false, getter)
.AddAttributeLists(GetAttributeForMethod(Constants.AssemblyName, Constants.AssemblyVersion, nameof(SecureFolderFS)));
members.Add(serviceProviderProperty);
@@ -31,6 +31,7 @@ public sealed class InjectGenerator : AttributeWithTypeGenerator
{
var name = string.Empty;
var visibility = SyntaxKind.None;
+ var isRequired = true;
if (attribute.AttributeClass is not { TypeArguments: [var type, ..] })
return null;
@@ -48,6 +49,10 @@ public sealed class InjectGenerator : AttributeWithTypeGenerator
case "Visibility":
visibility = GetVisibility((string)value);
break;
+
+ case "Optionality":
+ isRequired = (string)value != "optional";
+ break;
}
}
}
@@ -64,11 +69,11 @@ public sealed class InjectGenerator : AttributeWithTypeGenerator
var loggerTypeName = $"global::Microsoft.Extensions.Logging.ILogger<{containingTypeName}>";
var loggerField = GetFieldDeclaration(SyntaxKind.PrivateKeyword, backingFieldName, loggerTypeName, true);
- var loggerProperty = GetPropertyDeclaration(visibility, name, loggerTypeName).WithExpressionBody(
+ var loggerProperty = GetPropertyDeclaration(visibility, name, loggerTypeName, !isRequired).WithExpressionBody(
ArrowExpressionClause(
AssignmentExpression(SyntaxKind.CoalesceAssignmentExpression,
GetThisMemberAccessExpression(backingFieldName),
- GetLoggerRegistration(containingTypeName, Constants.ServiceProviderName))))
+ GetLoggerRegistration(containingTypeName, Constants.ServiceProviderName, isRequired))))
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken))
.AddAttributeLists(GetAttributeForMethod(Constants.AssemblyName, Constants.AssemblyVersion, nameof(InjectGenerator)));
@@ -81,11 +86,11 @@ public sealed class InjectGenerator : AttributeWithTypeGenerator
var backingFieldName = $"_{name}";
var injecteeField = GetFieldDeclaration(SyntaxKind.PrivateKeyword, backingFieldName, type.ToDisplayString(), true);
- var injecteeProperty = GetPropertyDeclaration(visibility, name, type.ToDisplayString()).WithExpressionBody(
+ var injecteeProperty = GetPropertyDeclaration(visibility, name, type.ToDisplayString(), !isRequired).WithExpressionBody(
ArrowExpressionClause(
AssignmentExpression(SyntaxKind.CoalesceAssignmentExpression,
GetThisMemberAccessExpression(backingFieldName),
- GetServiceRegistration(type, Constants.ServiceProviderName))))
+ GetServiceRegistration(type, Constants.ServiceProviderName, isRequired))))
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken))
.AddAttributeLists(GetAttributeForMethod(Constants.AssemblyName, Constants.AssemblyVersion, nameof(InjectGenerator)));
From 717cea586b6c54dbc1ab8eeb852c7f95690f278c Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Sat, 22 Aug 2026 19:16:25 +0200
Subject: [PATCH 6/9] Added Named Pipe transport
---
.../Transport/NamedPipeApiTransport.cs | 156 ++++++++++++++++++
1 file changed, 156 insertions(+)
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Transport/NamedPipeApiTransport.cs
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Transport/NamedPipeApiTransport.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Transport/NamedPipeApiTransport.cs
new file mode 100644
index 000000000..2034a6341
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Transport/NamedPipeApiTransport.cs
@@ -0,0 +1,156 @@
+using System;
+using System.IO;
+using System.IO.Pipes;
+using System.Runtime.Versioning;
+using System.Security.AccessControl;
+using System.Security.Principal;
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Sdk.Api.Enums;
+
+namespace SecureFolderFS.Sdk.Api.Transport
+{
+ ///
+ /// Serves the API over a Windows named pipe restricted to the current user.
+ ///
+ [SupportedOSPlatform("windows")]
+ public sealed class NamedPipeApiTransport : IApiTransport
+ {
+ private readonly string _pipeName;
+ private readonly PipeSecurity _pipeSecurity;
+ private NamedPipeServerStream? _pendingInstance;
+ private bool _disposed;
+
+ ///
+ public ApiTransportKind Kind => ApiTransportKind.NamedPipe;
+
+ ///
+ public string Address => $@"\\.\pipe\{_pipeName}";
+
+ public NamedPipeApiTransport(string pipeName)
+ {
+ _pipeName = pipeName;
+ _pipeSecurity = CreateSecurity();
+ }
+
+ ///
+ public Task StartAsync(CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ // Create the first instance eagerly so the pipe is connectable as soon as the host starts
+ _pendingInstance ??= CreateInstance();
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task AcceptAsync(CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ var instance = _pendingInstance ?? CreateInstance();
+ _pendingInstance = null;
+
+ try
+ {
+ await instance.WaitForConnectionAsync(cancellationToken);
+ }
+ catch
+ {
+ await instance.DisposeAsync();
+ throw;
+ }
+
+ // Stand up the next instance immediately so the pipe name never briefly disappears
+ _pendingInstance = CreateInstance();
+
+ return new NamedPipeConnection(instance);
+ }
+
+ private NamedPipeServerStream CreateInstance()
+ {
+ return NamedPipeServerStreamAcl.Create(
+ _pipeName,
+ PipeDirection.InOut,
+ Constants.Limits.MAX_CONNECTIONS,
+ PipeTransmissionMode.Byte,
+ PipeOptions.Asynchronous,
+ inBufferSize: 0,
+ outBufferSize: 0,
+ _pipeSecurity);
+ }
+
+ private static PipeSecurity CreateSecurity()
+ {
+ var security = new PipeSecurity();
+ var currentUser = WindowsIdentity.GetCurrent().User
+ ?? throw new InvalidOperationException("Unable to determine the current user's SID.");
+
+ // Only the user running SecureFolderFS may talk to the pipe
+ security.AddAccessRule(new PipeAccessRule(
+ currentUser,
+ PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance,
+ AccessControlType.Allow));
+
+ // Named pipes are reachable remotely over SMB. Deny ACEs (ordered ahead of allow) close that
+ // path for a remote logon as the same account, without affecting local clients.
+ security.AddAccessRule(new PipeAccessRule(
+ new SecurityIdentifier(WellKnownSidType.NetworkSid, null),
+ PipeAccessRights.FullControl,
+ AccessControlType.Deny));
+
+ security.AddAccessRule(new PipeAccessRule(
+ new SecurityIdentifier(WellKnownSidType.AnonymousSid, null),
+ PipeAccessRights.FullControl,
+ AccessControlType.Deny));
+
+ return security;
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ if (_pendingInstance is not null)
+ await _pendingInstance.DisposeAsync();
+
+ _pendingInstance = null;
+ }
+
+ private sealed class NamedPipeConnection : IApiConnection
+ {
+ private readonly NamedPipeServerStream _pipe;
+
+ ///
+ public Stream Stream => _pipe;
+
+ ///
+ public ApiPeerHandle Peer { get; }
+
+ public NamedPipeConnection(NamedPipeServerStream pipe)
+ {
+ _pipe = pipe;
+ Peer = new ApiPeerHandle(pipe.SafePipeHandle);
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ if (_pipe.IsConnected)
+ _pipe.Disconnect();
+ }
+ catch (Exception)
+ {
+ // The peer may already be gone. We don't care, only dispose
+ }
+
+ await _pipe.DisposeAsync();
+ }
+ }
+ }
+}
From 932655a8c25f737750a3158595bcdbaa09437dac Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Mon, 24 Aug 2026 11:28:57 +0200
Subject: [PATCH 7/9] Added Unix socket transport
---
.../PInvoke/UnsafeNative.Imports.cs | 13 +
src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs | 2 +
.../Models/PairingModels.cs | 23 ++
.../Services/IPairingStore.cs | 87 ++++++
.../Services/PairingStore.cs | 282 ++++++++++++++++++
.../Transport/UnixSocketApiTransport.cs | 180 +++++++++++
6 files changed, 587 insertions(+)
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/IPairingStore.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Transport/UnixSocketApiTransport.cs
diff --git a/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs b/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs
index ad9f39009..4247d5096 100644
--- a/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs
+++ b/src/Platforms/SecureFolderFS.Uno/PInvoke/UnsafeNative.Imports.cs
@@ -1,6 +1,7 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
+using Microsoft.Win32.SafeHandles;
namespace SecureFolderFS.Uno.PInvoke
{
@@ -30,6 +31,10 @@ internal static partial class UnsafeNative
[DllImport("user32.dll", SetLastError = true)]
public static extern uint GetDpiForWindow(IntPtr hWnd);
+ [LibraryImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static partial bool GetNamedPipeClientProcessId(SafePipeHandle pipe, out uint clientProcessId);
+
[DllImport("comctl32.dll", SetLastError = true)]
public static extern bool SetWindowSubclass(
IntPtr hWnd,
@@ -118,6 +123,11 @@ public delegate IntPtr SUBCLASSPROC(
UIntPtr uIdSubclass,
IntPtr dwRefData);
#endif
+
+#if !WINDOWS
+ [LibraryImport("libc", SetLastError = true)]
+ public static partial int getsockopt(int socket, int level, int optionName, byte[] optionValue, ref int optionLength);
+#endif
#if __UNO_SKIA_MACOS__
public const string LibObjc = "libobjc.dylib";
@@ -300,6 +310,9 @@ public static partial void CFNotificationCenterRemoveObserver(
public static partial void objc_msgSend_void_long_IntPtr_IntPtr(IntPtr receiver, IntPtr selector, long arg1, IntPtr arg2, IntPtr arg3);
#endregion
+
+ [LibraryImport("libproc", SetLastError = true)]
+ public static partial int proc_pidpath(int pid, byte[] buffer, uint bufferSize);
public static IntPtr CfString(string value)
{
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs
index 2d23a6897..dc3d81518 100644
--- a/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Constants.cs
@@ -9,6 +9,8 @@ public static class Constants
public const string ENDPOINT_FILE_NAME = "api-endpoint.json";
public const string APP_DIRECTORY_NAME = "SecureFolderFS";
public const string XDG_DIRECTORY_NAME = "securefolderfs";
+ public const string TRANSPORT_NAMED_PIPE = "namedPipe";
+ public const string TRANSPORT_UNIX_SOCKET = "unixSocket";
public static class Methods
{
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs
index f0c6e4de6..991c06f1b 100644
--- a/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Models/PairingModels.cs
@@ -35,4 +35,27 @@ public sealed record PairedClient(
public sealed record DeniedClient(
[property: JsonPropertyName("fingerprint")] string Fingerprint,
[property: JsonPropertyName("deniedAt")] DateTimeOffset DeniedAt);
+
+ public sealed class PairingStoreDataModel
+ {
+ [JsonPropertyName("schemaVersion")]
+ public int SchemaVersion { get; set; } = 1;
+
+ [JsonPropertyName("vaultIdKey")]
+ public string VaultIdKey { get; set; } = string.Empty;
+
+ [JsonPropertyName("clients")]
+ public List Clients { get; set; } = [];
+
+ [JsonPropertyName("denied")]
+ public List Denied { get; set; } = [];
+
+ public PairingStoreDataModel Clone() => new()
+ {
+ SchemaVersion = SchemaVersion,
+ VaultIdKey = VaultIdKey,
+ Clients = [.. Clients],
+ Denied = [.. Denied]
+ };
+ }
}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IPairingStore.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IPairingStore.cs
new file mode 100644
index 000000000..52abf089a
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IPairingStore.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Sdk.Api.Models;
+using SecureFolderFS.Shared.ComponentModel;
+
+namespace SecureFolderFS.Sdk.Api.Services
+{
+ ///
+ /// Persists the applications the user has authorized and the tokens that identify them.
+ ///
+ ///
+ /// Tokens are stored only as SHA-256 digests. An individual token carries 256 bits of generator entropy,
+ /// so a plain digest is the right construction.
+ ///
+ public interface IPairingStore : IPersistable
+ {
+ ///
+ /// Gets the per-installation vault ID key used to derive public vault identifiers.
+ ///
+ byte[] VaultIdKey { get; }
+
+ ///
+ /// Gets how long an application must wait before prompting again after being refused.
+ ///
+ TimeSpan DenialCooldown { get; }
+
+ ///
+ /// Gets every currently paired application.
+ ///
+ IReadOnlyList GetClients();
+
+ ///
+ /// Finds the pairing a token belongs to, or when unrecognized.
+ ///
+ PairedClient? Authenticate(string? token);
+
+ ///
+ /// Determines whether an application is still within its post-refusal cooldown.
+ ///
+ /// The fingerprint to check against.
+ /// True if is in cooldown; otherwise, false.
+ bool IsInDenialCooldown(string fingerprint);
+
+ ///
+ /// Records that a pairing was used, for display in the paired-clients list.
+ ///
+ /// The identifier of the pairing.
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation.
+ Task TouchLastUsedAsync(string clientId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Creates a pairing and issues its token, which is never stored in recoverable form.
+ ///
+ /// The display name of the application.
+ /// The peer evidence of the application.
+ /// The scopes the application is requesting.
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation. Value is the issued token.
+ Task CreateClientAsync(string displayName, PeerEvidence evidence, IReadOnlyList scopes, CancellationToken cancellationToken = default);
+
+ ///
+ /// Removes a pairing, so the application must ask for consent again.
+ ///
+ /// The identifier of the pairing.
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation.
+ Task RevokeClientAsync(string clientId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Removes every pairing and forgets every recorded refusal.
+ ///
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation.
+ Task RevokeAllAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Records that the user refused an application.
+ ///
+ /// The fingerprint of the application.
+ /// A that cancels this action.
+ /// A that represents the asynchronous operation.
+ Task RecordDenialAsync(string fingerprint, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs
new file mode 100644
index 000000000..69a8fabc0
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs
@@ -0,0 +1,282 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using OwlCore.Storage;
+using SecureFolderFS.Sdk.Api.Helpers;
+using SecureFolderFS.Sdk.Api.Models;
+using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Shared.Extensions;
+
+namespace SecureFolderFS.Sdk.Api.Services
+{
+ ///
+ public sealed class PairingStore : IPairingStore
+ {
+ private readonly string _fileName;
+ private readonly IModifiableFolder _settingsFolder;
+ private readonly IAsyncSerializer _serializer;
+ private readonly Lock _lock = new();
+ private readonly SemaphoreSlim _fileLock = new(1, 1);
+
+ private PairingStoreDataModel _data = new();
+ private IFile? _file;
+ private bool _initialized;
+
+ ///
+ public TimeSpan DenialCooldown { get; } = TimeSpan.FromMinutes(5);
+
+ public PairingStore(string fileName, IModifiableFolder settingsFolder, IAsyncSerializer serializer)
+ {
+ _fileName = fileName;
+ _settingsFolder = settingsFolder;
+ _serializer = serializer;
+ }
+
+ ///
+ public byte[] VaultIdKey
+ {
+ get
+ {
+ lock (_lock)
+ {
+ EnsureInitialized();
+ return Convert.FromBase64String(_data.VaultIdKey);
+ }
+ }
+ }
+
+ ///
+ public IReadOnlyList GetClients()
+ {
+ lock (_lock)
+ return _data.Clients.ToArray();
+ }
+
+ ///
+ public PairedClient? Authenticate(string? token)
+ {
+ if (string.IsNullOrEmpty(token))
+ return null;
+
+ var candidateHash = ComputeTokenHash(token);
+ lock (_lock)
+ {
+ // Compared in constant time so a caller cannot learn a valid digest by timing rejections
+ return _data.Clients.FirstOrDefault(x => FixedTimeEqualsBase64(x.TokenHash, candidateHash));
+ }
+ }
+
+ ///
+ public bool IsInDenialCooldown(string fingerprint)
+ {
+ lock (_lock)
+ {
+ var entry = _data.Denied.Find(x => x.Fingerprint == fingerprint);
+ if (entry is null)
+ return false;
+
+ if (DateTimeOffset.UtcNow - entry.DeniedAt < DenialCooldown)
+ return true;
+
+ // Expired: drop it from memory; the file is reconciled on the next save
+ _data.Denied.Remove(entry);
+ return false;
+ }
+ }
+
+ ///
+ public Task TouchLastUsedAsync(string clientId, CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ EnsureInitialized();
+
+ var index = _data.Clients.FindIndex(x => x.Id == clientId);
+ if (index < 0)
+ return Task.CompletedTask;
+
+ _data.Clients[index] = _data.Clients[index] with { LastUsedAt = DateTimeOffset.UtcNow };
+ }
+
+ return SaveAsync(cancellationToken);
+ }
+
+ ///
+ public async Task CreateClientAsync(
+ string displayName, PeerEvidence evidence, IReadOnlyList scopes, CancellationToken cancellationToken = default)
+ {
+ var token = PublicVaultId.ToBase64Url(RandomNumberGenerator.GetBytes(32));
+ var client = new PairedClient(
+ Id: Guid.NewGuid().ToString("N"),
+ DisplayName: displayName,
+ TokenHash: ComputeTokenHash(token),
+ WasIdentityVerified: evidence.IsVerified,
+ ExecutablePath: evidence.ExecutablePath,
+ Signer: evidence.Signer,
+ Scopes: scopes,
+ CreatedAt: DateTimeOffset.UtcNow,
+ LastUsedAt: null);
+
+ lock (_lock)
+ {
+ EnsureInitialized();
+ _data.Clients.Add(client);
+ }
+
+ await SaveAsync(cancellationToken);
+ return token;
+ }
+
+ ///
+ public Task RevokeClientAsync(string clientId, CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ EnsureInitialized();
+ if (_data.Clients.RemoveAll(x => x.Id == clientId) == 0)
+ return Task.CompletedTask;
+ }
+
+ return SaveAsync(cancellationToken);
+ }
+
+ ///
+ public Task RevokeAllAsync(CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ EnsureInitialized();
+ if (_data.Clients.Count == 0 && _data.Denied.Count == 0)
+ return Task.CompletedTask;
+
+ _data.Clients.Clear();
+ _data.Denied.Clear();
+ }
+
+ return SaveAsync(cancellationToken);
+ }
+
+ ///
+ public Task RecordDenialAsync(string fingerprint, CancellationToken cancellationToken = default)
+ {
+ lock (_lock)
+ {
+ EnsureInitialized();
+ _data.Denied.RemoveAll(x => x.Fingerprint == fingerprint);
+ _data.Denied.Add(new DeniedClient(fingerprint, DateTimeOffset.UtcNow));
+ }
+
+ return SaveAsync(cancellationToken);
+ }
+
+ ///
+ public async Task InitAsync(CancellationToken cancellationToken = default)
+ {
+ await _fileLock.WaitAsync(cancellationToken);
+ try
+ {
+ if (_initialized)
+ return;
+
+ _file ??= await _settingsFolder.CreateFileAsync(_fileName, false, cancellationToken);
+
+ var loaded = await TryReadAsync(_file, cancellationToken);
+ if (loaded is not null && !string.IsNullOrEmpty(loaded.VaultIdKey))
+ {
+ lock (_lock)
+ _data = loaded;
+ }
+ else
+ {
+ // A missing or corrupt store is replaced which revokes every pairing
+ PairingStoreDataModel fresh;
+ lock (_lock)
+ fresh = _data = new PairingStoreDataModel { VaultIdKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) };
+
+ await WriteAsync(_file, fresh, cancellationToken);
+ }
+
+ _initialized = true;
+ }
+ finally
+ {
+ _fileLock.Release();
+ }
+ }
+
+ ///
+ public async Task SaveAsync(CancellationToken cancellationToken = default)
+ {
+ await _fileLock.WaitAsync(cancellationToken);
+ try
+ {
+ _file ??= await _settingsFolder.CreateFileAsync(_fileName, false, cancellationToken);
+
+ PairingStoreDataModel snapshot;
+ lock (_lock)
+ snapshot = _data.Clone();
+
+ await WriteAsync(_file, snapshot, cancellationToken);
+ }
+ finally
+ {
+ _fileLock.Release();
+ }
+ }
+
+ private async Task TryReadAsync(IFile file, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await using var stream = await file.OpenReadAsync(cancellationToken);
+ return await _serializer.DeserializeAsync(stream, cancellationToken);
+ }
+ catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private async Task WriteAsync(IFile file, PairingStoreDataModel data, CancellationToken cancellationToken)
+ {
+ await using var destination = await file.OpenWriteAsync(cancellationToken);
+ await using var serialized = await _serializer.SerializeAsync(data, cancellationToken);
+
+ destination.SetLength(0L);
+ serialized.Position = 0L;
+ await serialized.CopyToAsync(destination, cancellationToken);
+ await destination.FlushAsync(cancellationToken);
+ }
+
+ private static string ComputeTokenHash(string token)
+ => Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
+
+ private static bool FixedTimeEqualsBase64(string left, string right)
+ {
+ try
+ {
+ return CryptographicOperations.FixedTimeEquals(
+ Convert.FromBase64String(left),
+ Convert.FromBase64String(right));
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void EnsureInitialized()
+ {
+ if (!_initialized)
+ throw new InvalidOperationException($"{nameof(PairingStore)} must be initialized before use.");
+ }
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Transport/UnixSocketApiTransport.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Transport/UnixSocketApiTransport.cs
new file mode 100644
index 000000000..a21803b11
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Transport/UnixSocketApiTransport.cs
@@ -0,0 +1,180 @@
+using System;
+using System.IO;
+using System.Net.Sockets;
+using System.Runtime.Versioning;
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Sdk.Api.Enums;
+
+namespace SecureFolderFS.Sdk.Api.Transport
+{
+ ///
+ /// Serves the API over a Unix domain socket restricted to the current user.
+ ///
+ ///
+ /// The socket file is owner-only, but the guarantee that matters is the containing directory being
+ /// owner-only, which is verified before the socket is bound.
+ ///
+ [UnsupportedOSPlatform("windows")]
+ public sealed class UnixSocketApiTransport : IApiTransport
+ {
+ private const int LISTEN_BACKLOG = 16;
+
+ private const UnixFileMode OwnerOnlyDirectory =
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
+
+ private const UnixFileMode OwnerOnlyFile =
+ UnixFileMode.UserRead | UnixFileMode.UserWrite;
+
+ private const UnixFileMode GroupOrOtherAccess =
+ UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
+ UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
+
+ private readonly string _socketPath;
+ private Socket? _listener;
+ private bool _disposed;
+
+ ///
+ public ApiTransportKind Kind => ApiTransportKind.UnixSocket;
+
+ ///
+ public string Address => _socketPath;
+
+ public UnixSocketApiTransport(string socketPath)
+ {
+ _socketPath = socketPath;
+ }
+
+ ///
+ public async Task StartAsync(CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ EnsureOwnerOnlyDirectory(Path.GetDirectoryName(_socketPath)!);
+ await ClearStaleSocketAsync(cancellationToken);
+
+ var listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+ try
+ {
+ listener.Bind(new UnixDomainSocketEndPoint(_socketPath));
+ File.SetUnixFileMode(_socketPath, OwnerOnlyFile);
+ listener.Listen(LISTEN_BACKLOG);
+ }
+ catch
+ {
+ listener.Dispose();
+ throw;
+ }
+
+ _listener = listener;
+ }
+
+ ///
+ public async Task AcceptAsync(CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ if (_listener is null)
+ throw new InvalidOperationException($"{nameof(StartAsync)} must be called before accepting connections.");
+
+ var accepted = await _listener.AcceptAsync(cancellationToken);
+ return new UnixSocketConnection(accepted);
+ }
+
+ ///
+ /// Creates the containing directory as owner-only, or verifies an existing one is safe to use.
+ ///
+ private static void EnsureOwnerOnlyDirectory(string directoryPath)
+ {
+ if (!Directory.Exists(directoryPath))
+ {
+ _ = Directory.CreateDirectory(directoryPath, OwnerOnlyDirectory);
+ return;
+ }
+
+ var info = new DirectoryInfo(directoryPath);
+
+ // A symlink here could redirect the socket somewhere world-writable
+ if (info.LinkTarget is not null)
+ throw new IOException($"Refusing to use '{directoryPath}' for the API socket because it is a symbolic link.");
+
+ // A directory another user can write to would let them replace or observe the socket
+ var mode = File.GetUnixFileMode(directoryPath);
+ if ((mode & GroupOrOtherAccess) != 0)
+ throw new IOException($"Refusing to use '{directoryPath}' for the API socket because it is accessible to users other than the owner (mode: {mode}).");
+ }
+
+ ///
+ /// Removes a socket file left behind by a previous run, distinguishing a live instance from a
+ /// stale file so a second instance fails instead of hijacking the endpoint.
+ ///
+ private async Task ClearStaleSocketAsync(CancellationToken cancellationToken)
+ {
+ if (!File.Exists(_socketPath))
+ return;
+
+ using var probe = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+ try
+ {
+ await probe.ConnectAsync(new UnixDomainSocketEndPoint(_socketPath), cancellationToken);
+ }
+ catch (SocketException)
+ {
+ // Nothing is listening, so the file is a leftover and is safe to remove.
+ File.Delete(_socketPath);
+ return;
+ }
+
+ throw new IOException($"Another SecureFolderFS instance is already serving the API on '{_socketPath}'.");
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ return ValueTask.CompletedTask;
+
+ _disposed = true;
+ _listener?.Dispose();
+ _listener = null;
+
+ try
+ {
+ if (File.Exists(_socketPath))
+ File.Delete(_socketPath);
+ }
+ catch (Exception)
+ {
+ // A leftover socket file is recovered from on next start by ClearStaleSocketAsync
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ private sealed class UnixSocketConnection : IApiConnection
+ {
+ private readonly Socket _socket;
+ private readonly NetworkStream _stream;
+
+ ///
+ public Stream Stream => _stream;
+
+ ///
+ public ApiPeerHandle Peer { get; }
+
+ public UnixSocketConnection(Socket socket)
+ {
+ _socket = socket;
+ _stream = new NetworkStream(socket, ownsSocket: false);
+ Peer = new ApiPeerHandle(socket);
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await _stream.DisposeAsync();
+ _socket.Dispose();
+ }
+ }
+ }
+}
From df9f4a2fc9135206edcc2e34f38905745690d2a3 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Mon, 24 Aug 2026 14:01:37 +0200
Subject: [PATCH 8/9] Moved things around
---
.../Strings/en-US/Resources.resx | 57 +++++
.../PeerEvidenceProvider.cs | 155 +++++++++++++
.../Settings/PreferencesSettingsPage.xaml | 82 +++++++
.../ApiVaultChangedEventArgs.cs | 2 +-
.../Helpers/ApiDiscoveryHelpers.cs | 137 +++++++++++
.../ApiRateLimiterHelper.cs} | 44 +---
...blicVaultId.cs => PublicVaultIdHelpers.cs} | 2 +-
.../Helpers/RequestCoalescerHelper.cs | 44 ++++
.../Models/ApiEndpointInfo.cs | 20 ++
.../ApiMessageModels.cs} | 0
.../{Protocol => Models}/ApiModels.cs | 2 +-
.../Serialization/NdjsonChannel.cs | 184 +++++++++++++++
.../Services/ApiHost.cs | 216 ++++++++++++++++++
.../Services/IVaultApiBridge.cs | 2 +-
.../Services/PairingStore.cs | 2 +-
15 files changed, 903 insertions(+), 46 deletions(-)
create mode 100644 src/Platforms/SecureFolderFS.Uno/ServiceImplementation/PeerEvidenceProvider.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Helpers/ApiDiscoveryHelpers.cs
rename src/Sdk/SecureFolderFS.Sdk.Api/{Policy/ApiPolicy.cs => Helpers/ApiRateLimiterHelper.cs} (69%)
rename src/Sdk/SecureFolderFS.Sdk.Api/Helpers/{PublicVaultId.cs => PublicVaultIdHelpers.cs} (96%)
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Helpers/RequestCoalescerHelper.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiEndpointInfo.cs
rename src/Sdk/SecureFolderFS.Sdk.Api/{Protocol/ApiMessage.cs => Models/ApiMessageModels.cs} (100%)
rename src/Sdk/SecureFolderFS.Sdk.Api/{Protocol => Models}/ApiModels.cs (99%)
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Serialization/NdjsonChannel.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/ApiHost.cs
diff --git a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx
index 95a1fac10..39ce22a7f 100644
--- a/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx
+++ b/src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx
@@ -2207,4 +2207,61 @@
Set up new credentials
+
+ Allow app to connect?
+
+
+ {0} wants to connect to SecureFolderFS.
+
+
+ Verified publisher: {0}
+
+
+ This application's identity could not be verified
+
+
+ It will be able to:
+
+
+ See your vaults and whether they are unlocked
+
+
+ Ask you to unlock vaults, and lock them
+
+
+ Bring the SecureFolderFS window to the front
+
+
+ It can never read your files or your passwords, and unlocking always requires you to enter your credentials in SecureFolderFS.
+
+
+ Allow
+
+
+ Deny
+
+
+ App integrations
+
+
+ Let other apps on this device see your vaults and ask to unlock them
+
+
+ Connected apps
+
+
+ No apps have been allowed to connect
+
+
+ Revoke
+
+
+ Revoke all
+
+
+ Identity not verified
+
+
+ Never used
+
diff --git a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/PeerEvidenceProvider.cs b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/PeerEvidenceProvider.cs
new file mode 100644
index 000000000..8a002d894
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/PeerEvidenceProvider.cs
@@ -0,0 +1,155 @@
+using System;
+#if WINDOWS
+using System.Diagnostics;
+using System.Runtime.Versioning;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Win32.SafeHandles;
+using SecureFolderFS.Sdk.Api.Enums;
+#elif __UNO_SKIA_MACOS__
+using System.Text;
+#else
+using System.IO;
+#endif
+using SecureFolderFS.Sdk.Api.Transport;
+using SecureFolderFS.Sdk.Api.Models;
+using SecureFolderFS.Sdk.Api.Services;
+using SecureFolderFS.Uno.PInvoke;
+
+namespace SecureFolderFS.Uno.ServiceImplementation
+{
+ ///
+ /// Gathers what each operating system can tell us about a connecting process. Windows can verify a
+ /// publisher signature; macOS and Linux cannot, and say so. Used only to inform the consent prompt and
+ /// never to authorize a request, as every identifier here is spoofable or race-prone.
+ ///
+ public sealed class PeerEvidenceProvider : IPeerEvidenceProvider
+ {
+#if __UNO_SKIA_MACOS__
+ private const int PROC_PID_PATH_MAX = 4096;
+
+ // macOS: SOL_LOCAL / LOCAL_PEERPID, returning a single pid
+ private const int SOL_LOCAL_MACOS = 0;
+ private const int LOCAL_PEERPID_MACOS = 2;
+#elif !WINDOWS
+ // Linux: SOL_SOCKET / SO_PEERCRED, returning struct ucred { pid, uid, gid }
+ private const int SOL_SOCKET_LINUX = 1;
+ private const int SO_PEERCRED_LINUX = 17;
+#endif
+
+ ///
+ public PeerEvidence Describe(ApiPeerHandle peer)
+ {
+ var processId = TryGetProcessId(peer);
+ if (processId is null)
+ return PeerEvidence.Unknown;
+
+ var executablePath = TryGetExecutablePath(processId.Value);
+
+#if WINDOWS
+ // Only Windows can attest to who published the executable
+ if (executablePath is not null)
+ {
+ var signer = TryGetAuthenticodeSigner(executablePath);
+ if (signer is not null)
+ return new PeerEvidence(IsVerified: true, executablePath, signer);
+ }
+#endif
+
+ // Elsewhere the consent prompt states plainly that the identity could not be verified
+ return new PeerEvidence(IsVerified: false, executablePath);
+ }
+
+ private static int? TryGetProcessId(ApiPeerHandle peer)
+ {
+ try
+ {
+#if WINDOWS
+ if (peer.Kind == ApiTransportKind.NamedPipe && peer.PipeHandle is { } pipeHandle)
+ return TryGetPipeClientProcessId(pipeHandle);
+#else
+ if (peer.Socket is { } socket)
+ return TryGetSocketPeerProcessId((int)socket.Handle);
+#endif
+ }
+ catch (Exception)
+ {
+ // Evidence is advisory. Failing to collect it must never refuse a connection
+ }
+
+ return null;
+ }
+
+#if WINDOWS
+ [SupportedOSPlatform("windows")]
+ private static int? TryGetPipeClientProcessId(SafePipeHandle pipeHandle)
+ => UnsafeNative.GetNamedPipeClientProcessId(pipeHandle, out var clientProcessId) ? (int)clientProcessId : null;
+#else
+ private static int? TryGetSocketPeerProcessId(int fileDescriptor)
+ {
+#if __UNO_SKIA_MACOS__
+ var buffer = new byte[4];
+ var length = buffer.Length;
+ if (UnsafeNative.getsockopt(fileDescriptor, SOL_LOCAL_MACOS, LOCAL_PEERPID_MACOS, buffer, ref length) != 0)
+ return null;
+
+ return BitConverter.ToInt32(buffer, 0);
+#else
+ var credentials = new byte[12];
+ var length = credentials.Length;
+ if (UnsafeNative.getsockopt(fileDescriptor, SOL_SOCKET_LINUX, SO_PEERCRED_LINUX, credentials, ref length) != 0)
+ return null;
+
+ return BitConverter.ToInt32(credentials, 0);
+#endif
+ }
+#endif
+
+ private static string? TryGetExecutablePath(int processId)
+ {
+ try
+ {
+#if WINDOWS
+ using var process = Process.GetProcessById(processId);
+ return process.MainModule?.FileName;
+#elif __UNO_SKIA_MACOS__
+ var buffer = new byte[PROC_PID_PATH_MAX];
+ var length = UnsafeNative.proc_pidpath(processId, buffer, (uint)buffer.Length);
+
+ return length > 0 ? Encoding.UTF8.GetString(buffer, 0, length) : null;
+#else
+ return File.ResolveLinkTarget($"/proc/{processId}/exe", returnFinalTarget: true)?.FullName;
+#endif
+ }
+ catch (Exception)
+ {
+ // The process may have exited, or be inaccessible to us
+ }
+
+ return null;
+ }
+
+#if WINDOWS
+ [SupportedOSPlatform("windows")]
+ private static string? TryGetAuthenticodeSigner(string executablePath)
+ {
+ try
+ {
+ // X509CertificateLoader has no path that extracts an Authenticode signer from a PE, so the
+ // signer certificate is read from the signed file and then parsed through the loader.
+#pragma warning disable SYSLIB0057 // CreateFromSignedFile is the only BCL Authenticode reader
+ using var signerCertificate = X509Certificate.CreateFromSignedFile(executablePath);
+#pragma warning restore SYSLIB0057
+ using var certificate = X509CertificateLoader.LoadCertificate(signerCertificate.GetRawCertData());
+
+ var commonName = certificate.GetNameInfo(X509NameType.SimpleName, forIssuer: false);
+ return string.IsNullOrWhiteSpace(commonName) ? null : commonName;
+ }
+ catch (Exception)
+ {
+ // Unsigned, or the signature could not be read. Either way the caller is unverified.
+ return null;
+ }
+ }
+#endif
+ }
+}
diff --git a/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml b/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml
index 45965ab5c..ad604cf8f 100644
--- a/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml
+++ b/src/Platforms/SecureFolderFS.Uno/Views/Settings/PreferencesSettingsPage.xaml
@@ -9,6 +9,7 @@
xmlns:uc="using:SecureFolderFS.Uno.UserControls"
xmlns:ucab="using:SecureFolderFS.Uno.UserControls.ActionBlocks"
xmlns:vm="using:SecureFolderFS.Sdk.ViewModels.Controls.Components"
+ xmlns:vm2="using:SecureFolderFS.Sdk.ViewModels.Controls"
mc:Ignorable="d">
@@ -143,6 +144,87 @@
IsOn="{x:Bind ViewModel.OpenFolderOnUnlock, Mode=TwoWay}" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/EventArguments/ApiVaultChangedEventArgs.cs b/src/Sdk/SecureFolderFS.Sdk.Api/EventArguments/ApiVaultChangedEventArgs.cs
index 94a30048d..191d1c9e3 100644
--- a/src/Sdk/SecureFolderFS.Sdk.Api/EventArguments/ApiVaultChangedEventArgs.cs
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/EventArguments/ApiVaultChangedEventArgs.cs
@@ -1,6 +1,6 @@
using System;
-using SecureFolderFS.Sdk.Api.Protocol;
using SecureFolderFS.Sdk.Api.Enums;
+using SecureFolderFS.Sdk.Api.Models;
namespace SecureFolderFS.Sdk.Api.EventArguments
{
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/ApiDiscoveryHelpers.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/ApiDiscoveryHelpers.cs
new file mode 100644
index 000000000..9daead769
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/ApiDiscoveryHelpers.cs
@@ -0,0 +1,137 @@
+using System;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Runtime.Versioning;
+using System.Security.Cryptography;
+using System.Security.Principal;
+using System.Text;
+using System.Text.Json;
+using SecureFolderFS.Sdk.Api.Enums;
+using SecureFolderFS.Sdk.Api.Models;
+using SecureFolderFS.Sdk.Api.Serialization;
+
+namespace SecureFolderFS.Sdk.Api.Helpers
+{
+ ///
+ /// Resolves where the API endpoint lives on each platform, and reads or writes the endpoint file.
+ ///
+ public static class ApiDiscoveryHelpers
+ {
+ // sockaddr_un.sun_path is 104 bytes on macOS and 108 on Linux
+ private const int MAX_SOCKET_PATH_BYTES = 104;
+ private const string SOCKET_FILE_NAME = "api-v1.sock";
+
+ ///
+ /// Determines the transport and address this machine should use.
+ ///
+ public static (ApiTransportKind Kind, string Address) ResolveEndpoint()
+ {
+ return OperatingSystem.IsWindows()
+ ? (ApiTransportKind.NamedPipe, GetPipeName())
+ : (ApiTransportKind.UnixSocket, GetSocketPath());
+ }
+
+ // The SID is hashed rather than embedded so the pipe name, visible machine-wide, leaks no account id.
+ [SupportedOSPlatform("windows")]
+ private static string GetPipeName()
+ {
+ using var identity = WindowsIdentity.GetCurrent();
+ var identitySid = identity.User?.Value ?? identity.Name;
+ var digest = SHA256.HashData(Encoding.UTF8.GetBytes(identitySid));
+ var suffix = Convert.ToHexString(digest, 0, 8).ToLowerInvariant();
+
+ return $"{Constants.APP_DIRECTORY_NAME}.Api.v{Constants.PROTOCOL_VERSION}.{suffix}";
+ }
+
+ private static string GetSocketPath()
+ {
+ foreach (var directory in EnumerateSocketDirectoryCandidates())
+ {
+ if (string.IsNullOrEmpty(directory))
+ continue;
+
+ var candidate = Path.Combine(directory, SOCKET_FILE_NAME);
+ if (Encoding.UTF8.GetByteCount(candidate) + 1 <= MAX_SOCKET_PATH_BYTES)
+ return candidate;
+ }
+
+ throw new IOException("Unable to find a location for the API socket that fits within the platform path limit.");
+ }
+
+ ///
+ /// Yields socket directories in order of preference.
+ ///
+ private static string?[] EnumerateSocketDirectoryCandidates()
+ {
+ if (OperatingSystem.IsMacOS())
+ {
+ return
+ [
+ // $TMPDIR is per-user (mode 0700), the right home for runtime state, and short enough
+ // to stay within the socket path limit whatever the account is called.
+ CombineIfPresent(Path.GetTempPath(), Constants.XDG_DIRECTORY_NAME),
+
+ // Application Support is conventionally world-readable, so use a subdirectory we own.
+ Path.Combine(GetPersistentDirectory(), "api")
+ ];
+ }
+
+ return
+ [
+ // Runtime state belongs in XDG_RUNTIME_DIR, which is per-user and already mode 0700.
+ CombineIfPresent(Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"), Constants.XDG_DIRECTORY_NAME),
+
+ // Sessions without a runtime directory (plain SSH, for instance) still need somewhere.
+ CombineIfPresent(Path.GetTempPath(), $"{Constants.XDG_DIRECTORY_NAME}-{Environment.UserName}")
+ ];
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ static string? CombineIfPresent(string? root, string child)
+ => string.IsNullOrEmpty(root) ? null : Path.Combine(root, child);
+ }
+
+ ///
+ /// Gets the per-user directory that holds state surviving application exit.
+ ///
+ private static string GetPersistentDirectory()
+ {
+ if (OperatingSystem.IsWindows())
+ return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Constants.APP_DIRECTORY_NAME);
+
+ if (OperatingSystem.IsMacOS())
+ return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", Constants.APP_DIRECTORY_NAME);
+
+ var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME");
+ if (string.IsNullOrEmpty(configHome))
+ configHome = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config");
+
+ return Path.Combine(configHome, Constants.XDG_DIRECTORY_NAME);
+ }
+
+ ///
+ /// Writes the endpoint file atomically, so a polling client never observes a partial descriptor.
+ ///
+ public static void Write(ApiEndpointInfo endpointInfo)
+ {
+ var endpointPath = Path.Combine(GetPersistentDirectory(), Constants.ENDPOINT_FILE_NAME);
+ var directory = Path.GetDirectoryName(endpointPath)!;
+
+ // Default permissions on purpose because the directory may be shared and the file holds no secrets
+ Directory.CreateDirectory(directory);
+
+ var temporaryPath = $"{endpointPath}.tmp";
+ File.WriteAllText(temporaryPath, JsonSerializer.Serialize(endpointInfo, ApiSerializer.Options));
+ File.Move(temporaryPath, endpointPath, overwrite: true);
+ }
+
+ ///
+ /// Gets the transport identifier written into the endpoint file.
+ ///
+ public static string GetTransportName(ApiTransportKind kind) => kind switch
+ {
+ ApiTransportKind.NamedPipe => Constants.TRANSPORT_NAMED_PIPE,
+ ApiTransportKind.UnixSocket => Constants.TRANSPORT_UNIX_SOCKET,
+ _ => throw new ArgumentOutOfRangeException(nameof(kind))
+ };
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Policy/ApiPolicy.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/ApiRateLimiterHelper.cs
similarity index 69%
rename from src/Sdk/SecureFolderFS.Sdk.Api/Policy/ApiPolicy.cs
rename to src/Sdk/SecureFolderFS.Sdk.Api/Helpers/ApiRateLimiterHelper.cs
index 87ab2783c..7602641ff 100644
--- a/src/Sdk/SecureFolderFS.Sdk.Api/Policy/ApiPolicy.cs
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/ApiRateLimiterHelper.cs
@@ -3,12 +3,12 @@
using System.Threading;
using SecureFolderFS.Sdk.Api.Enums;
-namespace SecureFolderFS.Sdk.Api.Policy
+namespace SecureFolderFS.Sdk.Api.Helpers
{
///
/// Enforces per-client request budgets using a token bucket per request class.
///
- public sealed class ApiRateLimiter
+ public sealed class ApiRateLimiterHelper
{
private readonly Lock _lock = new();
private readonly Dictionary> _clients = [];
@@ -76,42 +76,4 @@ public void Refill(DateTimeOffset now, double capacity, TimeSpan refillInterval)
}
}
}
-
- ///
- /// Collapses duplicate in-flight requests so identical work is never started twice. While a request
- /// for a given key is outstanding, further requests for the same key are refused entry.
- ///
- public sealed class RequestCoalescer
- {
- private readonly Lock _lock = new();
- private readonly HashSet _inFlight = [];
-
- ///
- /// Attempts to claim exclusive ownership of a unit of work.
- ///
- /// A scope that releases the claim when disposed, or when the
- /// same work is already in flight.
- public IDisposable? TryBeginScope(string key)
- {
- lock (_lock)
- {
- if (!_inFlight.Add(key))
- return null;
- }
-
- return new Scope(this, key);
- }
-
- private void End(string key)
- {
- lock (_lock)
- _inFlight.Remove(key);
- }
-
- private sealed class Scope(RequestCoalescer owner, string key) : IDisposable
- {
- ///
- public void Dispose() => owner.End(key);
- }
- }
-}
+}
\ No newline at end of file
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultId.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultIdHelpers.cs
similarity index 96%
rename from src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultId.cs
rename to src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultIdHelpers.cs
index 13384431f..c57329f5b 100644
--- a/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultId.cs
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultIdHelpers.cs
@@ -9,7 +9,7 @@ namespace SecureFolderFS.Sdk.Api.Helpers
/// Derives the public identifier under which a vault is exposed to integrations, so third-party
/// configuration never comes to depend on the app's internal persistence identifier.
///
- public static class PublicVaultId
+ public static class PublicVaultIdHelpers
{
// 16 digest bytes equating to a 128-bit identifier
private const int ID_BYTE_LENGTH = 16;
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/RequestCoalescerHelper.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/RequestCoalescerHelper.cs
new file mode 100644
index 000000000..44c4661f0
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/RequestCoalescerHelper.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+
+namespace SecureFolderFS.Sdk.Api.Helpers
+{
+ ///
+ /// Collapses duplicate in-flight requests so identical work is never started twice. While a request
+ /// for a given key is outstanding, further requests for the same key are refused entry.
+ ///
+ public sealed class RequestCoalescerHelper
+ {
+ private readonly Lock _lock = new();
+ private readonly HashSet _inFlight = [];
+
+ ///
+ /// Attempts to claim exclusive ownership of a unit of work.
+ ///
+ /// A scope that releases the claim when disposed, or when the
+ /// same work is already in flight.
+ public IDisposable? TryBeginScope(string key)
+ {
+ lock (_lock)
+ {
+ if (!_inFlight.Add(key))
+ return null;
+ }
+
+ return new Scope(this, key);
+ }
+
+ private void End(string key)
+ {
+ lock (_lock)
+ _inFlight.Remove(key);
+ }
+
+ private sealed class Scope(RequestCoalescerHelper owner, string key) : IDisposable
+ {
+ ///
+ public void Dispose() => owner.End(key);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiEndpointInfo.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiEndpointInfo.cs
new file mode 100644
index 000000000..a8115dd90
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiEndpointInfo.cs
@@ -0,0 +1,20 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace SecureFolderFS.Sdk.Api.Models
+{
+ ///
+ /// Describes where the API can be reached, and whether it is available at all.
+ ///
+ public sealed record ApiEndpointInfo(
+ [property: JsonPropertyName("schemaVersion")] int SchemaVersion,
+ [property: JsonPropertyName("transport")] string Transport,
+ [property: JsonPropertyName("address")] string Address,
+ [property: JsonPropertyName("enabled")] bool Enabled,
+ [property: JsonPropertyName("protocolMin")] int ProtocolMin,
+ [property: JsonPropertyName("protocolMax")] int ProtocolMax,
+ [property: JsonPropertyName("capabilities")] IReadOnlyList Capabilities,
+ [property: JsonPropertyName("appVersion")] string? AppVersion = null,
+ [property: JsonPropertyName("executablePath")] string? ExecutablePath = null,
+ [property: JsonPropertyName("processId")] int? ProcessId = null);
+}
\ No newline at end of file
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiMessage.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiMessageModels.cs
similarity index 100%
rename from src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiMessage.cs
rename to src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiMessageModels.cs
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiModels.cs
similarity index 99%
rename from src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs
rename to src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiModels.cs
index 67e9fd808..44816f3ad 100644
--- a/src/Sdk/SecureFolderFS.Sdk.Api/Protocol/ApiModels.cs
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Models/ApiModels.cs
@@ -2,7 +2,7 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
-namespace SecureFolderFS.Sdk.Api.Protocol
+namespace SecureFolderFS.Sdk.Api.Models
{
///
/// The first message a client sends after connecting.
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Serialization/NdjsonChannel.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Serialization/NdjsonChannel.cs
new file mode 100644
index 000000000..fb7c1f4de
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Serialization/NdjsonChannel.cs
@@ -0,0 +1,184 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Sdk.Api.Protocol;
+using SecureFolderFS.Shared.ComponentModel;
+using SecureFolderFS.Shared.Extensions;
+
+namespace SecureFolderFS.Sdk.Api.Serialization
+{
+ ///
+ /// Reads and writes instances as newline-delimited JSON over a stream.
+ ///
+ ///
+ /// One compact JSON object per line, terminated by \n. Each message is serialized through the
+ /// shared ; this type only owns the framing. Reads are
+ /// bounded by so an attacker cannot force unbounded buffering.
+ ///
+ public sealed class NdjsonChannel : IDisposable
+ {
+ private const byte NEWLINE = (byte)'\n';
+ private const byte CARRIAGE_RETURN = (byte)'\r';
+ private const int INITIAL_BUFFER_SIZE = 4096;
+
+ private static readonly byte[] NewlineBuffer = [NEWLINE];
+
+ private readonly Stream _stream;
+ private readonly IAsyncSerializer _serializer;
+ private readonly int _maxMessageBytes;
+ private readonly SemaphoreSlim _writeLock;
+ private byte[] _buffer;
+ private int _bufferedLength;
+ private bool _disposed;
+
+ public NdjsonChannel(
+ Stream stream,
+ IAsyncSerializer? serializer = null,
+ int maxMessageBytes = Constants.Limits.MAX_MESSAGE_BYTES)
+ {
+ _stream = stream;
+ _serializer = serializer ?? ApiSerializer.Instance;
+ _maxMessageBytes = maxMessageBytes;
+ _writeLock = new SemaphoreSlim(1, 1);
+ _buffer = new byte[INITIAL_BUFFER_SIZE];
+ }
+
+ ///
+ /// Reads the next message, or when the peer closed the connection.
+ ///
+ public async Task ReadAsync(CancellationToken cancellationToken = default)
+ {
+ while (true)
+ {
+ var newlineIndex = Array.IndexOf(_buffer, NEWLINE, 0, _bufferedLength);
+ if (newlineIndex >= 0)
+ {
+ var consumed = newlineIndex + 1;
+ var lineLength = newlineIndex;
+
+ // Tolerate CRLF from clients written against line-oriented APIs
+ if (lineLength > 0 && _buffer[lineLength - 1] == CARRIAGE_RETURN)
+ lineLength--;
+
+ // Blank lines are permitted as keep-alives
+ if (lineLength == 0)
+ {
+ Consume(consumed);
+ continue;
+ }
+
+ try
+ {
+ return await DeserializeAsync(lineLength, cancellationToken);
+ }
+ finally
+ {
+ // Discard the line even when parsing throws, otherwise one bad line loops forever.
+ Consume(consumed);
+ }
+ }
+
+ // No delimiter yet, and the peer has already spent the whole budget on one line
+ if (_bufferedLength >= _maxMessageBytes)
+ {
+ throw new ApiProtocolException(
+ Constants.ErrorCodes.INVALID_REQUEST,
+ $"Message exceeded the {_maxMessageBytes} byte limit.",
+ isFatal: true);
+ }
+
+ if (_bufferedLength == _buffer.Length)
+ Array.Resize(ref _buffer, Math.Min(_buffer.Length * 2, _maxMessageBytes + 1));
+
+ var read = await _stream.ReadAsync(_buffer.AsMemory(_bufferedLength), cancellationToken);
+ if (read == 0)
+ return null;
+
+ _bufferedLength += read;
+ }
+ }
+
+ ///
+ /// Writes a message to the peer. Serialization happens under the write lock so concurrent
+ /// notifications and responses can never interleave bytes.
+ ///
+ public async Task WriteAsync(ApiMessage message, CancellationToken cancellationToken = default)
+ {
+ await _writeLock.WaitAsync(cancellationToken);
+ try
+ {
+ await using var payload = await _serializer.SerializeAsync(message, cancellationToken);
+
+ await payload.CopyToAsync(_stream, cancellationToken);
+ await _stream.WriteAsync(NewlineBuffer, cancellationToken);
+ await _stream.FlushAsync(cancellationToken);
+ }
+ finally
+ {
+ _writeLock.Release();
+ }
+ }
+
+ private async Task DeserializeAsync(int lineLength, CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var lineStream = new MemoryStream(_buffer, 0, lineLength, writable: false);
+
+ return await _serializer.DeserializeAsync(lineStream, cancellationToken)
+ ?? throw new ApiProtocolException(
+ Constants.ErrorCodes.INVALID_REQUEST, "Message was not a JSON object.", isFatal: false);
+ }
+ catch (JsonException ex)
+ {
+ throw new ApiProtocolException(
+ Constants.ErrorCodes.PARSE_ERROR, $"Message was not valid JSON: {ex.Message}", isFatal: false);
+ }
+ }
+
+ private void Consume(int count)
+ {
+ var remaining = _bufferedLength - count;
+ if (remaining > 0)
+ Buffer.BlockCopy(_buffer, count, _buffer, 0, remaining);
+
+ _bufferedLength = remaining;
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ _writeLock.Dispose();
+ }
+ }
+
+ ///
+ /// A violation of the wire protocol by the remote peer.
+ ///
+ public sealed class ApiProtocolException : Exception
+ {
+ ///
+ /// Gets the error code from to report to the peer.
+ ///
+ public string Code { get; }
+
+ ///
+ /// Gets whether the connection must be torn down. A malformed message is recoverable; an
+ /// oversized one is not, because the framing is no longer trustworthy.
+ ///
+ public bool IsFatal { get; }
+
+ public ApiProtocolException(string code, string message, bool isFatal)
+ : base(message)
+ {
+ Code = code;
+ IsFatal = isFatal;
+ }
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/ApiHost.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/ApiHost.cs
new file mode 100644
index 000000000..91489f215
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/ApiHost.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using SecureFolderFS.Sdk.Api.Helpers;
+using SecureFolderFS.Sdk.Api.Models;
+using SecureFolderFS.Sdk.Api.Session;
+using SecureFolderFS.Sdk.Api.Transport;
+using SecureFolderFS.Shared.Helpers;
+using ApiConstants = SecureFolderFS.Sdk.Api.Constants;
+
+namespace SecureFolderFS.Sdk.Api.Services
+{
+ ///
+ public sealed class ApiHost : IApiHost
+ {
+ private readonly VaultNotificationHub _hub;
+ private readonly ApiSessionContext _context;
+ private readonly List _sessions = [];
+ private readonly Lock _lock = new();
+
+ private CancellationTokenSource? _lifetimeCts;
+ private IApiTransport? _transport;
+ private Task? _acceptLoop;
+ private bool _disposed;
+
+ public ApiHost(
+ IVaultApiBridge bridge,
+ IPairingStore pairingStore,
+ IApiConsentService consentService,
+ IPeerEvidenceProvider evidenceProvider,
+ string appVersion)
+ {
+ _hub = new VaultNotificationHub(bridge);
+ _context = new ApiSessionContext(
+ bridge,
+ _hub,
+ pairingStore,
+ new ApiRateLimiterHelper(),
+ new RequestCoalescerHelper(),
+ consentService,
+ evidenceProvider,
+ appVersion);
+ }
+
+ ///
+ public async Task StartAsync(CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ if (_transport is not null)
+ return;
+
+ var transport = CreateTransport();
+ await transport.StartAsync(cancellationToken);
+
+ _transport = transport;
+ _lifetimeCts = new CancellationTokenSource();
+
+ // Written only after the listener is bound, so a client woken by the file change finds something accepting on the other end
+ PublishEndpoint(enabled: true);
+
+ _acceptLoop = Task.Run(() => AcceptLoopAsync(transport, _lifetimeCts.Token), CancellationToken.None);
+ }
+
+ ///
+ public async Task StopAsync(CancellationToken cancellationToken = default)
+ {
+ var transport = _transport;
+ if (transport is null)
+ return;
+
+ _transport = null;
+ if (_lifetimeCts is not null)
+ await _lifetimeCts.CancelAsync();
+
+ await transport.DisposeAsync();
+ if (_acceptLoop is not null)
+ await Task.WhenAny(_acceptLoop, Task.Delay(TimeSpan.FromSeconds(2), cancellationToken));
+
+ ApiSession[] sessions;
+ lock (_lock)
+ {
+ sessions = _sessions.ToArray();
+ _sessions.Clear();
+ }
+
+ foreach (var session in sessions)
+ await SafetyHelpers.NoFailureAsync(async () => await session.DisposeAsync());
+
+ _lifetimeCts?.Dispose();
+ _lifetimeCts = null;
+ _acceptLoop = null;
+ }
+
+ ///
+ public void PublishDisabled() => PublishEndpoint(enabled: false);
+
+ private void PublishEndpoint(bool enabled)
+ {
+ try
+ {
+ var (kind, address) = ApiDiscoveryHelpers.ResolveEndpoint();
+ ApiDiscoveryHelpers.Write(new ApiEndpointInfo(
+ ApiConstants.PROTOCOL_VERSION,
+ ApiDiscoveryHelpers.GetTransportName(kind),
+ address,
+ enabled,
+ ApiConstants.PROTOCOL_VERSION_MIN,
+ ApiConstants.PROTOCOL_VERSION,
+ Constants.Scopes.All,
+ _context.AppVersion,
+ SafetyHelpers.NoFailureResult(() => Environment.ProcessPath),
+ Environment.ProcessId));
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // The API still works for a client that already knows the address, so this is not fatal
+ }
+ }
+
+ private async Task AcceptLoopAsync(IApiTransport transport, CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ IApiConnection connection;
+ try
+ {
+ connection = await transport.AcceptAsync(cancellationToken);
+ }
+ catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException)
+ {
+ break;
+ }
+ catch (Exception)
+ {
+ continue;
+ }
+
+ if (!TryAdmit())
+ {
+ // Refused connections are closed immediately, so a client sees a clean disconnect
+ await SafetyHelpers.NoFailureAsync(async () => await connection.DisposeAsync());
+ continue;
+ }
+
+ var session = new ApiSession(connection, _context);
+ lock (_lock)
+ _sessions.Add(session);
+
+ _ = Task.Run(() => RunSessionAsync(session, cancellationToken), CancellationToken.None);
+ }
+ }
+
+ ///
+ /// Decides whether a new connection fits within the configured budgets.
+ ///
+ private bool TryAdmit()
+ {
+ lock (_lock)
+ {
+ if (_sessions.Count >= Constants.Limits.MAX_CONNECTIONS)
+ return false;
+
+ // Unauthenticated sockets get their own small allowance so an unpaired caller cannot lock out paired clients.
+ return _sessions.Count(x => !x.IsAuthenticated) < Constants.Limits.MAX_UNAUTHENTICATED_CONNECTIONS;
+ }
+ }
+
+ private async Task RunSessionAsync(ApiSession session, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await session.RunAsync(cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _ = ex;
+ }
+ finally
+ {
+ lock (_lock)
+ _sessions.Remove(session);
+
+ await SafetyHelpers.NoFailureAsync(async () => await session.DisposeAsync());
+ }
+ }
+
+ private static IApiTransport CreateTransport()
+ {
+ var (_, address) = ApiDiscoveryHelpers.ResolveEndpoint();
+ if (!OperatingSystem.IsWindows())
+ return new UnixSocketApiTransport(address);
+
+ // The endpoint file advertises the full \\.\pipe\ form; the server API takes the bare name.
+ var pipeName = address.StartsWith(@"\\.\pipe\", StringComparison.OrdinalIgnoreCase)
+ ? address[@"\\.\pipe\".Length..]
+ : address;
+
+ return new NamedPipeApiTransport(pipeName);
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ await StopAsync();
+ _hub.Dispose();
+ }
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs
index 90bebe4ff..7868c1c89 100644
--- a/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/IVaultApiBridge.cs
@@ -2,9 +2,9 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
-using SecureFolderFS.Sdk.Api.Protocol;
using SecureFolderFS.Sdk.Api.Enums;
using SecureFolderFS.Sdk.Api.EventArguments;
+using SecureFolderFS.Sdk.Api.Models;
namespace SecureFolderFS.Sdk.Api.Services
{
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs
index 69a8fabc0..3de71ed57 100644
--- a/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/PairingStore.cs
@@ -112,7 +112,7 @@ public Task TouchLastUsedAsync(string clientId, CancellationToken cancellationTo
public async Task CreateClientAsync(
string displayName, PeerEvidence evidence, IReadOnlyList scopes, CancellationToken cancellationToken = default)
{
- var token = PublicVaultId.ToBase64Url(RandomNumberGenerator.GetBytes(32));
+ var token = PublicVaultIdHelpers.ToBase64Url(RandomNumberGenerator.GetBytes(32));
var client = new PairedClient(
Id: Guid.NewGuid().ToString("N"),
DisplayName: displayName,
From 69ba83e2f56f112e1a70dff4d1abd3dbe6e70fae Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Mon, 24 Aug 2026 15:46:41 +0200
Subject: [PATCH 9/9] Added UnoVaultApiBridge
---
.../UnoVaultApiBridge.cs | 323 +++++++++++
.../Helpers/PublicVaultIdHelpers.cs | 6 +-
.../Serialization/ApiSerializer.cs | 33 ++
.../Services/VaultNotificationHub.cs | 129 +++++
.../Session/ApiSession.cs | 501 ++++++++++++++++++
5 files changed, 989 insertions(+), 3 deletions(-)
create mode 100644 src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoVaultApiBridge.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Serialization/ApiSerializer.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Services/VaultNotificationHub.cs
create mode 100644 src/Sdk/SecureFolderFS.Sdk.Api/Session/ApiSession.cs
diff --git a/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoVaultApiBridge.cs b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoVaultApiBridge.cs
new file mode 100644
index 000000000..4ccd0d8d5
--- /dev/null
+++ b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoVaultApiBridge.cs
@@ -0,0 +1,323 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.Messaging;
+using SecureFolderFS.Sdk.Api.Enums;
+using SecureFolderFS.Sdk.Api.EventArguments;
+using SecureFolderFS.Sdk.Api.Helpers;
+using SecureFolderFS.Sdk.Api.Models;
+using SecureFolderFS.Sdk.Api.Services;
+using SecureFolderFS.Sdk.Messages;
+using SecureFolderFS.Sdk.Services;
+using SecureFolderFS.Sdk.ViewModels;
+using SecureFolderFS.Sdk.ViewModels.Controls.VaultList;
+using SecureFolderFS.Sdk.ViewModels.Views.Root;
+using SecureFolderFS.Shared;
+using SecureFolderFS.Shared.Extensions;
+using ApiConstants = SecureFolderFS.Sdk.Api.Constants;
+
+namespace SecureFolderFS.Uno.ServiceImplementation
+{
+ ///
+ /// Exposes live vault state to the local integration API.
+ ///
+ public sealed class UnoVaultApiBridge : IVaultApiBridge, IRecipient, IRecipient, IDisposable
+ {
+ private readonly IPairingStore _pairingStore;
+ private MainViewModel? _mainViewModel;
+ private SynchronizationContext? _synchronizationContext;
+ private VaultInfo[] _snapshot = [];
+ private bool _disposed;
+
+ ///
+ public bool IsAvailable => _mainViewModel is not null;
+
+ ///
+ public event EventHandler? VaultChanged;
+
+ ///
+ /// Gets or sets the callback that shows the unlock prompt for a vault.
+ ///
+ public Func>? ShowUnlockPromptAsync { get; set; }
+
+ ///
+ /// Gets or sets the callback that brings the main window to the foreground.
+ ///
+ public Func? ShowMainWindow { get; set; }
+
+ public UnoVaultApiBridge(IPairingStore pairingStore)
+ {
+ _pairingStore = pairingStore;
+ }
+
+ ///
+ /// Connects the bridge to live application state, once the main view model exists.
+ ///
+ public void Attach(MainViewModel mainViewModel, SynchronizationContext? synchronizationContext)
+ {
+ _mainViewModel = mainViewModel;
+ _synchronizationContext = synchronizationContext;
+
+ mainViewModel.VaultListViewModel.Items.CollectionChanged += Items_CollectionChanged;
+ foreach (var item in mainViewModel.VaultListViewModel.Items)
+ item.VaultViewModel.PropertyChanged += VaultViewModel_PropertyChanged;
+
+ WeakReferenceMessenger.Default.Register(this);
+ WeakReferenceMessenger.Default.Register(this);
+
+ Rebuild();
+ }
+
+ ///
+ public IReadOnlyList GetVaults() => Volatile.Read(ref _snapshot);
+
+ ///
+ public void Receive(VaultUnlockedMessage message) => Rebuild();
+
+ ///
+ public void Receive(VaultLockedMessage message) => Rebuild();
+
+ ///
+ /// Recomputes the snapshot and raises an event for every difference.
+ ///
+ private void Rebuild()
+ {
+ if (_mainViewModel is null)
+ return;
+
+ var previous = Volatile.Read(ref _snapshot);
+ var current = BuildSnapshot(_mainViewModel);
+ Volatile.Write(ref _snapshot, current);
+
+ if (VaultChanged is null)
+ return;
+
+ var previousById = previous.ToDictionary(x => x.Id);
+ var currentById = current.ToDictionary(x => x.Id);
+
+ foreach (var vault in current)
+ {
+ if (!previousById.TryGetValue(vault.Id, out var before))
+ {
+ NotifyVaultChanged(ApiVaultChangeKind.Added, vault.Id, vault);
+ continue;
+ }
+
+ if (before.State != vault.State)
+ {
+ var kind = vault.State == ApiConstants.VaultStates.UNLOCKED
+ ? ApiVaultChangeKind.Unlocked
+ : ApiVaultChangeKind.Locked;
+
+ NotifyVaultChanged(kind, vault.Id, vault);
+ }
+ else if (!string.Equals(before.Name, vault.Name, StringComparison.Ordinal))
+ {
+ NotifyVaultChanged(ApiVaultChangeKind.Renamed, vault.Id, vault);
+ }
+ }
+
+ foreach (var vault in previous)
+ {
+ if (!currentById.ContainsKey(vault.Id))
+ NotifyVaultChanged(ApiVaultChangeKind.Removed, vault.Id, null);
+ }
+ }
+
+ private void NotifyVaultChanged(ApiVaultChangeKind kind, string vaultId, VaultInfo? vault)
+ => VaultChanged?.Invoke(this, new ApiVaultChangedEventArgs(kind, vaultId, vault));
+
+ private VaultInfo[] BuildSnapshot(MainViewModel mainViewModel)
+ {
+ var vaultIdKey = _pairingStore.VaultIdKey;
+ var results = new List();
+
+ foreach (var item in mainViewModel.VaultListViewModel.Items)
+ {
+ var vaultViewModel = item.VaultViewModel;
+ var persistableId = vaultViewModel.VaultModel.DataModel.PersistableId;
+ if (string.IsNullOrEmpty(persistableId))
+ continue;
+
+ results.Add(new VaultInfo(
+ PublicVaultIdHelpers.Compute(vaultIdKey, persistableId),
+ vaultViewModel.Title ?? string.Empty,
+ vaultViewModel.IsUnlocked ? ApiConstants.VaultStates.UNLOCKED : ApiConstants.VaultStates.LOCKED,
+ TryGetMountPath(vaultViewModel),
+ vaultViewModel.LastAccessDate));
+ }
+
+ return results.ToArray();
+ }
+
+ private static string? TryGetMountPath(VaultViewModel vaultViewModel)
+ {
+ if (!vaultViewModel.IsUnlocked)
+ return null;
+
+ try
+ {
+ return vaultViewModel.GetUnlockedViewModel().StorageRoot.VirtualizedRoot.Id;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ ///
+ public Task RequestUnlockAsync(string vaultId, CancellationToken cancellationToken = default)
+ {
+ return InvokeOnUiThreadAsync(async () =>
+ {
+ if (!TryResolve(vaultId, out var vaultViewModel))
+ return ApiActionOutcome.NotFound;
+
+ if (vaultViewModel.IsUnlocked)
+ return ApiActionOutcome.NoChange;
+
+ if (ShowUnlockPromptAsync is null)
+ return ApiActionOutcome.Unavailable;
+
+ var shown = await ShowUnlockPromptAsync(vaultViewModel);
+ return shown ? ApiActionOutcome.Ok : ApiActionOutcome.AlreadyPending;
+ });
+ }
+
+ ///
+ public Task LockAsync(string vaultId, CancellationToken cancellationToken = default)
+ {
+ return InvokeOnUiThreadAsync(() =>
+ {
+ if (!TryResolve(vaultId, out var vaultViewModel))
+ return Task.FromResult(ApiActionOutcome.NotFound);
+
+ if (!vaultViewModel.IsUnlocked)
+ return Task.FromResult(ApiActionOutcome.NoChange);
+
+ WeakReferenceMessenger.Default.Send(new VaultLockRequestedMessage(vaultViewModel.VaultModel));
+ return Task.FromResult(ApiActionOutcome.Ok);
+ });
+ }
+
+ ///
+ public Task RevealAsync(string vaultId, CancellationToken cancellationToken = default)
+ {
+ return InvokeOnUiThreadAsync(async () =>
+ {
+ if (!TryResolve(vaultId, out var vaultViewModel))
+ return ApiActionOutcome.NotFound;
+
+ if (!vaultViewModel.IsUnlocked)
+ return ApiActionOutcome.InvalidState;
+
+ var fileExplorerService = DI.OptionalService();
+ if (fileExplorerService is null)
+ return ApiActionOutcome.Unavailable;
+
+ var unlocked = vaultViewModel.GetUnlockedViewModel();
+ await fileExplorerService.TryOpenInFileExplorerAsync(unlocked.StorageRoot.VirtualizedRoot, cancellationToken);
+
+ return ApiActionOutcome.Ok;
+ });
+ }
+
+ ///
+ public Task ShowMainWindowAsync(CancellationToken cancellationToken = default)
+ {
+ return InvokeOnUiThreadAsync(async () =>
+ {
+ if (ShowMainWindow is null)
+ return ApiActionOutcome.Unavailable;
+
+ await ShowMainWindow();
+ return ApiActionOutcome.Ok;
+ });
+ }
+
+ private bool TryResolve(string vaultId, out VaultViewModel vaultViewModel)
+ {
+ vaultViewModel = null!;
+
+ if (_mainViewModel is null)
+ return false;
+
+ var vaultIdKey = _pairingStore.VaultIdKey;
+ foreach (var item in _mainViewModel.VaultListViewModel.Items)
+ {
+ var persistableId = item.VaultViewModel.VaultModel.DataModel.PersistableId;
+ if (string.IsNullOrEmpty(persistableId))
+ continue;
+
+ if (!string.Equals(PublicVaultIdHelpers.Compute(vaultIdKey, persistableId), vaultId, StringComparison.Ordinal))
+ continue;
+
+ vaultViewModel = item.VaultViewModel;
+ return true;
+ }
+
+ return false;
+ }
+
+ private async Task InvokeOnUiThreadAsync(Func> action)
+ {
+ if (_mainViewModel is null)
+ return ApiActionOutcome.Unavailable;
+
+ var outcome = ApiActionOutcome.Unavailable;
+ await _synchronizationContext.PostOrExecuteAsync(async () =>
+ {
+ try
+ {
+ outcome = await action();
+ }
+ catch (Exception)
+ {
+ outcome = ApiActionOutcome.Unavailable;
+ }
+ });
+
+ return outcome;
+ }
+
+ private void Items_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ foreach (var item in e.OldItems?.OfType() ?? [])
+ item.VaultViewModel.PropertyChanged -= VaultViewModel_PropertyChanged;
+
+ foreach (var item in e.NewItems?.OfType() ?? [])
+ item.VaultViewModel.PropertyChanged += VaultViewModel_PropertyChanged;
+
+ Rebuild();
+ }
+
+ private void VaultViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(VaultViewModel.Title) or nameof(VaultViewModel.IsUnlocked))
+ Rebuild();
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ WeakReferenceMessenger.Default.UnregisterAll(this);
+
+ if (_mainViewModel is not null)
+ {
+ _mainViewModel.VaultListViewModel.Items.CollectionChanged -= Items_CollectionChanged;
+ foreach (var item in _mainViewModel.VaultListViewModel.Items)
+ item.VaultViewModel.PropertyChanged -= VaultViewModel_PropertyChanged;
+ }
+
+ _mainViewModel = null;
+ }
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultIdHelpers.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultIdHelpers.cs
index c57329f5b..031bf97d3 100644
--- a/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultIdHelpers.cs
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Helpers/PublicVaultIdHelpers.cs
@@ -17,13 +17,13 @@ public static class PublicVaultIdHelpers
///
/// Computes the public identifier for a vault.
///
- /// The per-installation secret held by the pairing store.
+ /// The per-installation vault ID key held by the pairing store.
/// The application's internal identifier for the vault.
[SkipLocalsInit]
- public static string Compute(ReadOnlySpan installSecret, string persistableId)
+ public static string Compute(ReadOnlySpan vaultIdKey, string persistableId)
{
Span digest = stackalloc byte[32];
- HMACSHA256.HashData(installSecret, Encoding.UTF8.GetBytes(persistableId), digest);
+ HMACSHA256.HashData(vaultIdKey, Encoding.UTF8.GetBytes(persistableId), digest);
return ToBase64Url(digest[..ID_BYTE_LENGTH]);
}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Serialization/ApiSerializer.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Serialization/ApiSerializer.cs
new file mode 100644
index 000000000..829e557a1
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Serialization/ApiSerializer.cs
@@ -0,0 +1,33 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using SecureFolderFS.Shared.Models;
+
+namespace SecureFolderFS.Sdk.Api.Serialization
+{
+ ///
+ /// The single JSON configuration used for every API message and persisted file. Writing is
+ /// non-indented so a payload never breaks the one-message-per-line wire framing, and null members are omitted.
+ ///
+ public sealed class ApiSerializer : StreamSerializer
+ {
+ ///
+ /// Gets the shared API serializer instance.
+ ///
+ public new static ApiSerializer Instance { get; } = new();
+
+ ///
+ /// Gets the underlying options, for the synchronous file paths that cannot await a stream.
+ ///
+ public static JsonSerializerOptions Options => Instance.SerializerOptions;
+
+ private ApiSerializer() : base(new JsonSerializerOptions
+ {
+ WriteIndented = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ PropertyNameCaseInsensitive = false,
+ NumberHandling = JsonNumberHandling.Strict
+ })
+ {
+ }
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Services/VaultNotificationHub.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Services/VaultNotificationHub.cs
new file mode 100644
index 000000000..a5e3e8a58
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Services/VaultNotificationHub.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using SecureFolderFS.Sdk.Api.Enums;
+using SecureFolderFS.Sdk.Api.EventArguments;
+using SecureFolderFS.Sdk.Api.Models;
+using SecureFolderFS.Sdk.Api.Protocol;
+
+namespace SecureFolderFS.Sdk.Api.Services
+{
+ ///
+ /// Assigns revisions to vault changes and distributes them out to subscribed sessions.
+ ///
+ ///
+ /// Every change increments a monotonic revision carried on snapshots and notifications, so a client
+ /// that sees the revision jump by more than one knows it missed an event and can resynchronize.
+ ///
+ public sealed class VaultNotificationHub : IDisposable
+ {
+ private readonly IVaultApiBridge _bridge;
+ private readonly Lock _lock = new();
+ private readonly Dictionary> _subscribers;
+ private long _revision;
+ private bool _disposed;
+
+ public VaultNotificationHub(IVaultApiBridge bridge)
+ {
+ _bridge = bridge;
+ _bridge.VaultChanged += OnVaultChanged;
+ _subscribers = new();
+ }
+
+ ///
+ /// Gets the current vault list without subscribing to further changes.
+ ///
+ public VaultListResponse GetSnapshot()
+ {
+ lock (_lock)
+ return new VaultListResponse(_revision, _bridge.GetVaults());
+ }
+
+ ///
+ /// Subscribes to changes and returns the snapshot those changes apply on top of. Registration and
+ /// snapshot capture happen under one lock, so no change can fall between them.
+ ///
+ ///
+ /// Receives notifications. Must not block. It is invoked while the hub lock is held, so it should
+ /// enqueue rather than perform I/O. Returning means the subscriber's queue
+ /// overflowed, which surfaces to the client as a revision gap.
+ ///
+ public (VaultListResponse Snapshot, IDisposable Subscription) Subscribe(Func send)
+ {
+ lock (_lock)
+ {
+ var id = Guid.NewGuid();
+ _subscribers[id] = send;
+
+ var snapshot = new VaultListResponse(_revision, _bridge.GetVaults());
+ return (snapshot, new HubSubscription(this, id));
+ }
+ }
+
+ private void OnVaultChanged(object? sender, ApiVaultChangedEventArgs e)
+ {
+ lock (_lock)
+ {
+ if (_disposed)
+ return;
+
+ var notification = CreateNotification(e, ++_revision);
+ if (notification is null)
+ return;
+
+ // Dispatched under the lock so notifications reach every subscriber in revision order.
+ // Safe only because subscribers enqueue rather than write to the socket
+ foreach (var subscriber in _subscribers.Values)
+ subscriber(notification);
+ }
+ }
+
+ private static ApiMessage? CreateNotification(ApiVaultChangedEventArgs e, long revision)
+ {
+ if (e.Kind == ApiVaultChangeKind.Removed)
+ return ApiMessage.Notification(Constants.Events.VAULT_REMOVED, new VaultRemovedNotification(revision, e.VaultId));
+
+ if (e.Vault is null)
+ return null;
+
+ var method = e.Kind switch
+ {
+ ApiVaultChangeKind.Added => Constants.Events.VAULT_ADDED,
+ ApiVaultChangeKind.Renamed => Constants.Events.VAULT_RENAMED,
+ ApiVaultChangeKind.Unlocked => Constants.Events.VAULT_UNLOCKED,
+ ApiVaultChangeKind.Locked => Constants.Events.VAULT_LOCKED,
+ _ => null
+ };
+
+ return method is null
+ ? null
+ : ApiMessage.Notification(method, new VaultChangedNotification(revision, e.Vault));
+ }
+
+ private void Unsubscribe(Guid id)
+ {
+ lock (_lock)
+ _subscribers.Remove(id);
+ }
+
+ ///
+ public void Dispose()
+ {
+ lock (_lock)
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ _bridge.VaultChanged -= OnVaultChanged;
+ _subscribers.Clear();
+ }
+ }
+
+ private sealed class HubSubscription(VaultNotificationHub hub, Guid id) : IDisposable
+ {
+ ///
+ public void Dispose() => hub.Unsubscribe(id);
+ }
+ }
+}
diff --git a/src/Sdk/SecureFolderFS.Sdk.Api/Session/ApiSession.cs b/src/Sdk/SecureFolderFS.Sdk.Api/Session/ApiSession.cs
new file mode 100644
index 000000000..9da434ca8
--- /dev/null
+++ b/src/Sdk/SecureFolderFS.Sdk.Api/Session/ApiSession.cs
@@ -0,0 +1,501 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Channels;
+using System.Threading.Tasks;
+using SecureFolderFS.Sdk.Api.Enums;
+using SecureFolderFS.Sdk.Api.Helpers;
+using SecureFolderFS.Sdk.Api.Models;
+using SecureFolderFS.Sdk.Api.Protocol;
+using SecureFolderFS.Sdk.Api.Serialization;
+using SecureFolderFS.Sdk.Api.Services;
+using SecureFolderFS.Sdk.Api.Transport;
+using static SecureFolderFS.Sdk.Api.Constants;
+
+namespace SecureFolderFS.Sdk.Api.Session
+{
+ ///
+ /// Serves one connected client for the lifetime of its connection.
+ ///
+ internal sealed class ApiSession : IAsyncDisposable
+ {
+ // A client that stops reading must not grow server memory without bound. On overflow a
+ // notification is dropped, and the resulting revision gap tells the client to resynchronize
+ private const int OUTBOUND_QUEUE_CAPACITY = 256;
+
+ private static readonly string[] DefaultRequestedScopes =
+ [
+ Scopes.VAULTS_READ,
+ Scopes.VAULTS_TRIGGER,
+ Scopes.APP_CONTROL
+ ];
+
+ private readonly IApiConnection _connection;
+ private readonly ApiSessionContext _context;
+ private readonly NdjsonChannel _channel;
+ private readonly Channel _outbound;
+ private readonly PeerEvidence _evidence;
+
+ private PairedClient? _client;
+ private IReadOnlyList _scopes = [];
+ private string _clientName = "Unknown application";
+ private IDisposable? _subscription;
+ private bool _helloReceived;
+ private bool _disposed;
+
+ /// Gets whether this session has presented a valid token.
+ public bool IsAuthenticated => _client is not null;
+
+ public ApiSession(IApiConnection connection, ApiSessionContext context)
+ {
+ _connection = connection;
+ _context = context;
+ _channel = new NdjsonChannel(connection.Stream);
+ _evidence = SafeDescribePeer(connection.Peer, context.EvidenceProvider);
+ _outbound = Channel.CreateBounded(new BoundedChannelOptions(OUTBOUND_QUEUE_CAPACITY)
+ {
+ FullMode = BoundedChannelFullMode.Wait,
+ SingleReader = true
+ });
+ }
+
+ ///
+ /// Runs the read loop until the client disconnects or the host shuts down.
+ ///
+ public async Task RunAsync(CancellationToken cancellationToken)
+ {
+ using var sessionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ var pumpTask = PumpOutboundAsync(sessionCts.Token);
+
+ // A connection that never authenticates is dropped, so idle unpaired sockets cannot occupy
+ // the small unauthenticated budget indefinitely
+ using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ handshakeCts.CancelAfter(TimeSpan.FromSeconds(Limits.HANDSHAKE_TIMEOUT_SECONDS));
+
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var readToken = IsAuthenticated ? cancellationToken : handshakeCts.Token;
+
+ ApiMessage? request;
+ try
+ {
+ request = await _channel.ReadAsync(readToken);
+ }
+ catch (ApiProtocolException ex)
+ {
+ await TrySendAsync(ApiMessage.Failure(null, ex.Code, ex.Message), cancellationToken);
+ if (ex.IsFatal)
+ break;
+
+ continue;
+ }
+
+ if (request is null)
+ break;
+
+ var response = await DispatchAsync(request, cancellationToken);
+ if (response is not null)
+ await TrySendAsync(response, cancellationToken);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutdown, or the handshake deadline elapsed.
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException)
+ {
+ // The peer vanished mid-conversation.
+ }
+ finally
+ {
+ await sessionCts.CancelAsync();
+ await Task.WhenAny(pumpTask, Task.Delay(TimeSpan.FromSeconds(1), CancellationToken.None));
+ }
+ }
+
+ private async Task DispatchAsync(ApiMessage request, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(request.Method))
+ return ApiMessage.Failure(request.Id, ErrorCodes.INVALID_REQUEST, "Missing 'method'.");
+
+ // Requests without an identifier expect no reply
+ if (request.Id is null)
+ return null;
+
+ var id = request.Id.Value;
+ if (request.Method == Methods.HELLO)
+ return await HandleHelloAsync(id, request, cancellationToken);
+
+ if (!_helloReceived)
+ return ApiMessage.Failure(id, ErrorCodes.UNAUTHORIZED, "Send 'hello' first.");
+
+ if (request.Method == Methods.PAIR)
+ return await HandlePairAsync(id, request, cancellationToken);
+
+ if (!IsAuthenticated)
+ return ApiMessage.Failure(id, ErrorCodes.PAIRING_REQUIRED, "This client is not paired.");
+
+ var requiredScope = GetRequiredScope(request.Method);
+ if (requiredScope is null)
+ return ApiMessage.Failure(id, ErrorCodes.UNKNOWN_METHOD, $"Unknown method '{request.Method}'.");
+
+ if (!_scopes.Contains(requiredScope))
+ return ApiMessage.Failure(id, ErrorCodes.FORBIDDEN_SCOPE, $"Missing scope '{requiredScope}'.");
+
+ var (isAllowed, retryAfterMs) = _context.RateLimiter.Check(GetRateLimitKey(), GetRequestClass(request.Method));
+ if (!isAllowed)
+ return ApiMessage.Failure(id, ErrorCodes.RATE_LIMITED, "Request budget exceeded.", retryAfterMs);
+
+ if (!_context.Bridge.IsAvailable)
+ return ApiMessage.Failure(id, ErrorCodes.INVALID_STATE, "The application is still starting up.");
+
+ return request.Method switch
+ {
+ Methods.VAULTS_LIST => ApiMessage.Response(id, _context.Hub.GetSnapshot()),
+ Methods.VAULTS_SUBSCRIBE => HandleSubscribe(id),
+ Methods.VAULTS_UNSUBSCRIBE => HandleUnsubscribe(id),
+ Methods.VAULTS_REQUEST_UNLOCK => await HandleVaultActionAsync(id, request, VaultAction.RequestUnlock, cancellationToken),
+ Methods.VAULTS_LOCK => await HandleVaultActionAsync(id, request, VaultAction.Lock, cancellationToken),
+ Methods.VAULTS_REVEAL => await HandleVaultActionAsync(id, request, VaultAction.Reveal, cancellationToken),
+ Methods.APP_SHOW => await HandleShowAsync(id, cancellationToken),
+ _ => ApiMessage.Failure(id, ErrorCodes.UNKNOWN_METHOD, $"Unknown method '{request.Method}'.")
+ };
+ }
+
+ private async Task HandleHelloAsync(long id, ApiMessage request, CancellationToken cancellationToken)
+ {
+ HelloRequest? hello;
+ try
+ {
+ hello = request.GetParams();
+ }
+ catch (Exception)
+ {
+ return ApiMessage.Failure(id, ErrorCodes.INVALID_PARAMS, "Malformed 'hello' parameters.");
+ }
+
+ if (hello is null)
+ return ApiMessage.Failure(id, ErrorCodes.INVALID_PARAMS, "Missing 'hello' parameters.");
+
+ // Ranges must overlap in both directions, otherwise neither side can be understood.
+ if (hello.ProtocolMax < PROTOCOL_VERSION_MIN || hello.ProtocolMin > PROTOCOL_VERSION)
+ {
+ return ApiMessage.Failure(
+ id,
+ ErrorCodes.UNSUPPORTED_VERSION,
+ $"This build speaks protocol {PROTOCOL_VERSION_MIN}-{PROTOCOL_VERSION}.");
+ }
+
+ _helloReceived = true;
+ _clientName = SanitizeClientName(hello.ClientName);
+
+ var client = _context.PairingStore.Authenticate(hello.Token);
+ if (client is not null)
+ {
+ _client = client;
+ _scopes = client.Scopes;
+
+ try
+ {
+ await _context.PairingStore.TouchLastUsedAsync(client.Id, cancellationToken);
+ }
+ catch (Exception)
+ {
+ // A failed write must not break authentication.
+ }
+
+ _clientName = client.DisplayName;
+ }
+
+ var state = IsAuthenticated
+ ? SessionStates.PAIRED
+ : SessionStates.PAIRING_REQUIRED;
+
+ return ApiMessage.Response(id, new HelloResponse(
+ PROTOCOL_VERSION,
+ _context.AppVersion,
+ state,
+ Scopes.All,
+ _scopes));
+ }
+
+ private async Task HandlePairAsync(long id, ApiMessage request, CancellationToken cancellationToken)
+ {
+ if (IsAuthenticated)
+ return ApiMessage.Response(id, new PairResponse(string.Empty, _scopes));
+
+ var fingerprint = ComputeFingerprint(_evidence.ExecutablePath, _clientName);
+ if (_context.PairingStore.IsInDenialCooldown(fingerprint))
+ {
+ return ApiMessage.Failure(
+ id,
+ ErrorCodes.PAIRING_UNAVAILABLE,
+ "This application was recently refused and must wait before asking again.",
+ (int)_context.PairingStore.DenialCooldown.TotalMilliseconds);
+ }
+
+ var (isAllowed, retryAfterMs) = _context.RateLimiter.Check(fingerprint, ApiRequestType.Pairing);
+ if (!isAllowed)
+ return ApiMessage.Failure(id, ErrorCodes.RATE_LIMITED, "Too many pairing attempts.", retryAfterMs);
+
+ var requestedScopes = NormalizeScopes(request.GetParams()?.Scopes);
+
+ // Only one consent prompt may be open at a time, so a burst of connections cannot stack dialogs.
+ using var scope = _context.Coalescer.TryBeginScope("pairing");
+ if (scope is null)
+ {
+ return ApiMessage.Failure(
+ id,
+ ErrorCodes.PAIRING_UNAVAILABLE,
+ "Another pairing request is already awaiting a decision.");
+ }
+
+ ApiConsentResult consent;
+ try
+ {
+ consent = await _context.ConsentService.RequestConsentAsync(
+ new ApiConsentRequest(_clientName, _evidence, requestedScopes),
+ cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception)
+ {
+ return ApiMessage.Failure(id, ErrorCodes.INTERNAL_ERROR, "The consent prompt could not be shown.");
+ }
+
+ if (!consent.IsGranted || consent.GrantedScopes.Count == 0)
+ {
+ await _context.PairingStore.RecordDenialAsync(fingerprint, cancellationToken);
+ return ApiMessage.Failure(id, ErrorCodes.PAIRING_DENIED, "The user declined the request.");
+ }
+
+ var token = await _context.PairingStore.CreateClientAsync(_clientName, _evidence, consent.GrantedScopes, cancellationToken);
+
+ _client = _context.PairingStore.Authenticate(token);
+ _scopes = consent.GrantedScopes;
+
+ return ApiMessage.Response(id, new PairResponse(token, consent.GrantedScopes));
+ }
+
+ private ApiMessage HandleSubscribe(long id)
+ {
+ _subscription?.Dispose();
+
+ var (snapshot, subscription) = _context.Hub.Subscribe(TryEnqueueNotification);
+ _subscription = subscription;
+
+ return ApiMessage.Response(id, snapshot);
+ }
+
+ private ApiMessage HandleUnsubscribe(long id)
+ {
+ _subscription?.Dispose();
+ _subscription = null;
+
+ return ApiMessage.Response(id, new ActionResponse(ActionStatus.OK));
+ }
+
+ private async Task HandleVaultActionAsync(
+ long id,
+ ApiMessage request,
+ VaultAction action,
+ CancellationToken cancellationToken)
+ {
+ VaultRequest? parameters;
+ try
+ {
+ parameters = request.GetParams();
+ }
+ catch (Exception)
+ {
+ return ApiMessage.Failure(id, ErrorCodes.INVALID_PARAMS, "Malformed parameters.");
+ }
+
+ if (string.IsNullOrEmpty(parameters?.VaultId))
+ return ApiMessage.Failure(id, ErrorCodes.INVALID_PARAMS, "Missing 'vaultId'.");
+
+ var vaultId = parameters.VaultId;
+ using var scope = _context.Coalescer.TryBeginScope($"{action}:{vaultId}");
+ if (scope is null)
+ return ApiMessage.Response(id, new ActionResponse(ActionStatus.ALREADY_PENDING));
+
+ var outcome = action switch
+ {
+ VaultAction.RequestUnlock => await _context.Bridge.RequestUnlockAsync(vaultId, cancellationToken),
+ VaultAction.Lock => await _context.Bridge.LockAsync(vaultId, cancellationToken),
+ VaultAction.Reveal => await _context.Bridge.RevealAsync(vaultId, cancellationToken),
+ _ => ApiActionOutcome.Unavailable
+ };
+
+ return TranslateOutcome(id, outcome);
+ }
+
+ private async Task HandleShowAsync(long id, CancellationToken cancellationToken)
+ {
+ var outcome = await _context.Bridge.ShowMainWindowAsync(cancellationToken);
+ return TranslateOutcome(id, outcome);
+ }
+
+ private static ApiMessage TranslateOutcome(long id, ApiActionOutcome outcome) => outcome switch
+ {
+ ApiActionOutcome.Ok => ApiMessage.Response(id, new ActionResponse(ActionStatus.OK)),
+ ApiActionOutcome.AlreadyPending => ApiMessage.Response(id, new ActionResponse(ActionStatus.ALREADY_PENDING)),
+ ApiActionOutcome.NoChange => ApiMessage.Response(id, new ActionResponse(ActionStatus.NO_CHANGE)),
+ ApiActionOutcome.NotFound => ApiMessage.Failure(id, ErrorCodes.NOT_FOUND, "No such vault."),
+ ApiActionOutcome.InvalidState => ApiMessage.Failure(id, ErrorCodes.INVALID_STATE, "The vault is not in a state that allows this."),
+ _ => ApiMessage.Failure(id, ErrorCodes.INTERNAL_ERROR, "The request could not be completed.")
+ };
+
+ private bool TryEnqueueNotification(ApiMessage message)
+ => _outbound.Writer.TryWrite(message);
+
+ private async Task PumpOutboundAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach (var message in _outbound.Reader.ReadAllAsync(cancellationToken))
+ await _channel.WriteAsync(message, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // Session ending.
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException)
+ {
+ // The peer went away; the read loop will notice and tear the session down.
+ }
+ }
+
+ private async Task TrySendAsync(ApiMessage message, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await _channel.WriteAsync(message, cancellationToken);
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException)
+ {
+ // Losing a response to a vanished peer is not actionable.
+ }
+ }
+
+ private string GetRateLimitKey()
+ {
+ // Keyed by pairing when known, else by the executable, so reconnecting does not reset budgets.
+ return _client?.Id ?? ComputeFingerprint(_evidence.ExecutablePath, _clientName);
+ }
+
+ // Recognizes a caller across pairing attempts. Denial cooldowns and pre-pairing rate budgets are
+ // keyed by this, so simply reconnecting cannot hand a misbehaving caller a fresh start.
+ private static string ComputeFingerprint(string? executablePath, string clientName)
+ {
+ var material = string.IsNullOrEmpty(executablePath) ? $"name:{clientName}" : $"path:{executablePath}";
+ return Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(material)));
+ }
+
+ private static string? GetRequiredScope(string method) => method switch
+ {
+ Methods.VAULTS_LIST or
+ Methods.VAULTS_SUBSCRIBE or
+ Methods.VAULTS_UNSUBSCRIBE => Scopes.VAULTS_READ,
+
+ Methods.VAULTS_REQUEST_UNLOCK or
+ Methods.VAULTS_LOCK or
+ Methods.VAULTS_REVEAL => Scopes.VAULTS_TRIGGER,
+
+ Methods.APP_SHOW => Scopes.APP_CONTROL,
+
+ _ => null
+ };
+
+ private static ApiRequestType GetRequestClass(string method) => method switch
+ {
+ Methods.VAULTS_LIST or
+ Methods.VAULTS_SUBSCRIBE or
+ Methods.VAULTS_UNSUBSCRIBE => ApiRequestType.Read,
+
+ _ => ApiRequestType.Trigger
+ };
+
+ private static IReadOnlyList NormalizeScopes(IReadOnlyList? requested)
+ {
+ if (requested is null || requested.Count == 0)
+ return DefaultRequestedScopes;
+
+ // Silently discard unrecognized scopes so a client cannot inflate the prompt.
+ var filtered = requested.Where(DefaultRequestedScopes.Contains).Distinct().ToArray();
+ return filtered.Length == 0 ? DefaultRequestedScopes : filtered;
+ }
+
+ private static string SanitizeClientName(string? name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ return "Unknown application";
+
+ var trimmed = name.Trim();
+ if (trimmed.Length > Limits.MAX_CLIENT_NAME_LENGTH)
+ trimmed = trimmed[..Limits.MAX_CLIENT_NAME_LENGTH];
+
+ // Control characters could forge line breaks or spoof surrounding text in the consent prompt.
+ return new string(trimmed.Where(x => !char.IsControl(x)).ToArray());
+ }
+
+ private static PeerEvidence SafeDescribePeer(ApiPeerHandle peer, IPeerEvidenceProvider provider)
+ {
+ try
+ {
+ return provider.Describe(peer);
+ }
+ catch (Exception)
+ {
+ // Evidence is advisory, so failing to collect it must never refuse a connection.
+ return PeerEvidence.Unknown;
+ }
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+
+ _subscription?.Dispose();
+ _subscription = null;
+
+ _outbound.Writer.TryComplete();
+ _channel.Dispose();
+
+ await _connection.DisposeAsync();
+ }
+
+ private enum VaultAction
+ {
+ RequestUnlock,
+ Lock,
+ Reveal
+ }
+ }
+
+ ///
+ /// The services every session shares.
+ ///
+ internal sealed record ApiSessionContext(
+ IVaultApiBridge Bridge,
+ VaultNotificationHub Hub,
+ IPairingStore PairingStore,
+ ApiRateLimiterHelper RateLimiter,
+ RequestCoalescerHelper Coalescer,
+ IApiConsentService ConsentService,
+ IPeerEvidenceProvider EvidenceProvider,
+ string AppVersion);
+}