//-----------------------------------------------------------------------
//
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
//
//-----------------------------------------------------------------------
namespace AliasVault.Client.Services.JsInterop;
using System.Security.Cryptography;
using System.Text.Json;
using AliasVault.Client.Services.JsInterop.Models;
using AliasVault.Shared.Core;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
///
/// JavaScript interop service for calling JavaScript functions from C#.
///
/// IJSRuntime.
public sealed class JsInteropService(IJSRuntime jsRuntime)
{
private const string _DEFAULT_VERSION = "0.0.0";
private const string _VAULT_SQL_GENERATOR_FACTORY_FUNCTION = "CreateVaultSqlGenerator";
private readonly string _cacheBuster = AppInfo.GetFullVersion();
private IJSObjectReference? _identityGeneratorModule;
private IJSObjectReference? _passwordGeneratorModule;
private IJSObjectReference? _vaultSqlInteropModule;
///
/// Initialize the identity generator module.
///
/// A task representing the asynchronous operation.
public async Task InitializeAsync()
{
_identityGeneratorModule = await jsRuntime.InvokeAsync("import", $"./js/dist/core/identity-generator/index.mjs?v={_cacheBuster}");
if (_identityGeneratorModule == null)
{
throw new InvalidOperationException("Failed to initialize identity generator module");
}
_passwordGeneratorModule = await jsRuntime.InvokeAsync("import", $"./js/dist/core/password-generator/index.mjs?v={_cacheBuster}");
if (_passwordGeneratorModule == null)
{
throw new InvalidOperationException("Failed to initialize password generator module");
}
_vaultSqlInteropModule = await jsRuntime.InvokeAsync("import", $"./js/dist/core/vault/index.mjs?v={_cacheBuster}");
if (_vaultSqlInteropModule == null)
{
throw new InvalidOperationException("Failed to initialize vault SQL generator module");
}
}
///
/// Symmetrically encrypts a string using the provided encryption key.
///
/// Plain text to encrypt.
/// Encryption key to use.
/// Encrypted ciphertext.
public async Task SymmetricEncrypt(string plaintext, string encryptionKey)
{
if (string.IsNullOrEmpty(plaintext))
{
return plaintext;
}
return await jsRuntime.InvokeAsync("cryptoInterop.encrypt", plaintext, encryptionKey);
}
///
/// Symmetrically decrypts a string using the provided encryption key.
///
/// Cipher text to decrypt.
/// Encryption key to use.
/// Encrypted ciphertext.
public async Task SymmetricDecrypt(string ciphertext, string encryptionKey)
{
if (string.IsNullOrEmpty(ciphertext))
{
return ciphertext;
}
return await jsRuntime.InvokeAsync("cryptoInterop.decrypt", ciphertext, encryptionKey);
}
///
/// Downloads a file from a stream.
///
/// Filename of the download.
/// Blob byte array to download.
/// Task.
public async Task DownloadFileFromStream(string filename, byte[] blob) =>
await jsRuntime.InvokeVoidAsync("downloadFileFromStream", filename, blob);
///
/// Focus an element by its ID.
///
/// The element ID to focus.
/// Task.
public async Task FocusElementById(string elementId) =>
await jsRuntime.InvokeVoidAsync("focusElement", elementId);
///
/// Focus an element by its ID and select all its text.
///
/// The element ID to focus and select.
/// Task.
public async Task FocusAndSelectElementById(string elementId) =>
await jsRuntime.InvokeVoidAsync("focusAndSelectElement", elementId);
///
/// Blur (defocus) an element by its ID.
///
/// The element ID to focus.
/// Task.
public async Task BlurElementById(string elementId) =>
await jsRuntime.InvokeVoidAsync("blurElement", elementId);
///
/// Copy a string to the browser's clipboard.
///
/// Value to copy to clipboard.
/// Task.
public async Task CopyToClipboard(string value) =>
await jsRuntime.InvokeVoidAsync("navigator.clipboard.writeText", value);
///
/// Copy a string to the clipboard and schedule automatic clearing after specified seconds.
///
/// Value to copy to clipboard.
/// Number of seconds after which to clear the clipboard.
/// True if copy was successful, false otherwise.
public async Task CopyToClipboardWithClear(string value, int clearAfterSeconds) =>
await jsRuntime.InvokeAsync("copyToClipboardWithClear", value, clearAfterSeconds);
///
/// Clear the clipboard safely, handling document focus issues.
///
/// True if clipboard was cleared successfully, false otherwise.
public async Task SafeClearClipboard() =>
await jsRuntime.InvokeAsync("safeClearClipboard");
///
/// Clear the clipboard by setting it to an empty string.
///
/// Task.
public async Task ClearClipboard() =>
await jsRuntime.InvokeVoidAsync("navigator.clipboard.writeText", string.Empty);
///
/// Register a callback for clipboard status changes.
///
/// Component type.
/// DotNetObjectReference.
/// Task.
public async Task RegisterClipboardStatusCallback(DotNetObjectReference objRef)
where TComponent : class =>
await jsRuntime.InvokeVoidAsync("registerClipboardStatusCallback", objRef);
///
/// Unregister the clipboard status callback.
///
/// Task.
public async Task UnregisterClipboardStatusCallback() =>
await jsRuntime.InvokeVoidAsync("unregisterClipboardStatusCallback");
///
/// Initializes the top menu.
///
/// Task.
public async Task InitTopMenu() =>
await jsRuntime.InvokeVoidAsync("window.initTopMenu");
///
/// Registers a click outside handler for the top menu.
///
/// Component type.
/// DotNetObjectReference.
/// Task.
public async Task TopMenuClickOutsideHandler(DotNetObjectReference objRef)
where TComponent : class =>
await jsRuntime.InvokeVoidAsync("window.topMenuClickOutsideHandler", objRef);
///
/// Registers a callback for dark mode theme changes.
///
/// Component type.
/// DotNetObjectReference.
/// Task.
public async Task RegisterDarkModeCallback(DotNetObjectReference objRef)
where TComponent : class =>
await jsRuntime.InvokeVoidAsync("window.registerDarkModeCallback", objRef);
///
/// Generates a new RSA key pair.
///
/// Tuple with public and private key.
public async Task<(string PublicKey, string PrivateKey)> GenerateRsaKeyPair()
{
var result = await jsRuntime.InvokeAsync("rsaInterop.generateRsaKeyPair");
return (result.GetProperty("publicKey").GetString()!, result.GetProperty("privateKey").GetString()!);
}
///
/// Encrypts a plaintext with a public key.
///
/// Plain text to encrypt.
/// Public key to use for encryption.
/// Encrypted ciphertext.
public async Task EncryptWithPublicKey(string plaintext, string publicKey) =>
await jsRuntime.InvokeAsync("rsaInterop.encryptWithPublicKey", plaintext, publicKey);
///
/// Decrypts a ciphertext with a private key.
///
/// Ciphertext to decrypt.
/// Private key to use for decryption.
/// Decrypted bytes as base64 string.
public async Task DecryptWithPrivateKey(string base64Ciphertext, string privateKey)
{
try
{
// Invoke the JavaScript function and get the result as a base64 string
string result = await jsRuntime.InvokeAsync("rsaInterop.decryptWithPrivateKey", base64Ciphertext, privateKey);
return result;
}
catch (JSException ex)
{
await Console.Error.WriteLineAsync($"JavaScript decryption error: {ex.Message}");
throw new CryptographicException("Decryption failed", ex);
}
}
///
/// Generates a QR code.
///
/// Element ID that contains data-url attribute which to generate QR code for.
/// Task.
public async Task GenerateQrCode(string elementId)
{
try
{
// Invoke the JavaScript function and get the result as a byte array
await jsRuntime.InvokeVoidAsync("generateQrCode", elementId);
}
catch (JSException ex)
{
await Console.Error.WriteLineAsync($"JavaScript error: {ex.Message}");
}
}
///
/// Gets the WebAuthn credential derived key used to encrypt or decrypt the persisted vault encryption key.
///
/// The credential ID to use.
/// The salt to use.
/// Derived key as base64 string.
/// Thrown when the authenticator does not support the PRF extension.
/// Thrown when the PRF key derivation fails.
/// Thrown when there's an error getting the WebAuthn credential or when decryption fails.
public async Task GetWebAuthnCredentialDerivedKey(string credentialId, string salt)
{
try
{
var result = await jsRuntime.InvokeAsync("getWebAuthnCredentialAndDeriveKey", credentialId, salt);
if (result.Error is null)
{
return result.DerivedKey ?? throw new CryptographicException("Derived key is null");
}
throw result.Error switch
{
"PRF_NOT_SUPPORTED" => new NotSupportedException("Authenticator does not support the PRF extension."),
"PRF_DERIVATION_FAILED" => new InvalidOperationException("Failed to derive key using PRF extension."),
"WEBAUTHN_CREATE_ERROR" => new CryptographicException($"Failed to create WebAuthn credential: {result.Message}"),
_ => new CryptographicException($"Unknown error occurred: {result.Error ?? "No error specified"}"),
};
}
catch (JSException ex)
{
await Console.Error.WriteLineAsync($"JavaScript error: {ex.Message}");
throw new CryptographicException("Decryption failed", ex);
}
}
///
/// Gets or creates the WebAuthn credential derived key used to encrypt or decrypt the persisted vault encryption key.
///
/// The username for the credential.
/// A tuple containing the credential ID, salt and the derived key.
/// Thrown when decryption fails due to a JavaScript error.
/// Thrown when the authenticator does not support the PRF extension.
/// Thrown when the PRF key derivation fails.
public async Task<(string CredentialId, string Salt, string DerivedKey)> CreateWebAuthnCredentialDerivedKey(string username)
{
try
{
var result = await jsRuntime.InvokeAsync("createWebAuthnCredentialAndDeriveKey", username);
if (result.CredentialId is not null &&
result.Salt is not null &&
result.DerivedKey is not null)
{
return (result.CredentialId, result.Salt, result.DerivedKey);
}
throw result.Error switch
{
"PRF_NOT_SUPPORTED" => new NotSupportedException("Authenticator does not support the PRF extension."),
"PRF_DERIVATION_FAILED" => new InvalidOperationException("Failed to derive key using PRF extension."),
"WEBAUTHN_CREATE_ERROR" => new CryptographicException($"Failed to create WebAuthn credential: {result.Message}"),
_ => new CryptographicException($"Unknown error occurred: {result.Error ?? "No error specified"}"),
};
}
catch (JSException ex)
{
await Console.Error.WriteLineAsync($"JavaScript error: {ex.Message}");
throw new CryptographicException("Decryption failed", ex);
}
}
///
/// Scrolls to the top of the page.
///
/// Task.
public async Task ScrollToTop()
{
await jsRuntime.InvokeVoidAsync("window.scrollTo", 0, 0);
}
///
/// Sets up an IntersectionObserver for infinite scrolling.
///
/// Component type.
/// The sentinel element to observe.
/// DotNetObjectReference.
/// Task.
public async Task SetupInfiniteScroll(ElementReference element, DotNetObjectReference objRef)
where TComponent : class =>
await jsRuntime.InvokeVoidAsync("window.setupInfiniteScroll", element, objRef);
///
/// Tears down the IntersectionObserver for infinite scrolling.
///
/// The sentinel element that was observed.
/// Task.
public async Task TeardownInfiniteScroll(ElementReference element) =>
await jsRuntime.InvokeVoidAsync("window.teardownInfiniteScroll", element);
///
/// Registers a visibility callback which is invoked when the visibility of component changes in client.
///
/// Component type.
/// DotNetObjectReference.
/// Task.
public async Task RegisterVisibilityCallback(DotNetObjectReference objRef)
where TComponent : class =>
await jsRuntime.InvokeVoidAsync("window.registerVisibilityCallback", objRef);
///
/// Unregisters the visibility callback to prevent memory leaks.
///
/// Component type.
/// DotNetObjectReference.
/// Task.
public async Task UnregisterVisibilityCallback(DotNetObjectReference objRef)
where TComponent : class =>
await jsRuntime.InvokeVoidAsync("window.unregisterVisibilityCallback", objRef);
///
/// Symmetrically decrypts a byte array using the provided encryption key.
///
/// Cipher bytes to decrypt.
/// Encryption key to use.
/// Decrypted bytes.
public async Task SymmetricDecryptBytes(byte[] cipherBytes, string encryptionKey)
{
if (cipherBytes == null || cipherBytes.Length == 0)
{
return [];
}
var base64Ciphertext = Convert.ToBase64String(cipherBytes);
return await jsRuntime.InvokeAsync("cryptoInterop.decryptBytes", base64Ciphertext, encryptionKey);
}
///
/// Gets all available languages for identity generation.
///
/// Array of language options.
public async Task> GetAvailableIdentityGeneratorLanguagesAsync()
{
try
{
if (_identityGeneratorModule == null)
{
await InitializeAsync();
}
var result = await _identityGeneratorModule!.InvokeAsync>("getAvailableLanguages");
return result ?? new List();
}
catch (JSException ex)
{
await Console.Error.WriteLineAsync($"JavaScript error getting available languages: {ex.Message}");
return new List();
}
}
///
/// Gets all available age range options from the shared JavaScript utility.
///
/// Array of age range options.
public async Task> GetAvailableIdentityGeneratorAgeRangesAsync()
{
try
{
if (_identityGeneratorModule == null)
{
await InitializeAsync();
}
var result = await _identityGeneratorModule!.InvokeAsync>("getAvailableAgeRanges");
return result ?? new List();
}
catch (JSException ex)
{
await Console.Error.WriteLineAsync($"JavaScript error getting age ranges: {ex.Message}");
return new List();
}
}
///
/// Maps a UI language code to an identity generator language code.
/// If no explicit match is found, returns null to indicate no preference.
///
/// The UI language code (e.g., "en", "en-US", "nl-NL", "de-DE", "fr").
/// The matching identity generator language code or null if no match.
public async Task MapUiLanguageToIdentityLanguageAsync(string? uiLanguageCode)
{
try
{
if (string.IsNullOrEmpty(uiLanguageCode))
{
return null;
}
if (_identityGeneratorModule == null)
{
await InitializeAsync();
}
var result = await _identityGeneratorModule!.InvokeAsync("mapUiLanguageToIdentityLanguage", uiLanguageCode);
return result;
}
catch (JSException ex)
{
await Console.Error.WriteLineAsync($"JavaScript error mapping UI language to identity language: {ex.Message}");
return null;
}
}
///
/// Converts an age range string to birthdate options using the shared JavaScript utility.
///
/// Age range string (e.g., "21-25", "30-35", or "random").
/// Birthdate options object or null if random.
public async Task