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..81e0a7a19 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Cli/AppApiClient.cs @@ -0,0 +1,355 @@ +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. +/// +internal sealed class AppApiClient : IAsyncDisposable +{ + private const string CLIENT_NAME = "SecureFolderFS CLI"; + private const int PROTOCOL_VERSION = 1; + + 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 + _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 + 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. + /// + 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. + /// + 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/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/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/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 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/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/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/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/ServiceImplementation/UnoApiConsentService.cs b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoApiConsentService.cs new file mode 100644 index 000000000..fc64b3ac9 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/ServiceImplementation/UnoApiConsentService.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Api.Models; +using SecureFolderFS.Sdk.Api.Services; +using SecureFolderFS.Sdk.Extensions; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels.Views.Overlays; +using SecureFolderFS.Shared; +using SecureFolderFS.Shared.Extensions; +using ApiConstants = SecureFolderFS.Sdk.Api.Constants; + +namespace SecureFolderFS.Uno.ServiceImplementation +{ + /// + 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/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/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/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}" /> + + + + + + + + + + + + + + + + + + + + + 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/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..d027e634e --- /dev/null +++ b/src/Sdk/SecureFolderFS.Sdk/Services/ILocalIntegrationsService.cs @@ -0,0 +1,42 @@ +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 . + /// 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. + /// + /// A that cancels this action. + /// A that represents the asynchronous operation. + Task RevokeAllClientsAsync(CancellationToken cancellationToken = default); + } +} 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(); + } + } +} 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(); 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)));