mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-24 08:17:57 -04:00
Add Rust Core WASM scaffolding to AliasVault.Client (#1404)
This commit is contained in:
@@ -10,6 +10,7 @@ using AliasVault.Client;
|
||||
using AliasVault.Client.Main.Services;
|
||||
using AliasVault.Client.Providers;
|
||||
using AliasVault.Client.Services;
|
||||
using AliasVault.Client.Services.Native;
|
||||
using AliasVault.RazorComponents.Services;
|
||||
using AliasVault.Shared.Core;
|
||||
using Blazor.WebAssembly.DynamicCulture.Loader;
|
||||
@@ -99,6 +100,7 @@ builder.Services.AddScoped<ClipboardCopyService>();
|
||||
builder.Services.AddScoped<ConfirmModalService>();
|
||||
builder.Services.AddScoped<QuickCreateStateService>();
|
||||
builder.Services.AddScoped<LanguageService>();
|
||||
builder.Services.AddScoped<RustCore>();
|
||||
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddBlazoredLocalStorage();
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="DbMergeUtility.cs" company="aliasvault">
|
||||
// Copyright (c) aliasvault. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Client.Services.Database;
|
||||
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// Class with helper methods to merge two or more vaults.
|
||||
/// </summary>
|
||||
public static class DbMergeUtility
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the names of all tables in the SQLite database.
|
||||
/// </summary>
|
||||
/// <param name="connection">The SQLite connection to use.</param>
|
||||
/// <returns>A list of table names.</returns>
|
||||
/// <returns>List of table names.</returns>
|
||||
public static async Task<List<string>> GetTableNames(SqliteConnection connection)
|
||||
{
|
||||
var tables = new List<string>();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';";
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
tables.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges data from a source table into a base table.
|
||||
/// </summary>
|
||||
/// <param name="baseConnection">The connection to the base database.</param>
|
||||
/// <param name="sourceConnection">The connection to the source database.</param>
|
||||
/// <param name="tableName">The name of the table to merge.</param>
|
||||
/// <param name="logger">ILogger instance.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public static async Task MergeTable(SqliteConnection baseConnection, SqliteConnection sourceConnection, string tableName, ILogger<DbService> logger)
|
||||
{
|
||||
await using var baseCommand = baseConnection.CreateCommand();
|
||||
await using var sourceCommand = sourceConnection.CreateCommand();
|
||||
|
||||
baseCommand.CommandText = $"PRAGMA table_info({tableName})";
|
||||
var columns = new List<string>();
|
||||
|
||||
// Get column names from the base table.
|
||||
await using (var reader = await baseCommand.ExecuteReaderAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
string columnName = reader.GetString(1);
|
||||
columns.Add(columnName);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the table has Id, UpdatedAt and IsDeleted columns which are required in order to merge.
|
||||
// If columns are missing, skip the table.
|
||||
if (!columns.Contains("Id") || !columns.Contains("UpdatedAt") || !columns.Contains("IsDeleted"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all records from the source table.
|
||||
sourceCommand.CommandText = $"SELECT * FROM {tableName}";
|
||||
await using var sourceReader = await sourceCommand.ExecuteReaderAsync();
|
||||
|
||||
logger.LogDebug("Got records for {tableName}.", tableName);
|
||||
while (await sourceReader.ReadAsync())
|
||||
{
|
||||
var id = sourceReader.GetValue(0);
|
||||
var updatedAt = sourceReader.GetDateTime(columns.IndexOf("UpdatedAt"));
|
||||
|
||||
// Check if the record exists in the base table.
|
||||
baseCommand.CommandText = $"SELECT UpdatedAt FROM {tableName} WHERE Id = @Id";
|
||||
baseCommand.Parameters.Clear();
|
||||
baseCommand.Parameters.AddWithValue("@Id", id);
|
||||
|
||||
logger.LogDebug("Checking if record exists in {tableName}.", tableName);
|
||||
|
||||
var existingRecord = await baseCommand.ExecuteScalarAsync();
|
||||
if (existingRecord != null)
|
||||
{
|
||||
logger.LogDebug("Record exists in {tableName}.", tableName);
|
||||
|
||||
// Record exists, compare UpdatedAt if it exists.
|
||||
logger.LogDebug("Comparing UpdatedAt in {tableName}.", tableName);
|
||||
logger.LogDebug("UpdatedAt: {existingRecord}", existingRecord);
|
||||
var baseUpdatedAt = DateTime.Parse((string)existingRecord, CultureInfo.InvariantCulture);
|
||||
if (updatedAt > baseUpdatedAt)
|
||||
{
|
||||
// Source record is newer, update the base record.
|
||||
await UpdateRecord(baseConnection, tableName, sourceReader, columns);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Base record is newer, skip.
|
||||
logger.LogDebug("Base record is newer, skipping {tableName}.", tableName);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Record doesn't exist in base, add it.
|
||||
await InsertRecord(baseConnection, tableName, sourceReader, columns);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug("Finished merging {tableName}.", tableName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a new record into the specified table with data from the source reader.
|
||||
/// </summary>
|
||||
/// <param name="connection">The SQLite connection to use.</param>
|
||||
/// <param name="tableName">The name of the table to insert into.</param>
|
||||
/// <param name="sourceReader">The data reader containing the source record.</param>
|
||||
/// <param name="columns">The list of column names in the table.</param>
|
||||
/// <returns>Task.</returns>
|
||||
private static async Task InsertRecord(SqliteConnection connection, string tableName, SqliteDataReader sourceReader, List<string> columns)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
var columnNames = string.Join(", ", columns);
|
||||
var parameterNames = string.Join(", ", columns.Select(c => $"@{c}"));
|
||||
command.CommandText = $"INSERT INTO {tableName} ({columnNames}) VALUES ({parameterNames})";
|
||||
|
||||
for (int i = 0; i < columns.Count; i++)
|
||||
{
|
||||
command.Parameters.AddWithValue($"@{columns[i]}", sourceReader.GetValue(i));
|
||||
}
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a record in the specified table with data from the source reader.
|
||||
/// </summary>
|
||||
/// <param name="connection">The SQLite connection to use.</param>
|
||||
/// <param name="tableName">The name of the table to update.</param>
|
||||
/// <param name="sourceReader">The data reader containing the source record.</param>
|
||||
/// <param name="columns">The list of column names in the table.</param>
|
||||
/// <returns>Task.</returns>
|
||||
private static async Task UpdateRecord(SqliteConnection connection, string tableName, SqliteDataReader sourceReader, List<string> columns)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
var updateColumns = string.Join(", ", columns.Select(c => $"{c} = @{c}"));
|
||||
command.CommandText = $"UPDATE {tableName} SET {updateColumns} WHERE Id = @Id";
|
||||
|
||||
for (int i = 0; i < columns.Count; i++)
|
||||
{
|
||||
command.Parameters.AddWithValue($"@{columns[i]}", sourceReader.GetValue(i));
|
||||
}
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,13 @@ namespace AliasVault.Client.Services.Database;
|
||||
|
||||
using System.Data;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using AliasClientDb;
|
||||
using AliasClientDb.Models;
|
||||
using AliasVault.Client.Services;
|
||||
using AliasVault.Client.Services.Auth;
|
||||
using AliasVault.Client.Services.JsInterop.Models;
|
||||
using AliasVault.Client.Services.Native;
|
||||
using AliasVault.Client.Utilities;
|
||||
using AliasVault.Shared.Models.Enums;
|
||||
using AliasVault.Shared.Models.WebApi.Vault;
|
||||
@@ -30,6 +32,7 @@ public sealed class DbService : IDisposable
|
||||
private const string _UNKNOWN_VERSION = "Unknown";
|
||||
private readonly AuthService _authService;
|
||||
private readonly JsInteropService _jsInteropService;
|
||||
private readonly RustCore _rustCore;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly DbServiceState _state = new();
|
||||
private readonly Config _config;
|
||||
@@ -48,14 +51,16 @@ public sealed class DbService : IDisposable
|
||||
/// </summary>
|
||||
/// <param name="authService">AuthService.</param>
|
||||
/// <param name="jsInteropService">JsInteropService.</param>
|
||||
/// <param name="rustCore">RustCore service for WASM interop.</param>
|
||||
/// <param name="httpClient">HttpClient.</param>
|
||||
/// <param name="config">Config instance.</param>
|
||||
/// <param name="globalNotificationService">Global notification service.</param>
|
||||
/// <param name="logger">ILogger instance.</param>
|
||||
public DbService(AuthService authService, JsInteropService jsInteropService, HttpClient httpClient, Config config, GlobalNotificationService globalNotificationService, ILogger<DbService> logger)
|
||||
public DbService(AuthService authService, JsInteropService jsInteropService, RustCore rustCore, HttpClient httpClient, Config config, GlobalNotificationService globalNotificationService, ILogger<DbService> logger)
|
||||
{
|
||||
_authService = authService;
|
||||
_jsInteropService = jsInteropService;
|
||||
_rustCore = rustCore;
|
||||
_httpClient = httpClient;
|
||||
_config = config;
|
||||
_globalNotificationService = globalNotificationService;
|
||||
@@ -113,7 +118,7 @@ public sealed class DbService : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two or more databases into one.
|
||||
/// Merges two or more databases into one using the Rust WASM merge logic.
|
||||
/// </summary>
|
||||
/// <returns>Bool which indicates if merging was successful.</returns>
|
||||
public async Task<bool> MergeDatabasesAsync()
|
||||
@@ -128,51 +133,61 @@ public sealed class DbService : IDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
var sqlConnections = new List<SqliteConnection>();
|
||||
_logger.LogInformation("Merging databases...");
|
||||
_logger.LogInformation("Merging databases using Rust WASM...");
|
||||
|
||||
// Decrypt and instantiate each vault as a separate in-memory SQLite database.
|
||||
// Get the list of syncable table names from Rust core.
|
||||
var tableNames = await _rustCore.GetSyncableTableNamesAsync();
|
||||
|
||||
// Read local tables as JSON.
|
||||
var localTables = await ReadTablesAsJsonAsync(_sqlConnection!, tableNames);
|
||||
_logger.LogDebug("Read {Count} local tables.", localTables.Count);
|
||||
|
||||
// Process each vault to merge.
|
||||
foreach (var vault in vaultsToMerge.Vaults)
|
||||
{
|
||||
// Store username of the loaded vault in memory to send to server as sanity check when updating the vault later.
|
||||
_authService.StoreUsername(vault.Username);
|
||||
|
||||
var decryptedBase64String = await _jsInteropService.SymmetricDecrypt(vault.Blob, _authService.GetEncryptionKeyAsBase64Async());
|
||||
|
||||
_logger.LogInformation("Decrypted vault {VaultUpdatedAt}.", vault.UpdatedAt);
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await ImportDbContextFromBase64Async(decryptedBase64String, connection);
|
||||
sqlConnections.Add(connection);
|
||||
|
||||
// Create a temporary in-memory SQLite database for the server vault.
|
||||
await using var serverConnection = new SqliteConnection("Data Source=:memory:");
|
||||
await serverConnection.OpenAsync();
|
||||
await ImportDbContextFromBase64Async(decryptedBase64String, serverConnection);
|
||||
|
||||
// Read server tables as JSON.
|
||||
var serverTables = await ReadTablesAsJsonAsync(serverConnection, tableNames);
|
||||
_logger.LogDebug("Read {Count} server tables.", serverTables.Count);
|
||||
|
||||
// Create the merge input.
|
||||
var mergeInput = new MergeInput
|
||||
{
|
||||
LocalTables = localTables,
|
||||
ServerTables = serverTables,
|
||||
};
|
||||
|
||||
// Call Rust WASM merge.
|
||||
var mergeOutput = await _rustCore.MergeVaultsAsync(mergeInput);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Merge completed: {TablesProcessed} tables, {FromLocal} kept local, {FromServer} from server, {Inserted} inserted, {Conflicts} conflicts.",
|
||||
mergeOutput.Stats.TablesProcessed,
|
||||
mergeOutput.Stats.RecordsFromLocal,
|
||||
mergeOutput.Stats.RecordsFromServer,
|
||||
mergeOutput.Stats.RecordsInserted,
|
||||
mergeOutput.Stats.Conflicts);
|
||||
|
||||
// Execute the SQL statements returned by the merge.
|
||||
await ExecuteMergeSqlStatementsAsync(mergeOutput.Statements);
|
||||
|
||||
// Update local tables for the next merge iteration (if there are multiple vaults).
|
||||
localTables = await ReadTablesAsJsonAsync(_sqlConnection!, tableNames);
|
||||
}
|
||||
|
||||
// Get all table names from the current base database.
|
||||
var tables = await DbMergeUtility.GetTableNames(_sqlConnection!);
|
||||
|
||||
// Disable foreign key checks on the base connection.
|
||||
// Verify foreign key integrity after merge.
|
||||
await using (var command = _sqlConnection!.CreateCommand())
|
||||
{
|
||||
command.CommandText = "PRAGMA foreign_keys = OFF;";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// Merge every remote database into the current database.
|
||||
foreach (var connection in sqlConnections)
|
||||
{
|
||||
foreach (var table in tables)
|
||||
{
|
||||
_logger.LogInformation("Merging table {Table}.", table);
|
||||
await DbMergeUtility.MergeTable(_sqlConnection, connection, table, _logger);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enable foreign key checks and verify integrity.
|
||||
await using (var command = _sqlConnection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "PRAGMA foreign_keys = ON;";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
|
||||
// Verify foreign key integrity.
|
||||
command.CommandText = "PRAGMA foreign_key_check;";
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
if (await reader.ReadAsync())
|
||||
@@ -186,13 +201,6 @@ public sealed class DbService : IDisposable
|
||||
// Update the db context with the new merged database.
|
||||
_dbContext = new AliasClientDbContext(_sqlConnection, log => _logger.LogDebug("{Message}", log));
|
||||
|
||||
// Clean up other connections.
|
||||
foreach (var connection in sqlConnections)
|
||||
{
|
||||
await connection.CloseAsync();
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
|
||||
// Update the current vault revision number to the highest revision number in the merged database(s).
|
||||
// This is important so the server knows that the local client has successfully merged the databases
|
||||
// and should overwrite the existing database on the server with the new merged database.
|
||||
@@ -671,6 +679,25 @@ public sealed class DbService : IDisposable
|
||||
File.Delete(tempFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a JsonElement to its appropriate .NET value for SQLite parameters.
|
||||
/// </summary>
|
||||
/// <param name="element">The JsonElement to convert.</param>
|
||||
/// <returns>The converted value.</returns>
|
||||
private static object? ConvertJsonElementToValue(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var longVal) ? longVal : element.GetDouble(),
|
||||
JsonValueKind.True => 1L, // SQLite stores booleans as integers
|
||||
JsonValueKind.False => 0L,
|
||||
JsonValueKind.Null => null,
|
||||
JsonValueKind.Undefined => null,
|
||||
_ => element.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if there are any pending migrations.
|
||||
/// </summary>
|
||||
@@ -894,6 +921,136 @@ public sealed class DbService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads all specified tables from a SQLite connection as JSON data for the Rust merge.
|
||||
/// </summary>
|
||||
/// <param name="connection">The SQLite connection to read from.</param>
|
||||
/// <param name="tableNames">The names of tables to read.</param>
|
||||
/// <returns>List of TableData objects containing the table records.</returns>
|
||||
private async Task<List<TableData>> ReadTablesAsJsonAsync(SqliteConnection connection, string[] tableNames)
|
||||
{
|
||||
var tables = new List<TableData>();
|
||||
|
||||
foreach (var tableName in tableNames)
|
||||
{
|
||||
var tableData = new TableData { Name = tableName };
|
||||
|
||||
// Check if table exists in the database.
|
||||
await using var checkCommand = connection.CreateCommand();
|
||||
checkCommand.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name=@tableName";
|
||||
checkCommand.Parameters.AddWithValue("@tableName", tableName);
|
||||
var exists = await checkCommand.ExecuteScalarAsync();
|
||||
|
||||
if (exists == null)
|
||||
{
|
||||
// Table doesn't exist, add empty table data.
|
||||
tables.Add(tableData);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get column names for the table.
|
||||
await using var columnsCommand = connection.CreateCommand();
|
||||
columnsCommand.CommandText = $"PRAGMA table_info({tableName})";
|
||||
var columns = new List<string>();
|
||||
await using (var columnsReader = await columnsCommand.ExecuteReaderAsync())
|
||||
{
|
||||
while (await columnsReader.ReadAsync())
|
||||
{
|
||||
columns.Add(columnsReader.GetString(1));
|
||||
}
|
||||
}
|
||||
|
||||
// Read all records from the table.
|
||||
await using var selectCommand = connection.CreateCommand();
|
||||
selectCommand.CommandText = $"SELECT * FROM {tableName}";
|
||||
await using var reader = await selectCommand.ExecuteReaderAsync();
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
var record = new Dictionary<string, object?>();
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
var value = reader.GetValue(i);
|
||||
|
||||
// Convert DBNull to null for proper JSON serialization.
|
||||
record[columns[i]] = value == DBNull.Value ? null : value;
|
||||
}
|
||||
|
||||
tableData.Records.Add(record);
|
||||
}
|
||||
|
||||
tables.Add(tableData);
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the SQL statements returned by the Rust merge operation.
|
||||
/// </summary>
|
||||
/// <param name="statements">The SQL statements to execute.</param>
|
||||
/// <returns>Task.</returns>
|
||||
private async Task ExecuteMergeSqlStatementsAsync(List<SqlStatement> statements)
|
||||
{
|
||||
if (statements.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No SQL statements to execute from merge.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Executing {Count} SQL statements from merge.", statements.Count);
|
||||
|
||||
// Disable foreign key checks during merge execution.
|
||||
await using (var pragmaCommand = _sqlConnection!.CreateCommand())
|
||||
{
|
||||
pragmaCommand.CommandText = "PRAGMA foreign_keys = OFF;";
|
||||
await pragmaCommand.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var statement in statements)
|
||||
{
|
||||
await using var command = _sqlConnection!.CreateCommand();
|
||||
command.CommandText = statement.Sql;
|
||||
|
||||
// Add parameters in order (SQLite uses positional parameters with ?).
|
||||
for (var i = 0; i < statement.Params.Count; i++)
|
||||
{
|
||||
var value = statement.Params[i];
|
||||
|
||||
// Handle JsonElement values from deserialization.
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
value = ConvertJsonElementToValue(jsonElement);
|
||||
}
|
||||
|
||||
command.Parameters.AddWithValue($"@p{i}", value ?? DBNull.Value);
|
||||
}
|
||||
|
||||
// Replace ? placeholders with named parameters.
|
||||
var paramIndex = 0;
|
||||
var sql = statement.Sql;
|
||||
while (sql.Contains('?'))
|
||||
{
|
||||
var pos = sql.IndexOf('?');
|
||||
sql = sql[..pos] + $"@p{paramIndex}" + sql[(pos + 1)..];
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
command.CommandText = sql;
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Re-enable foreign key checks.
|
||||
await using var pragmaCommand = _sqlConnection!.CreateCommand();
|
||||
pragmaCommand.CommandText = "PRAGMA foreign_keys = ON;";
|
||||
await pragmaCommand.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the service.
|
||||
/// </summary>
|
||||
|
||||
28
apps/server/AliasVault.Client/Services/Native/MergeInput.cs
Normal file
28
apps/server/AliasVault.Client/Services/Native/MergeInput.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="MergeInput.cs" company="aliasvault">
|
||||
// Copyright (c) aliasvault. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Client.Services.Native;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Input for the vault merge operation.
|
||||
/// </summary>
|
||||
public class MergeInput
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets tables from the local database.
|
||||
/// </summary>
|
||||
[JsonPropertyName("local_tables")]
|
||||
public List<TableData> LocalTables { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets tables from the server database.
|
||||
/// </summary>
|
||||
[JsonPropertyName("server_tables")]
|
||||
public List<TableData> ServerTables { get; set; } = [];
|
||||
}
|
||||
40
apps/server/AliasVault.Client/Services/Native/MergeOutput.cs
Normal file
40
apps/server/AliasVault.Client/Services/Native/MergeOutput.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="MergeOutput.cs" company="aliasvault">
|
||||
// Copyright (c) aliasvault. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Client.Services.Native;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Output of the merge operation.
|
||||
/// </summary>
|
||||
public class MergeOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the merge was successful.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets SQL statements to execute on the local database (in order).
|
||||
/// </summary>
|
||||
[JsonPropertyName("statements")]
|
||||
public List<SqlStatement> Statements { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets overall statistics.
|
||||
/// </summary>
|
||||
[JsonPropertyName("stats")]
|
||||
public MergeStats Stats { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets error message if success is false.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
52
apps/server/AliasVault.Client/Services/Native/MergeStats.cs
Normal file
52
apps/server/AliasVault.Client/Services/Native/MergeStats.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="MergeStats.cs" company="aliasvault">
|
||||
// Copyright (c) aliasvault. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Client.Services.Native;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about what was merged.
|
||||
/// </summary>
|
||||
public class MergeStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the number of tables processed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tables_processed")]
|
||||
public int TablesProcessed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets records where local version was kept.
|
||||
/// </summary>
|
||||
[JsonPropertyName("records_from_local")]
|
||||
public int RecordsFromLocal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets records where server version was used (updates).
|
||||
/// </summary>
|
||||
[JsonPropertyName("records_from_server")]
|
||||
public int RecordsFromServer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets records that only existed locally (created offline).
|
||||
/// </summary>
|
||||
[JsonPropertyName("records_created_locally")]
|
||||
public int RecordsCreatedLocally { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets number of conflicts resolved (both had the record).
|
||||
/// </summary>
|
||||
[JsonPropertyName("conflicts")]
|
||||
public int Conflicts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets records inserted from server (server-only records).
|
||||
/// </summary>
|
||||
[JsonPropertyName("records_inserted")]
|
||||
public int RecordsInserted { get; set; }
|
||||
}
|
||||
166
apps/server/AliasVault.Client/Services/Native/RustCore.cs
Normal file
166
apps/server/AliasVault.Client/Services/Native/RustCore.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="RustCore.cs" company="aliasvault">
|
||||
// Copyright (c) aliasvault. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Client.Services.Native;
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
/// <summary>
|
||||
/// JavaScript interop wrapper for the Rust WASM core library.
|
||||
/// Provides vault merge and credential matching functionality via WASM.
|
||||
/// </summary>
|
||||
public class RustCore : IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
private readonly IJSRuntime jsRuntime;
|
||||
private bool? isAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RustCore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsRuntime">The JS runtime for interop.</param>
|
||||
public RustCore(IJSRuntime jsRuntime)
|
||||
{
|
||||
this.jsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if the Rust WASM module is available.
|
||||
/// </summary>
|
||||
/// <returns>True if the WASM module is loaded and available.</returns>
|
||||
public async Task<bool> IsAvailableAsync()
|
||||
{
|
||||
if (isAvailable.HasValue)
|
||||
{
|
||||
return isAvailable.Value;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
isAvailable = await jsRuntime.InvokeAsync<bool>("rustCoreIsAvailable");
|
||||
return isAvailable.Value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
isAvailable = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge two vaults using Last-Write-Wins (LWW) strategy.
|
||||
/// </summary>
|
||||
/// <param name="input">The merge input containing local and server tables.</param>
|
||||
/// <returns>The merge output with SQL statements to execute.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if merge fails or WASM module is unavailable.</exception>
|
||||
public async Task<MergeOutput> MergeVaultsAsync(MergeInput input)
|
||||
{
|
||||
if (!await IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException("Rust WASM module is not available.");
|
||||
}
|
||||
|
||||
var inputJson = JsonSerializer.Serialize(input, JsonOptions);
|
||||
var resultJson = await jsRuntime.InvokeAsync<string>("rustCoreMergeVaults", inputJson);
|
||||
|
||||
if (string.IsNullOrEmpty(resultJson))
|
||||
{
|
||||
throw new InvalidOperationException("Merge operation returned empty result.");
|
||||
}
|
||||
|
||||
var result = JsonSerializer.Deserialize<MergeOutput>(resultJson, JsonOptions);
|
||||
if (result == null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to deserialize merge result.");
|
||||
}
|
||||
|
||||
if (!result.Success && !string.IsNullOrEmpty(result.Error))
|
||||
{
|
||||
throw new InvalidOperationException($"Merge failed: {result.Error}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the list of table names that need to be synced.
|
||||
/// </summary>
|
||||
/// <returns>Array of table names.</returns>
|
||||
public async Task<string[]> GetSyncableTableNamesAsync()
|
||||
{
|
||||
if (!await IsAvailableAsync())
|
||||
{
|
||||
return SyncableTables.Names;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await jsRuntime.InvokeAsync<string[]>("rustCoreGetSyncableTableNames");
|
||||
return result ?? SyncableTables.Names;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return SyncableTables.Names;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract domain from URL.
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to extract domain from.</param>
|
||||
/// <returns>The extracted domain.</returns>
|
||||
public async Task<string> ExtractDomainAsync(string url)
|
||||
{
|
||||
if (!await IsAvailableAsync())
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await jsRuntime.InvokeAsync<string>("rustCoreExtractDomain", url);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract root domain from a domain string.
|
||||
/// </summary>
|
||||
/// <param name="domain">The domain to extract root from.</param>
|
||||
/// <returns>The root domain.</returns>
|
||||
public async Task<string> ExtractRootDomainAsync(string domain)
|
||||
{
|
||||
if (!await IsAvailableAsync())
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await jsRuntime.InvokeAsync<string>("rustCoreExtractRootDomain", domain);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="SqlStatement.cs" company="aliasvault">
|
||||
// Copyright (c) aliasvault. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Client.Services.Native;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// A SQL statement with its parameter values, generated by the merge operation.
|
||||
/// </summary>
|
||||
public class SqlStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the SQL query with ? placeholders.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sql")]
|
||||
public string Sql { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets parameter values in order.
|
||||
/// </summary>
|
||||
[JsonPropertyName("params")]
|
||||
public List<object?> Params { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="SyncableTables.cs" company="aliasvault">
|
||||
// Copyright (c) aliasvault. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Client.Services.Native;
|
||||
|
||||
/// <summary>
|
||||
/// List of syncable table names that need to be read for merge operations.
|
||||
/// </summary>
|
||||
public static class SyncableTables
|
||||
{
|
||||
/// <summary>
|
||||
/// Table names that need LWW merge.
|
||||
/// </summary>
|
||||
public static readonly string[] Names =
|
||||
[
|
||||
"Items",
|
||||
"FieldValues",
|
||||
"Folders",
|
||||
"Tags",
|
||||
"ItemTags",
|
||||
"Attachments",
|
||||
"TotpCodes",
|
||||
"Passkeys",
|
||||
"FieldDefinitions",
|
||||
"FieldHistories",
|
||||
"Logos",
|
||||
];
|
||||
}
|
||||
28
apps/server/AliasVault.Client/Services/Native/TableData.cs
Normal file
28
apps/server/AliasVault.Client/Services/Native/TableData.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="TableData.cs" company="aliasvault">
|
||||
// Copyright (c) aliasvault. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Client.Services.Native;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Data for a single database table, used in vault merge operations.
|
||||
/// </summary>
|
||||
public class TableData
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the table name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets all records in this table as dictionaries.
|
||||
/// </summary>
|
||||
[JsonPropertyName("records")]
|
||||
public List<Dictionary<string, object?>> Records { get; set; } = [];
|
||||
}
|
||||
175
apps/server/AliasVault.Client/wwwroot/js/rustCoreInterop.js
Normal file
175
apps/server/AliasVault.Client/wwwroot/js/rustCoreInterop.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// Rust Core WASM Interop for Blazor
|
||||
// This module provides JavaScript functions that Blazor can call via JSInterop
|
||||
// to access the Rust WASM merge and credential matching functionality.
|
||||
|
||||
let wasmModule = null;
|
||||
let isInitialized = false;
|
||||
let initPromise = null;
|
||||
|
||||
/**
|
||||
* Initialize the Rust WASM module.
|
||||
* @returns {Promise<boolean>} True if initialization succeeded.
|
||||
*/
|
||||
async function initRustCore() {
|
||||
if (isInitialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (initPromise) {
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
// Fetch the WASM binary first
|
||||
const wasmResponse = await fetch('/wasm/aliasvault_core_bg.wasm');
|
||||
if (!wasmResponse.ok) {
|
||||
throw new Error(`Failed to fetch WASM: ${wasmResponse.status}`);
|
||||
}
|
||||
const wasmBytes = await wasmResponse.arrayBuffer();
|
||||
|
||||
// Dynamically import the ES module
|
||||
const module = await import('/wasm/aliasvault_core.js');
|
||||
|
||||
// Initialize the WASM module with the binary bytes
|
||||
await module.default(wasmBytes);
|
||||
|
||||
// Call init to set up panic hook
|
||||
if (typeof module.init === 'function') {
|
||||
module.init();
|
||||
}
|
||||
|
||||
wasmModule = module;
|
||||
isInitialized = true;
|
||||
console.log('[RustCore] WASM module initialized successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[RustCore] Failed to initialize WASM module:', error);
|
||||
isInitialized = false;
|
||||
initPromise = null; // Allow retry on failure
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Rust WASM module is available.
|
||||
* @returns {Promise<boolean>} True if available.
|
||||
*/
|
||||
window.rustCoreIsAvailable = async function() {
|
||||
return await initRustCore();
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge two vaults using LWW strategy.
|
||||
* @param {string} inputJson - JSON string containing MergeInput.
|
||||
* @returns {Promise<string>} JSON string containing MergeOutput.
|
||||
*/
|
||||
window.rustCoreMergeVaults = async function(inputJson) {
|
||||
if (!await initRustCore()) {
|
||||
return JSON.stringify({
|
||||
success: false,
|
||||
error: 'Rust WASM module not available',
|
||||
statements: [],
|
||||
stats: {}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = wasmModule.mergeVaultsJson(inputJson);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[RustCore] Merge failed:', error);
|
||||
return JSON.stringify({
|
||||
success: false,
|
||||
error: error.toString(),
|
||||
statements: [],
|
||||
stats: {}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter credentials for autofill.
|
||||
* @param {string} inputJson - JSON string containing CredentialMatcherInput.
|
||||
* @returns {Promise<string>} JSON string containing CredentialMatcherOutput.
|
||||
*/
|
||||
window.rustCoreFilterCredentials = async function(inputJson) {
|
||||
if (!await initRustCore()) {
|
||||
return JSON.stringify({
|
||||
matches: [],
|
||||
error: 'Rust WASM module not available'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = wasmModule.filterCredentialsJson(inputJson);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[RustCore] Filter credentials failed:', error);
|
||||
return JSON.stringify({
|
||||
matches: [],
|
||||
error: error.toString()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the list of syncable table names.
|
||||
* @returns {Promise<string[]>} Array of table names.
|
||||
*/
|
||||
window.rustCoreGetSyncableTableNames = async function() {
|
||||
if (!await initRustCore()) {
|
||||
// Return default list if WASM not available
|
||||
return [
|
||||
'Items', 'FieldValues', 'Folders', 'Tags', 'ItemTags',
|
||||
'Attachments', 'TotpCodes', 'Passkeys', 'FieldDefinitions',
|
||||
'FieldHistories', 'Logos'
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
return wasmModule.getSyncableTableNames();
|
||||
} catch (error) {
|
||||
console.error('[RustCore] Get syncable table names failed:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract domain from URL.
|
||||
* @param {string} url - The URL to extract domain from.
|
||||
* @returns {Promise<string>} The extracted domain.
|
||||
*/
|
||||
window.rustCoreExtractDomain = async function(url) {
|
||||
if (!await initRustCore()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return wasmModule.extractDomain(url);
|
||||
} catch (error) {
|
||||
console.error('[RustCore] Extract domain failed:', error);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract root domain from a domain string.
|
||||
* @param {string} domain - The domain to extract root from.
|
||||
* @returns {Promise<string>} The root domain.
|
||||
*/
|
||||
window.rustCoreExtractRootDomain = async function(domain) {
|
||||
if (!await initRustCore()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return wasmModule.extractRootDomain(domain);
|
||||
} catch (error) {
|
||||
console.error('[RustCore] Extract root domain failed:', error);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
743
apps/server/AliasVault.Client/wwwroot/wasm/aliasvault_core.js
Normal file
743
apps/server/AliasVault.Client/wwwroot/wasm/aliasvault_core.js
Normal file
@@ -0,0 +1,743 @@
|
||||
let wasm;
|
||||
|
||||
function addHeapObject(obj) {
|
||||
if (heap_next === heap.length) heap.push(heap.length + 1);
|
||||
const idx = heap_next;
|
||||
heap_next = heap[idx];
|
||||
|
||||
heap[idx] = obj;
|
||||
return idx;
|
||||
}
|
||||
|
||||
function debugString(val) {
|
||||
// primitive types
|
||||
const type = typeof val;
|
||||
if (type == 'number' || type == 'boolean' || val == null) {
|
||||
return `${val}`;
|
||||
}
|
||||
if (type == 'string') {
|
||||
return `"${val}"`;
|
||||
}
|
||||
if (type == 'symbol') {
|
||||
const description = val.description;
|
||||
if (description == null) {
|
||||
return 'Symbol';
|
||||
} else {
|
||||
return `Symbol(${description})`;
|
||||
}
|
||||
}
|
||||
if (type == 'function') {
|
||||
const name = val.name;
|
||||
if (typeof name == 'string' && name.length > 0) {
|
||||
return `Function(${name})`;
|
||||
} else {
|
||||
return 'Function';
|
||||
}
|
||||
}
|
||||
// objects
|
||||
if (Array.isArray(val)) {
|
||||
const length = val.length;
|
||||
let debug = '[';
|
||||
if (length > 0) {
|
||||
debug += debugString(val[0]);
|
||||
}
|
||||
for(let i = 1; i < length; i++) {
|
||||
debug += ', ' + debugString(val[i]);
|
||||
}
|
||||
debug += ']';
|
||||
return debug;
|
||||
}
|
||||
// Test for built-in
|
||||
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
|
||||
let className;
|
||||
if (builtInMatches && builtInMatches.length > 1) {
|
||||
className = builtInMatches[1];
|
||||
} else {
|
||||
// Failed to match the standard '[object ClassName]'
|
||||
return toString.call(val);
|
||||
}
|
||||
if (className == 'Object') {
|
||||
// we're a user defined class or Object
|
||||
// JSON.stringify avoids problems with cycles, and is generally much
|
||||
// easier than looping through ownProperties of `val`.
|
||||
try {
|
||||
return 'Object(' + JSON.stringify(val) + ')';
|
||||
} catch (_) {
|
||||
return 'Object';
|
||||
}
|
||||
}
|
||||
// errors
|
||||
if (val instanceof Error) {
|
||||
return `${val.name}: ${val.message}\n${val.stack}`;
|
||||
}
|
||||
// TODO we could test for more things here, like `Set`s and `Map`s.
|
||||
return className;
|
||||
}
|
||||
|
||||
function dropObject(idx) {
|
||||
if (idx < 132) return;
|
||||
heap[idx] = heap_next;
|
||||
heap_next = idx;
|
||||
}
|
||||
|
||||
function getArrayJsValueFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
const mem = getDataViewMemory0();
|
||||
const result = [];
|
||||
for (let i = ptr; i < ptr + 4 * len; i += 4) {
|
||||
result.push(takeObject(mem.getUint32(i, true)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getArrayU8FromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
||||
}
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return decodeText(ptr, len);
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function getObject(idx) { return heap[idx]; }
|
||||
|
||||
function handleError(f, args) {
|
||||
try {
|
||||
return f.apply(this, args);
|
||||
} catch (e) {
|
||||
wasm.__wbindgen_export3(addHeapObject(e));
|
||||
}
|
||||
}
|
||||
|
||||
let heap = new Array(128).fill(undefined);
|
||||
heap.push(undefined, null, true, false);
|
||||
|
||||
let heap_next = heap.length;
|
||||
|
||||
function isLikeNone(x) {
|
||||
return x === undefined || x === null;
|
||||
}
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = cachedTextEncoder.encodeInto(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function takeObject(idx) {
|
||||
const ret = getObject(idx);
|
||||
dropObject(idx);
|
||||
return ret;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
function decodeText(ptr, len) {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
const cachedTextEncoder = new TextEncoder();
|
||||
|
||||
if (!('encodeInto' in cachedTextEncoder)) {
|
||||
cachedTextEncoder.encodeInto = function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
/**
|
||||
* Extract domain from URL.
|
||||
*
|
||||
* Handles both full URLs and partial domains, returning normalized domain
|
||||
* without protocol, www prefix, path, query, or fragment.
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
export function extractDomain(url) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passStringToWasm0(url, wasm.__wbindgen_export, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.extractDomain(retptr, ptr0, len0);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
deferred2_0 = r0;
|
||||
deferred2_1 = r1;
|
||||
return getStringFromWasm0(r0, r1);
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
wasm.__wbindgen_export4(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract root domain from a domain string.
|
||||
*
|
||||
* E.g., "sub.example.com" -> "example.com"
|
||||
* E.g., "sub.example.co.uk" -> "example.co.uk"
|
||||
* @param {string} domain
|
||||
* @returns {string}
|
||||
*/
|
||||
export function extractRootDomain(domain) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passStringToWasm0(domain, wasm.__wbindgen_export, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.extractRootDomain(retptr, ptr0, len0);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
deferred2_0 = r0;
|
||||
deferred2_1 = r1;
|
||||
return getStringFromWasm0(r0, r1);
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
wasm.__wbindgen_export4(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter credentials for autofill.
|
||||
*
|
||||
* Takes a JsValue (CredentialMatcherInput) and returns a JsValue (CredentialMatcherOutput).
|
||||
* @param {any} input
|
||||
* @returns {any}
|
||||
*/
|
||||
export function filterCredentials(input) {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
wasm.filterCredentials(retptr, addHeapObject(input));
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
||||
if (r2) {
|
||||
throw takeObject(r1);
|
||||
}
|
||||
return takeObject(r0);
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter credentials using JSON strings (alternative API).
|
||||
*
|
||||
* Takes a JSON string and returns a JSON string.
|
||||
* @param {string} input_json
|
||||
* @returns {string}
|
||||
*/
|
||||
export function filterCredentialsJson(input_json) {
|
||||
let deferred3_0;
|
||||
let deferred3_1;
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passStringToWasm0(input_json, wasm.__wbindgen_export, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.filterCredentialsJson(retptr, ptr0, len0);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
||||
var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true);
|
||||
var ptr2 = r0;
|
||||
var len2 = r1;
|
||||
if (r3) {
|
||||
ptr2 = 0; len2 = 0;
|
||||
throw takeObject(r2);
|
||||
}
|
||||
deferred3_0 = ptr2;
|
||||
deferred3_1 = len2;
|
||||
return getStringFromWasm0(ptr2, len2);
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
wasm.__wbindgen_export4(deferred3_0, deferred3_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of table names that need to be synced.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getSyncableTableNames() {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
wasm.getSyncableTableNames(retptr);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var v1 = getArrayJsValueFromWasm0(r0, r1).slice();
|
||||
wasm.__wbindgen_export4(r0, r1 * 4, 4);
|
||||
return v1;
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize panic hook for better error messages.
|
||||
*/
|
||||
export function init() {
|
||||
wasm.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge vaults using LWW strategy.
|
||||
*
|
||||
* Takes a JsValue (MergeInput) and returns a JsValue (MergeOutput).
|
||||
* @param {any} input
|
||||
* @returns {any}
|
||||
*/
|
||||
export function mergeVaults(input) {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
wasm.mergeVaults(retptr, addHeapObject(input));
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
||||
if (r2) {
|
||||
throw takeObject(r1);
|
||||
}
|
||||
return takeObject(r0);
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge vaults using JSON strings (alternative API).
|
||||
*
|
||||
* Takes a JSON string and returns a JSON string.
|
||||
* @param {string} input_json
|
||||
* @returns {string}
|
||||
*/
|
||||
export function mergeVaultsJson(input_json) {
|
||||
let deferred3_0;
|
||||
let deferred3_1;
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passStringToWasm0(input_json, wasm.__wbindgen_export, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.mergeVaultsJson(retptr, ptr0, len0);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
||||
var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true);
|
||||
var ptr2 = r0;
|
||||
var len2 = r1;
|
||||
if (r3) {
|
||||
ptr2 = 0; len2 = 0;
|
||||
throw takeObject(r2);
|
||||
}
|
||||
deferred3_0 = ptr2;
|
||||
deferred3_1 = len2;
|
||||
return getStringFromWasm0(ptr2, len2);
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
wasm.__wbindgen_export4(deferred3_0, deferred3_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const EXPECTED_RESPONSE_TYPES = new Set(['basic', 'cors', 'default']);
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
} catch (e) {
|
||||
const validResponse = module.ok && EXPECTED_RESPONSE_TYPES.has(module.type);
|
||||
|
||||
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function __wbg_get_imports() {
|
||||
const imports = {};
|
||||
imports.wbg = {};
|
||||
imports.wbg.__wbg_Error_52673b7de5a0ca89 = function(arg0, arg1) {
|
||||
const ret = Error(getStringFromWasm0(arg0, arg1));
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbg_String_8f0eb39a4a4c2f66 = function(arg0, arg1) {
|
||||
const ret = String(getObject(arg1));
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_bigint_get_as_i64_6e32f5e6aff02e1d = function(arg0, arg1) {
|
||||
const v = getObject(arg1);
|
||||
const ret = typeof(v) === 'bigint' ? v : undefined;
|
||||
getDataViewMemory0().setBigInt64(arg0 + 8 * 1, isLikeNone(ret) ? BigInt(0) : ret, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true);
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_boolean_get_dea25b33882b895b = function(arg0) {
|
||||
const v = getObject(arg0);
|
||||
const ret = typeof(v) === 'boolean' ? v : undefined;
|
||||
return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0;
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_debug_string_adfb662ae34724b6 = function(arg0, arg1) {
|
||||
const ret = debugString(getObject(arg1));
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_in_0d3e1e8f0c669317 = function(arg0, arg1) {
|
||||
const ret = getObject(arg0) in getObject(arg1);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_is_bigint_0e1a2e3f55cfae27 = function(arg0) {
|
||||
const ret = typeof(getObject(arg0)) === 'bigint';
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_is_function_8d400b8b1af978cd = function(arg0) {
|
||||
const ret = typeof(getObject(arg0)) === 'function';
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_is_object_ce774f3490692386 = function(arg0) {
|
||||
const val = getObject(arg0);
|
||||
const ret = typeof(val) === 'object' && val !== null;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_is_string_704ef9c8fc131030 = function(arg0) {
|
||||
const ret = typeof(getObject(arg0)) === 'string';
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_is_undefined_f6b95eab589e0269 = function(arg0) {
|
||||
const ret = getObject(arg0) === undefined;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_jsval_eq_b6101cc9cef1fe36 = function(arg0, arg1) {
|
||||
const ret = getObject(arg0) === getObject(arg1);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_jsval_loose_eq_766057600fdd1b0d = function(arg0, arg1) {
|
||||
const ret = getObject(arg0) == getObject(arg1);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_number_get_9619185a74197f95 = function(arg0, arg1) {
|
||||
const obj = getObject(arg1);
|
||||
const ret = typeof(obj) === 'number' ? obj : undefined;
|
||||
getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true);
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_string_get_a2a31e16edf96e42 = function(arg0, arg1) {
|
||||
const obj = getObject(arg1);
|
||||
const ret = typeof(obj) === 'string' ? obj : undefined;
|
||||
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2);
|
||||
var len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
};
|
||||
imports.wbg.__wbg___wbindgen_throw_dd24417ed36fc46e = function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
};
|
||||
imports.wbg.__wbg_call_abb4ff46ce38be40 = function() { return handleError(function (arg0, arg1) {
|
||||
const ret = getObject(arg0).call(getObject(arg1));
|
||||
return addHeapObject(ret);
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_done_62ea16af4ce34b24 = function(arg0) {
|
||||
const ret = getObject(arg0).done;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_entries_83c79938054e065f = function(arg0) {
|
||||
const ret = Object.entries(getObject(arg0));
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbg_error_7534b8e9a36f1ab4 = function(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.error(getStringFromWasm0(arg0, arg1));
|
||||
} finally {
|
||||
wasm.__wbindgen_export4(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
};
|
||||
imports.wbg.__wbg_get_6b7bd52aca3f9671 = function(arg0, arg1) {
|
||||
const ret = getObject(arg0)[arg1 >>> 0];
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbg_get_af9dab7e9603ea93 = function() { return handleError(function (arg0, arg1) {
|
||||
const ret = Reflect.get(getObject(arg0), getObject(arg1));
|
||||
return addHeapObject(ret);
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_get_with_ref_key_1dc361bd10053bfe = function(arg0, arg1) {
|
||||
const ret = getObject(arg0)[getObject(arg1)];
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbg_instanceof_ArrayBuffer_f3320d2419cd0355 = function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = getObject(arg0) instanceof ArrayBuffer;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_instanceof_Map_084be8da74364158 = function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = getObject(arg0) instanceof Map;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_instanceof_Uint8Array_da54ccc9d3e09434 = function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = getObject(arg0) instanceof Uint8Array;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_isArray_51fd9e6422c0a395 = function(arg0) {
|
||||
const ret = Array.isArray(getObject(arg0));
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_isSafeInteger_ae7d3f054d55fa16 = function(arg0) {
|
||||
const ret = Number.isSafeInteger(getObject(arg0));
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_iterator_27b7c8b35ab3e86b = function() {
|
||||
const ret = Symbol.iterator;
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbg_length_22ac23eaec9d8053 = function(arg0) {
|
||||
const ret = getObject(arg0).length;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_length_d45040a40c570362 = function(arg0) {
|
||||
const ret = getObject(arg0).length;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_new_1ba21ce319a06297 = function() {
|
||||
const ret = new Object();
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbg_new_25f239778d6112b9 = function() {
|
||||
const ret = new Array();
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbg_new_6421f6084cc5bc5a = function(arg0) {
|
||||
const ret = new Uint8Array(getObject(arg0));
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbg_new_8a6f238a6ece86ea = function() {
|
||||
const ret = new Error();
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbg_new_b546ae120718850e = function() {
|
||||
const ret = new Map();
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbg_next_138a17bbf04e926c = function(arg0) {
|
||||
const ret = getObject(arg0).next;
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbg_next_3cfe5c0fe2a4cc53 = function() { return handleError(function (arg0) {
|
||||
const ret = getObject(arg0).next();
|
||||
return addHeapObject(ret);
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_prototypesetcall_dfe9b766cdc1f1fd = function(arg0, arg1, arg2) {
|
||||
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), getObject(arg2));
|
||||
};
|
||||
imports.wbg.__wbg_set_3f1d0b984ed272ed = function(arg0, arg1, arg2) {
|
||||
getObject(arg0)[takeObject(arg1)] = takeObject(arg2);
|
||||
};
|
||||
imports.wbg.__wbg_set_7df433eea03a5c14 = function(arg0, arg1, arg2) {
|
||||
getObject(arg0)[arg1 >>> 0] = takeObject(arg2);
|
||||
};
|
||||
imports.wbg.__wbg_set_efaaf145b9377369 = function(arg0, arg1, arg2) {
|
||||
const ret = getObject(arg0).set(getObject(arg1), getObject(arg2));
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbg_stack_0ed75d68575b0f3c = function(arg0, arg1) {
|
||||
const ret = getObject(arg1).stack;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
};
|
||||
imports.wbg.__wbg_value_57b7b035e117f7ee = function(arg0) {
|
||||
const ret = getObject(arg0).value;
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbindgen_cast_4625c577ab2ec9ee = function(arg0) {
|
||||
// Cast intrinsic for `U64 -> Externref`.
|
||||
const ret = BigInt.asUintN(64, arg0);
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbindgen_cast_9ae0607507abb057 = function(arg0) {
|
||||
// Cast intrinsic for `I64 -> Externref`.
|
||||
const ret = arg0;
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbindgen_cast_d6cd19b81560fd6e = function(arg0) {
|
||||
// Cast intrinsic for `F64 -> Externref`.
|
||||
const ret = arg0;
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbindgen_object_clone_ref = function(arg0) {
|
||||
const ret = getObject(arg0);
|
||||
return addHeapObject(ret);
|
||||
};
|
||||
imports.wbg.__wbindgen_object_drop_ref = function(arg0) {
|
||||
takeObject(arg0);
|
||||
};
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasm = instance.exports;
|
||||
__wbg_init.__wbindgen_wasm_module = module;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
|
||||
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (typeof module !== 'undefined') {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (typeof module_or_path !== 'undefined') {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module_or_path === 'undefined') {
|
||||
module_or_path = new URL('aliasvault_core_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync };
|
||||
export default __wbg_init;
|
||||
Binary file not shown.
@@ -0,0 +1,289 @@
|
||||
// <auto-generated />
|
||||
// This file is auto-generated from core/models/src/vault/SystemFieldRegistry.ts
|
||||
// Do not edit this file directly. Run 'npm run generate:models' to regenerate.
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace AliasClientDb.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Field categories for grouping in UI.
|
||||
/// </summary>
|
||||
public enum FieldCategory
|
||||
{
|
||||
/// <summary>Primary fields.</summary>
|
||||
Primary,
|
||||
/// <summary>Login fields.</summary>
|
||||
Login,
|
||||
/// <summary>Alias fields.</summary>
|
||||
Alias,
|
||||
/// <summary>Card fields.</summary>
|
||||
Card,
|
||||
/// <summary>Custom fields.</summary>
|
||||
Custom,
|
||||
/// <summary>Notes fields.</summary>
|
||||
Notes,
|
||||
/// <summary>Metadata fields.</summary>
|
||||
Metadata
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-item-type configuration for a system field.
|
||||
/// </summary>
|
||||
/// <param name="ShowByDefault">Whether this field is shown by default in create mode (vs. hidden behind an "add" button)</param>
|
||||
public record ItemTypeFieldConfig(bool ShowByDefault);
|
||||
|
||||
/// <summary>
|
||||
/// System field definition with metadata.
|
||||
/// </summary>
|
||||
/// <param name="FieldKey">Unique system field key (e.g., 'login.username')</param>
|
||||
/// <param name="FieldType">Field type for rendering/validation</param>
|
||||
/// <param name="IsHidden">Whether field is hidden/masked by default</param>
|
||||
/// <param name="IsMultiValue">Whether field supports multiple values</param>
|
||||
/// <param name="ApplicableToTypes">ApplicableToTypes</param>
|
||||
/// <param name="EnableHistory">Whether to track field value history</param>
|
||||
/// <param name="Category">Category for grouping in UI. 'Primary' fields are shown in the name block.</param>
|
||||
/// <param name="DefaultDisplayOrder">Default display order within category (lower = first)</param>
|
||||
public record SystemFieldDefinition(
|
||||
string FieldKey,
|
||||
string FieldType,
|
||||
bool IsHidden,
|
||||
bool IsMultiValue,
|
||||
IReadOnlyDictionary<string, ItemTypeFieldConfig> ApplicableToTypes,
|
||||
bool EnableHistory,
|
||||
FieldCategory Category,
|
||||
int DefaultDisplayOrder);
|
||||
|
||||
/// <summary>
|
||||
/// Registry of all system-defined fields.
|
||||
/// These fields are immutable and their metadata is defined in code.
|
||||
/// </summary>
|
||||
public static class SystemFieldRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// All system field definitions indexed by field key.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, SystemFieldDefinition> Fields =
|
||||
new Dictionary<string, SystemFieldDefinition>
|
||||
{
|
||||
[FieldKey.LoginUsername] = new SystemFieldDefinition(
|
||||
FieldKey: "login.username",
|
||||
FieldType: "Text",
|
||||
IsHidden: false,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Login"] = new ItemTypeFieldConfig(true), ["Alias"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: true,
|
||||
Category: FieldCategory.Login,
|
||||
DefaultDisplayOrder: 10),
|
||||
[FieldKey.LoginPassword] = new SystemFieldDefinition(
|
||||
FieldKey: "login.password",
|
||||
FieldType: "Password",
|
||||
IsHidden: true,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Login"] = new ItemTypeFieldConfig(true), ["Alias"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: true,
|
||||
Category: FieldCategory.Login,
|
||||
DefaultDisplayOrder: 20),
|
||||
[FieldKey.LoginEmail] = new SystemFieldDefinition(
|
||||
FieldKey: "login.email",
|
||||
FieldType: "Email",
|
||||
IsHidden: false,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Login"] = new ItemTypeFieldConfig(false), ["Alias"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: true,
|
||||
Category: FieldCategory.Login,
|
||||
DefaultDisplayOrder: 15),
|
||||
[FieldKey.LoginUrl] = new SystemFieldDefinition(
|
||||
FieldKey: "login.url",
|
||||
FieldType: "URL",
|
||||
IsHidden: false,
|
||||
IsMultiValue: true,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Login"] = new ItemTypeFieldConfig(true), ["Alias"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: false,
|
||||
Category: FieldCategory.Primary,
|
||||
DefaultDisplayOrder: 5),
|
||||
[FieldKey.AliasFirstName] = new SystemFieldDefinition(
|
||||
FieldKey: "alias.first_name",
|
||||
FieldType: "Text",
|
||||
IsHidden: false,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Alias"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: false,
|
||||
Category: FieldCategory.Alias,
|
||||
DefaultDisplayOrder: 20),
|
||||
[FieldKey.AliasLastName] = new SystemFieldDefinition(
|
||||
FieldKey: "alias.last_name",
|
||||
FieldType: "Text",
|
||||
IsHidden: false,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Alias"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: false,
|
||||
Category: FieldCategory.Alias,
|
||||
DefaultDisplayOrder: 30),
|
||||
[FieldKey.AliasGender] = new SystemFieldDefinition(
|
||||
FieldKey: "alias.gender",
|
||||
FieldType: "Text",
|
||||
IsHidden: false,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Alias"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: false,
|
||||
Category: FieldCategory.Alias,
|
||||
DefaultDisplayOrder: 50),
|
||||
[FieldKey.AliasBirthdate] = new SystemFieldDefinition(
|
||||
FieldKey: "alias.birthdate",
|
||||
FieldType: "Date",
|
||||
IsHidden: false,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Alias"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: false,
|
||||
Category: FieldCategory.Alias,
|
||||
DefaultDisplayOrder: 60),
|
||||
[FieldKey.CardCardholderName] = new SystemFieldDefinition(
|
||||
FieldKey: "card.cardholder_name",
|
||||
FieldType: "Text",
|
||||
IsHidden: false,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["CreditCard"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: false,
|
||||
Category: FieldCategory.Card,
|
||||
DefaultDisplayOrder: 10),
|
||||
[FieldKey.CardNumber] = new SystemFieldDefinition(
|
||||
FieldKey: "card.number",
|
||||
FieldType: "Hidden",
|
||||
IsHidden: true,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["CreditCard"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: false,
|
||||
Category: FieldCategory.Card,
|
||||
DefaultDisplayOrder: 20),
|
||||
[FieldKey.CardExpiryMonth] = new SystemFieldDefinition(
|
||||
FieldKey: "card.expiry_month",
|
||||
FieldType: "Text",
|
||||
IsHidden: false,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["CreditCard"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: false,
|
||||
Category: FieldCategory.Card,
|
||||
DefaultDisplayOrder: 30),
|
||||
[FieldKey.CardExpiryYear] = new SystemFieldDefinition(
|
||||
FieldKey: "card.expiry_year",
|
||||
FieldType: "Text",
|
||||
IsHidden: false,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["CreditCard"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: false,
|
||||
Category: FieldCategory.Card,
|
||||
DefaultDisplayOrder: 40),
|
||||
[FieldKey.CardCvv] = new SystemFieldDefinition(
|
||||
FieldKey: "card.cvv",
|
||||
FieldType: "Hidden",
|
||||
IsHidden: true,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["CreditCard"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: false,
|
||||
Category: FieldCategory.Card,
|
||||
DefaultDisplayOrder: 50),
|
||||
[FieldKey.CardPin] = new SystemFieldDefinition(
|
||||
FieldKey: "card.pin",
|
||||
FieldType: "Hidden",
|
||||
IsHidden: true,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["CreditCard"] = new ItemTypeFieldConfig(false) },
|
||||
EnableHistory: false,
|
||||
Category: FieldCategory.Card,
|
||||
DefaultDisplayOrder: 60),
|
||||
[FieldKey.NotesContent] = new SystemFieldDefinition(
|
||||
FieldKey: "notes.content",
|
||||
FieldType: "TextArea",
|
||||
IsHidden: false,
|
||||
IsMultiValue: false,
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ["Login"] = new ItemTypeFieldConfig(false), ["Alias"] = new ItemTypeFieldConfig(false), ["CreditCard"] = new ItemTypeFieldConfig(false), ["Note"] = new ItemTypeFieldConfig(true) },
|
||||
EnableHistory: false,
|
||||
Category: FieldCategory.Notes,
|
||||
DefaultDisplayOrder: 100)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Get system field definition by key.
|
||||
/// </summary>
|
||||
/// <param name="fieldKey">The field key to look up.</param>
|
||||
/// <returns>The field definition, or null if not found.</returns>
|
||||
public static SystemFieldDefinition? GetSystemField(string fieldKey)
|
||||
{
|
||||
return Fields.TryGetValue(fieldKey, out var field) ? field : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a field key represents a system field.
|
||||
/// </summary>
|
||||
/// <param name="fieldKey">The field key to check.</param>
|
||||
/// <returns>True if the field key is a system field.</returns>
|
||||
public static bool IsSystemField(string fieldKey)
|
||||
{
|
||||
return Fields.ContainsKey(fieldKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a field applies to a specific item type.
|
||||
/// </summary>
|
||||
/// <param name="field">The field definition.</param>
|
||||
/// <param name="itemType">The item type to check.</param>
|
||||
/// <returns>True if the field applies to the item type.</returns>
|
||||
public static bool FieldAppliesToType(SystemFieldDefinition field, string itemType)
|
||||
{
|
||||
return field.ApplicableToTypes.ContainsKey(itemType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all system fields applicable to a specific item type.
|
||||
/// Results are sorted by DefaultDisplayOrder.
|
||||
/// </summary>
|
||||
/// <param name="itemType">The item type.</param>
|
||||
/// <returns>Fields applicable to the item type.</returns>
|
||||
public static IEnumerable<SystemFieldDefinition> GetFieldsForItemType(string itemType)
|
||||
{
|
||||
return Fields.Values
|
||||
.Where(f => f.ApplicableToTypes.ContainsKey(itemType))
|
||||
.OrderBy(f => f.DefaultDisplayOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get system fields that should be shown by default for a specific item type.
|
||||
/// Results are sorted by DefaultDisplayOrder.
|
||||
/// </summary>
|
||||
/// <param name="itemType">The item type.</param>
|
||||
/// <returns>Fields shown by default for the item type.</returns>
|
||||
public static IEnumerable<SystemFieldDefinition> GetDefaultFieldsForItemType(string itemType)
|
||||
{
|
||||
return Fields.Values
|
||||
.Where(f => f.ApplicableToTypes.TryGetValue(itemType, out var config) && config.ShowByDefault)
|
||||
.OrderBy(f => f.DefaultDisplayOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get system fields that are NOT shown by default for a specific item type.
|
||||
/// These are the fields that can be added via an "add field" button.
|
||||
/// Results are sorted by DefaultDisplayOrder.
|
||||
/// </summary>
|
||||
/// <param name="itemType">The item type.</param>
|
||||
/// <returns>Optional fields for the item type.</returns>
|
||||
public static IEnumerable<SystemFieldDefinition> GetOptionalFieldsForItemType(string itemType)
|
||||
{
|
||||
return Fields.Values
|
||||
.Where(f => f.ApplicableToTypes.TryGetValue(itemType, out var config) && !config.ShowByDefault)
|
||||
.OrderBy(f => f.DefaultDisplayOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a field key matches a known system field prefix.
|
||||
/// </summary>
|
||||
/// <param name="fieldKey">The field key to check.</param>
|
||||
/// <returns>True if the field key has a system field prefix.</returns>
|
||||
public static bool IsSystemFieldPrefix(string fieldKey)
|
||||
{
|
||||
return fieldKey.StartsWith("login.") ||
|
||||
fieldKey.StartsWith("alias.") ||
|
||||
fieldKey.StartsWith("card.") ||
|
||||
fieldKey.StartsWith("notes.");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates FieldKey constants for C#, Swift, and Kotlin from TypeScript source.
|
||||
* Generates FieldKey constants and SystemFieldRegistry for C#, Swift, and Kotlin from TypeScript source.
|
||||
* All type definitions are dynamically extracted from the TypeScript source files.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -9,7 +10,9 @@ const path = require('path');
|
||||
// Paths
|
||||
const REPO_ROOT = path.join(__dirname, '../../..');
|
||||
const TS_SOURCE = path.join(REPO_ROOT, 'core/models/src/vault/FieldKey.ts');
|
||||
const TS_REGISTRY_SOURCE = path.join(REPO_ROOT, 'core/models/src/vault/SystemFieldRegistry.ts');
|
||||
const CS_OUTPUT = path.join(REPO_ROOT, 'apps/server/Databases/AliasClientDb/Models/FieldKey.cs');
|
||||
const CS_REGISTRY_OUTPUT = path.join(REPO_ROOT, 'apps/server/Databases/AliasClientDb/Models/SystemFieldRegistry.cs');
|
||||
const SWIFT_OUTPUT = path.join(REPO_ROOT, 'apps/mobile-app/ios/VaultModels/FieldKey.swift');
|
||||
const KOTLIN_OUTPUT = path.join(REPO_ROOT, 'apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/models/FieldKey.kt');
|
||||
|
||||
@@ -50,7 +53,140 @@ function parseTypeScriptFieldKeys(tsContent) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate C# static class
|
||||
* Parse FieldCategories from TypeScript source
|
||||
* Returns an array of category names in order
|
||||
*/
|
||||
function parseFieldCategories(tsContent) {
|
||||
const categories = [];
|
||||
|
||||
// Find the FieldCategories constant
|
||||
const match = tsContent.match(/export const FieldCategories\s*=\s*\{([^}]+)\}/s);
|
||||
if (!match) {
|
||||
console.warn('Warning: Could not find FieldCategories in source');
|
||||
return categories;
|
||||
}
|
||||
|
||||
const body = match[1];
|
||||
// Match each category: Primary: 'Primary',
|
||||
const categoryRegex = /(\w+):\s*'(\w+)'/g;
|
||||
let categoryMatch;
|
||||
|
||||
while ((categoryMatch = categoryRegex.exec(body)) !== null) {
|
||||
categories.push(categoryMatch[1]);
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ItemTypeFieldConfig type from TypeScript source
|
||||
* Returns an array of property definitions
|
||||
*/
|
||||
function parseItemTypeFieldConfig(tsContent) {
|
||||
const properties = [];
|
||||
|
||||
// Find the ItemTypeFieldConfig type
|
||||
const match = tsContent.match(/export type ItemTypeFieldConfig\s*=\s*\{([^}]+)\}/s);
|
||||
if (!match) {
|
||||
console.warn('Warning: Could not find ItemTypeFieldConfig in source');
|
||||
return properties;
|
||||
}
|
||||
|
||||
const body = match[1];
|
||||
// Match properties with comments
|
||||
const lines = body.split('\n');
|
||||
let currentComment = '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Capture comments
|
||||
const commentMatch = trimmed.match(/\/\*\*\s*(.+)\s*\*\//);
|
||||
if (commentMatch) {
|
||||
currentComment = commentMatch[1].trim();
|
||||
}
|
||||
|
||||
// Match property: PropertyName: type;
|
||||
const propMatch = trimmed.match(/^(\w+):\s*(\w+);?$/);
|
||||
if (propMatch) {
|
||||
properties.push({
|
||||
name: propMatch[1],
|
||||
type: propMatch[2],
|
||||
comment: currentComment
|
||||
});
|
||||
currentComment = '';
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SystemFieldDefinition type from TypeScript source
|
||||
* Returns an array of property definitions
|
||||
*/
|
||||
function parseSystemFieldDefinition(tsContent) {
|
||||
const properties = [];
|
||||
|
||||
// Find the SystemFieldDefinition type
|
||||
const match = tsContent.match(/export type SystemFieldDefinition\s*=\s*\{([\s\S]*?)\n\};/);
|
||||
if (!match) {
|
||||
console.warn('Warning: Could not find SystemFieldDefinition in source');
|
||||
return properties;
|
||||
}
|
||||
|
||||
const body = match[1];
|
||||
const lines = body.split('\n');
|
||||
let currentComment = '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Capture single-line comments
|
||||
const singleCommentMatch = trimmed.match(/\/\*\*\s*(.+)\s*\*\//);
|
||||
if (singleCommentMatch) {
|
||||
currentComment = singleCommentMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match property definition
|
||||
const propMatch = trimmed.match(/^(\w+):\s*(.+);?$/);
|
||||
if (propMatch && !trimmed.startsWith('/*') && !trimmed.startsWith('*')) {
|
||||
let propType = propMatch[2].replace(/;$/, '').trim();
|
||||
|
||||
// Simplify complex types for C# mapping
|
||||
let csharpType = mapTypeToCSharp(propType);
|
||||
|
||||
properties.push({
|
||||
name: propMatch[1],
|
||||
tsType: propType,
|
||||
csharpType: csharpType,
|
||||
comment: currentComment
|
||||
});
|
||||
currentComment = '';
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map TypeScript type to C# type
|
||||
*/
|
||||
function mapTypeToCSharp(tsType) {
|
||||
if (tsType === 'string') return 'string';
|
||||
if (tsType === 'boolean') return 'bool';
|
||||
if (tsType === 'number') return 'int';
|
||||
if (tsType === 'FieldType') return 'string'; // FieldType is a string union
|
||||
if (tsType === 'FieldCategory') return 'FieldCategory';
|
||||
if (tsType.includes('Partial<Record<ItemType, ItemTypeFieldConfig>>')) {
|
||||
return 'IReadOnlyDictionary<string, ItemTypeFieldConfig>';
|
||||
}
|
||||
return 'string'; // Default fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate C# static class for FieldKey
|
||||
*/
|
||||
function generateCSharp(fieldKeys) {
|
||||
const header = `// <auto-generated />
|
||||
@@ -148,6 +284,322 @@ object FieldKey {`;
|
||||
return header + '\n' + fields + footer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the TypeScript SystemFieldRegistry.ts file and extract field definitions
|
||||
*/
|
||||
function parseSystemFieldRegistry(tsContent) {
|
||||
const fields = {};
|
||||
|
||||
// Find the start of SystemFieldRegistry definition
|
||||
const registryStart = tsContent.indexOf('export const SystemFieldRegistry');
|
||||
if (registryStart === -1) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
// Extract just the registry content
|
||||
const registryContent = tsContent.slice(registryStart);
|
||||
|
||||
// Match each field definition block using a state machine approach
|
||||
// Look for patterns like: 'login.username': {
|
||||
const fieldStartRegex = /'([a-z]+\.[a-z_]+)':\s*\{/g;
|
||||
let match;
|
||||
|
||||
while ((match = fieldStartRegex.exec(registryContent)) !== null) {
|
||||
const fieldKey = match[1];
|
||||
const startIdx = match.index + match[0].length;
|
||||
|
||||
// Find matching closing brace by counting braces
|
||||
let braceCount = 1;
|
||||
let endIdx = startIdx;
|
||||
while (braceCount > 0 && endIdx < registryContent.length) {
|
||||
if (registryContent[endIdx] === '{') braceCount++;
|
||||
if (registryContent[endIdx] === '}') braceCount--;
|
||||
endIdx++;
|
||||
}
|
||||
|
||||
const fieldBody = registryContent.slice(startIdx, endIdx - 1);
|
||||
|
||||
// Skip if this doesn't look like a SystemFieldDefinition (needs FieldKey property)
|
||||
if (!fieldBody.includes('FieldKey:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = {
|
||||
FieldKey: fieldKey,
|
||||
FieldType: extractStringValue(fieldBody, 'FieldType'),
|
||||
IsHidden: extractBoolValue(fieldBody, 'IsHidden'),
|
||||
IsMultiValue: extractBoolValue(fieldBody, 'IsMultiValue'),
|
||||
EnableHistory: extractBoolValue(fieldBody, 'EnableHistory'),
|
||||
Category: extractEnumValue(fieldBody, 'Category', 'FieldCategories'),
|
||||
DefaultDisplayOrder: extractNumberValue(fieldBody, 'DefaultDisplayOrder'),
|
||||
ApplicableToTypes: extractApplicableToTypes(fieldBody)
|
||||
};
|
||||
|
||||
fields[fieldKey] = field;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a string value from a field body
|
||||
*/
|
||||
function extractStringValue(body, propName) {
|
||||
const match = body.match(new RegExp(`${propName}:\\s*'([^']+)'`));
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a boolean value from a field body
|
||||
*/
|
||||
function extractBoolValue(body, propName) {
|
||||
const match = body.match(new RegExp(`${propName}:\\s*(true|false)`));
|
||||
return match ? match[1] === 'true' : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a number value from a field body
|
||||
*/
|
||||
function extractNumberValue(body, propName) {
|
||||
const match = body.match(new RegExp(`${propName}:\\s*(\\d+)`));
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an enum value from a field body (e.g., FieldCategories.Login -> Login)
|
||||
*/
|
||||
function extractEnumValue(body, propName, enumName) {
|
||||
const match = body.match(new RegExp(`${propName}:\\s*${enumName}\\.(\\w+)`));
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ApplicableToTypes object from field body
|
||||
*/
|
||||
function extractApplicableToTypes(body) {
|
||||
const applicableToTypes = {};
|
||||
|
||||
// Find the start of ApplicableToTypes
|
||||
const startMatch = body.match(/ApplicableToTypes:\s*\{/);
|
||||
if (!startMatch) {
|
||||
return applicableToTypes;
|
||||
}
|
||||
|
||||
const startIdx = startMatch.index + startMatch[0].length;
|
||||
|
||||
// Find matching closing brace by counting braces
|
||||
let braceCount = 1;
|
||||
let endIdx = startIdx;
|
||||
while (braceCount > 0 && endIdx < body.length) {
|
||||
if (body[endIdx] === '{') braceCount++;
|
||||
if (body[endIdx] === '}') braceCount--;
|
||||
endIdx++;
|
||||
}
|
||||
|
||||
const typesBody = body.slice(startIdx, endIdx - 1);
|
||||
|
||||
// Match each item type configuration: ItemType: { ShowByDefault: bool }
|
||||
const itemTypeRegex = /(\w+):\s*\{\s*ShowByDefault:\s*(true|false)\s*\}/g;
|
||||
let typeMatch;
|
||||
|
||||
while ((typeMatch = itemTypeRegex.exec(typesBody)) !== null) {
|
||||
const itemType = typeMatch[1];
|
||||
const showByDefault = typeMatch[2] === 'true';
|
||||
applicableToTypes[itemType] = { ShowByDefault: showByDefault };
|
||||
}
|
||||
|
||||
return applicableToTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate C# SystemFieldRegistry with full metadata
|
||||
* All types are dynamically generated from the parsed TypeScript
|
||||
*/
|
||||
function generateCSharpSystemFieldRegistry(fields, categories, itemTypeFieldConfigProps, systemFieldDefProps) {
|
||||
// Generate FieldCategory enum dynamically
|
||||
const categoryEnum = categories.map((cat, index) => {
|
||||
return ` /// <summary>${cat} fields.</summary>
|
||||
${cat}${index < categories.length - 1 ? ',' : ''}`;
|
||||
}).join('\n');
|
||||
|
||||
// Generate ItemTypeFieldConfig record dynamically
|
||||
const itemTypeFieldConfigParams = itemTypeFieldConfigProps
|
||||
.map(p => `${mapTypeToCSharp(p.type)} ${p.name}`)
|
||||
.join(', ');
|
||||
|
||||
// Generate SystemFieldDefinition record dynamically
|
||||
const systemFieldDefParams = systemFieldDefProps
|
||||
.map(p => `${p.csharpType} ${p.name}`)
|
||||
.join(',\n ');
|
||||
|
||||
const systemFieldDefXmlParams = systemFieldDefProps
|
||||
.map(p => `/// <param name="${p.name}">${p.comment || p.name}</param>`)
|
||||
.join('\n');
|
||||
|
||||
const header = `// <auto-generated />
|
||||
// This file is auto-generated from core/models/src/vault/SystemFieldRegistry.ts
|
||||
// Do not edit this file directly. Run 'npm run generate:models' to regenerate.
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace AliasClientDb.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Field categories for grouping in UI.
|
||||
/// </summary>
|
||||
public enum FieldCategory
|
||||
{
|
||||
${categoryEnum}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-item-type configuration for a system field.
|
||||
/// </summary>
|
||||
${itemTypeFieldConfigProps.map(p => `/// <param name="${p.name}">${p.comment || p.name}</param>`).join('\n')}
|
||||
public record ItemTypeFieldConfig(${itemTypeFieldConfigParams});
|
||||
|
||||
/// <summary>
|
||||
/// System field definition with metadata.
|
||||
/// </summary>
|
||||
${systemFieldDefXmlParams}
|
||||
public record SystemFieldDefinition(
|
||||
${systemFieldDefParams});
|
||||
|
||||
/// <summary>
|
||||
/// Registry of all system-defined fields.
|
||||
/// These fields are immutable and their metadata is defined in code.
|
||||
/// </summary>
|
||||
public static class SystemFieldRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// All system field definitions indexed by field key.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, SystemFieldDefinition> Fields =
|
||||
new Dictionary<string, SystemFieldDefinition>
|
||||
{
|
||||
`;
|
||||
|
||||
const fieldEntries = Object.entries(fields)
|
||||
.map(([key, field]) => {
|
||||
const applicableTypes = Object.entries(field.ApplicableToTypes)
|
||||
.map(([type, config]) => `["${type}"] = new ItemTypeFieldConfig(${config.ShowByDefault})`)
|
||||
.join(', ');
|
||||
|
||||
return ` [FieldKey.${fieldKeyToPropertyName(key)}] = new SystemFieldDefinition(
|
||||
FieldKey: "${field.FieldKey}",
|
||||
FieldType: "${field.FieldType}",
|
||||
IsHidden: ${field.IsHidden.toString().toLowerCase()},
|
||||
IsMultiValue: ${field.IsMultiValue.toString().toLowerCase()},
|
||||
ApplicableToTypes: new Dictionary<string, ItemTypeFieldConfig> { ${applicableTypes} },
|
||||
EnableHistory: ${field.EnableHistory.toString().toLowerCase()},
|
||||
Category: FieldCategory.${field.Category},
|
||||
DefaultDisplayOrder: ${field.DefaultDisplayOrder})`;
|
||||
})
|
||||
.join(',\n');
|
||||
|
||||
// Extract unique prefixes from field keys for IsSystemFieldPrefix
|
||||
const prefixes = [...new Set(Object.keys(fields).map(k => k.split('.')[0]))];
|
||||
const prefixChecks = prefixes.map(p => `fieldKey.StartsWith("${p}.")`).join(' ||\n ');
|
||||
|
||||
const methods = `
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Get system field definition by key.
|
||||
/// </summary>
|
||||
/// <param name="fieldKey">The field key to look up.</param>
|
||||
/// <returns>The field definition, or null if not found.</returns>
|
||||
public static SystemFieldDefinition? GetSystemField(string fieldKey)
|
||||
{
|
||||
return Fields.TryGetValue(fieldKey, out var field) ? field : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a field key represents a system field.
|
||||
/// </summary>
|
||||
/// <param name="fieldKey">The field key to check.</param>
|
||||
/// <returns>True if the field key is a system field.</returns>
|
||||
public static bool IsSystemField(string fieldKey)
|
||||
{
|
||||
return Fields.ContainsKey(fieldKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a field applies to a specific item type.
|
||||
/// </summary>
|
||||
/// <param name="field">The field definition.</param>
|
||||
/// <param name="itemType">The item type to check.</param>
|
||||
/// <returns>True if the field applies to the item type.</returns>
|
||||
public static bool FieldAppliesToType(SystemFieldDefinition field, string itemType)
|
||||
{
|
||||
return field.ApplicableToTypes.ContainsKey(itemType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all system fields applicable to a specific item type.
|
||||
/// Results are sorted by DefaultDisplayOrder.
|
||||
/// </summary>
|
||||
/// <param name="itemType">The item type.</param>
|
||||
/// <returns>Fields applicable to the item type.</returns>
|
||||
public static IEnumerable<SystemFieldDefinition> GetFieldsForItemType(string itemType)
|
||||
{
|
||||
return Fields.Values
|
||||
.Where(f => f.ApplicableToTypes.ContainsKey(itemType))
|
||||
.OrderBy(f => f.DefaultDisplayOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get system fields that should be shown by default for a specific item type.
|
||||
/// Results are sorted by DefaultDisplayOrder.
|
||||
/// </summary>
|
||||
/// <param name="itemType">The item type.</param>
|
||||
/// <returns>Fields shown by default for the item type.</returns>
|
||||
public static IEnumerable<SystemFieldDefinition> GetDefaultFieldsForItemType(string itemType)
|
||||
{
|
||||
return Fields.Values
|
||||
.Where(f => f.ApplicableToTypes.TryGetValue(itemType, out var config) && config.ShowByDefault)
|
||||
.OrderBy(f => f.DefaultDisplayOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get system fields that are NOT shown by default for a specific item type.
|
||||
/// These are the fields that can be added via an "add field" button.
|
||||
/// Results are sorted by DefaultDisplayOrder.
|
||||
/// </summary>
|
||||
/// <param name="itemType">The item type.</param>
|
||||
/// <returns>Optional fields for the item type.</returns>
|
||||
public static IEnumerable<SystemFieldDefinition> GetOptionalFieldsForItemType(string itemType)
|
||||
{
|
||||
return Fields.Values
|
||||
.Where(f => f.ApplicableToTypes.TryGetValue(itemType, out var config) && !config.ShowByDefault)
|
||||
.OrderBy(f => f.DefaultDisplayOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a field key matches a known system field prefix.
|
||||
/// </summary>
|
||||
/// <param name="fieldKey">The field key to check.</param>
|
||||
/// <returns>True if the field key has a system field prefix.</returns>
|
||||
public static bool IsSystemFieldPrefix(string fieldKey)
|
||||
{
|
||||
return ${prefixChecks};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return header + fieldEntries + methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert field key to C# property name (e.g., 'login.username' -> 'LoginUsername')
|
||||
*/
|
||||
function fieldKeyToPropertyName(fieldKey) {
|
||||
return fieldKey
|
||||
.split('.')
|
||||
.map(part => part.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(''))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure directory exists
|
||||
*/
|
||||
@@ -162,8 +614,7 @@ function ensureDir(filePath) {
|
||||
* Main execution
|
||||
*/
|
||||
function main() {
|
||||
|
||||
// Read TypeScript source
|
||||
// Read TypeScript FieldKey source
|
||||
if (!fs.existsSync(TS_SOURCE)) {
|
||||
throw new Error(`Source file not found: ${TS_SOURCE}`);
|
||||
}
|
||||
@@ -175,20 +626,59 @@ function main() {
|
||||
throw new Error('No field keys found in source file');
|
||||
}
|
||||
|
||||
// Generate C#
|
||||
// Read TypeScript SystemFieldRegistry source
|
||||
if (!fs.existsSync(TS_REGISTRY_SOURCE)) {
|
||||
throw new Error(`Source file not found: ${TS_REGISTRY_SOURCE}`);
|
||||
}
|
||||
|
||||
const tsRegistryContent = fs.readFileSync(TS_REGISTRY_SOURCE, 'utf8');
|
||||
|
||||
// Parse types dynamically from TypeScript
|
||||
const categories = parseFieldCategories(tsRegistryContent);
|
||||
const itemTypeFieldConfigProps = parseItemTypeFieldConfig(tsRegistryContent);
|
||||
const systemFieldDefProps = parseSystemFieldDefinition(tsRegistryContent);
|
||||
const systemFields = parseSystemFieldRegistry(tsRegistryContent);
|
||||
|
||||
if (Object.keys(systemFields).length === 0) {
|
||||
throw new Error('No system fields found in registry source file');
|
||||
}
|
||||
|
||||
console.log(`Parsed ${Object.keys(fieldKeys).length} field keys`);
|
||||
console.log(`Parsed ${categories.length} field categories: ${categories.join(', ')}`);
|
||||
console.log(`Parsed ${itemTypeFieldConfigProps.length} ItemTypeFieldConfig properties`);
|
||||
console.log(`Parsed ${systemFieldDefProps.length} SystemFieldDefinition properties`);
|
||||
console.log(`Parsed ${Object.keys(systemFields).length} system field definitions`);
|
||||
|
||||
// Generate C# FieldKey
|
||||
ensureDir(CS_OUTPUT);
|
||||
const csContent = generateCSharp(fieldKeys);
|
||||
fs.writeFileSync(CS_OUTPUT, csContent, 'utf8');
|
||||
console.log(`Generated: ${CS_OUTPUT}`);
|
||||
|
||||
// Generate C# SystemFieldRegistry
|
||||
ensureDir(CS_REGISTRY_OUTPUT);
|
||||
const csRegistryContent = generateCSharpSystemFieldRegistry(
|
||||
systemFields,
|
||||
categories,
|
||||
itemTypeFieldConfigProps,
|
||||
systemFieldDefProps
|
||||
);
|
||||
fs.writeFileSync(CS_REGISTRY_OUTPUT, csRegistryContent, 'utf8');
|
||||
console.log(`Generated: ${CS_REGISTRY_OUTPUT}`);
|
||||
|
||||
// Generate Swift
|
||||
ensureDir(SWIFT_OUTPUT);
|
||||
const swiftContent = generateSwift(fieldKeys);
|
||||
fs.writeFileSync(SWIFT_OUTPUT, swiftContent, 'utf8');
|
||||
console.log(`Generated: ${SWIFT_OUTPUT}`);
|
||||
|
||||
// Generate Kotlin
|
||||
ensureDir(KOTLIN_OUTPUT);
|
||||
const kotlinContent = generateKotlin(fieldKeys);
|
||||
fs.writeFileSync(KOTLIN_OUTPUT, kotlinContent, 'utf8');
|
||||
console.log(`Generated: ${KOTLIN_OUTPUT}`);
|
||||
|
||||
console.log('\nCode generation complete!');
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -20,6 +20,8 @@ default = []
|
||||
uniffi = ["dep:uniffi"]
|
||||
# Feature for WASM builds (browser extension)
|
||||
wasm = ["dep:wasm-bindgen", "dep:serde-wasm-bindgen", "dep:console_error_panic_hook"]
|
||||
# Feature for C FFI exports (.NET P/Invoke)
|
||||
ffi = []
|
||||
|
||||
[dependencies]
|
||||
# Serialization - core dependency for JSON handling
|
||||
|
||||
@@ -17,9 +17,11 @@ cd "$SCRIPT_DIR"
|
||||
# Output directories
|
||||
DIST_DIR="$SCRIPT_DIR/dist"
|
||||
WASM_DIR="$DIST_DIR/wasm"
|
||||
DOTNET_DIR="$DIST_DIR/dotnet"
|
||||
|
||||
# Target directories in consumer apps
|
||||
BROWSER_EXT_DIST="$SCRIPT_DIR/../../apps/browser-extension/src/utils/dist/core/rust"
|
||||
BLAZOR_CLIENT_DIST="$SCRIPT_DIR/../../apps/server/AliasVault.Client/wwwroot/wasm"
|
||||
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE} AliasVault Rust Core Build Script${NC}"
|
||||
@@ -47,6 +49,7 @@ echo -e " Rust version: ${GREEN}$RUST_VERSION${NC}"
|
||||
# Build mode selection
|
||||
BUILD_ALL=false
|
||||
BUILD_BROWSER=false
|
||||
BUILD_DOTNET=false
|
||||
FAST_MODE=false
|
||||
|
||||
# Parse arguments
|
||||
@@ -56,6 +59,15 @@ while [[ $# -gt 0 ]]; do
|
||||
BUILD_BROWSER=true
|
||||
shift
|
||||
;;
|
||||
--dotnet)
|
||||
BUILD_DOTNET=true
|
||||
shift
|
||||
;;
|
||||
--all)
|
||||
BUILD_BROWSER=true
|
||||
BUILD_DOTNET=true
|
||||
shift
|
||||
;;
|
||||
--fast|--dev)
|
||||
FAST_MODE=true
|
||||
echo -e "${YELLOW}Fast/dev mode enabled${NC}"
|
||||
@@ -65,7 +77,9 @@ while [[ $# -gt 0 ]]; do
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Target options:"
|
||||
echo " --browser Build WASM for browser extension"
|
||||
echo " --browser Build WASM for browser extension and Blazor WASM client"
|
||||
echo " --dotnet Build native library for .NET server-side use (macOS/Linux/Windows)"
|
||||
echo " --all Build all targets"
|
||||
echo ""
|
||||
echo "Speed options:"
|
||||
echo " --fast, --dev Faster builds (for development)"
|
||||
@@ -73,9 +87,6 @@ while [[ $# -gt 0 ]]; do
|
||||
echo "Other options:"
|
||||
echo " --help Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " ./build.sh --browser # Build WASM for browser extension"
|
||||
echo " ./build.sh --browser --fast # Fast dev build"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
@@ -86,11 +97,12 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
# If no targets specified, show help
|
||||
if ! $BUILD_BROWSER; then
|
||||
if ! $BUILD_BROWSER && ! $BUILD_DOTNET; then
|
||||
echo "No target specified. Use --help for usage."
|
||||
echo ""
|
||||
echo "Quick start:"
|
||||
echo " ./build.sh --browser # Build for browser extension"
|
||||
echo " ./build.sh --dotnet # Build for .NET"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -163,9 +175,99 @@ README_EOF
|
||||
|
||||
echo -e "${GREEN}Distributed to: $BROWSER_EXT_DIST${NC}"
|
||||
ls -lh "$BROWSER_EXT_DIST/"
|
||||
|
||||
# Also distribute to Blazor client
|
||||
echo ""
|
||||
echo -e "${BLUE}Distributing to Blazor client...${NC}"
|
||||
rm -rf "$BLAZOR_CLIENT_DIST"
|
||||
mkdir -p "$BLAZOR_CLIENT_DIST"
|
||||
cp "$WASM_DIR"/aliasvault_core_bg.wasm "$BLAZOR_CLIENT_DIST/"
|
||||
cp "$WASM_DIR"/aliasvault_core.js "$BLAZOR_CLIENT_DIST/"
|
||||
|
||||
echo -e "${GREEN}Distributed to: $BLAZOR_CLIENT_DIST${NC}"
|
||||
ls -lh "$BLAZOR_CLIENT_DIST/"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# .NET Build (Native Library with FFI)
|
||||
# ============================================
|
||||
build_dotnet() {
|
||||
echo ""
|
||||
echo -e "${BLUE}Building native library for .NET...${NC}"
|
||||
|
||||
local start_time=$(date +%s)
|
||||
|
||||
# Detect current platform
|
||||
local os_name
|
||||
local arch_name
|
||||
local lib_name
|
||||
local target_dir
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
os_name="macos"
|
||||
lib_name="libaliasvault_core.dylib"
|
||||
;;
|
||||
Linux)
|
||||
os_name="linux"
|
||||
lib_name="libaliasvault_core.so"
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
os_name="windows"
|
||||
lib_name="aliasvault_core.dll"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unsupported OS: $(uname -s)${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64)
|
||||
arch_name="x64"
|
||||
;;
|
||||
arm64|aarch64)
|
||||
arch_name="arm64"
|
||||
;;
|
||||
*)
|
||||
arch_name="$(uname -m)"
|
||||
;;
|
||||
esac
|
||||
|
||||
target_dir="$DOTNET_DIR/${os_name}-${arch_name}"
|
||||
mkdir -p "$target_dir"
|
||||
|
||||
echo -e " Platform: ${YELLOW}${os_name}-${arch_name}${NC}"
|
||||
|
||||
# Build with cargo
|
||||
echo -e " Running cargo build..."
|
||||
if $FAST_MODE; then
|
||||
cargo build --features ffi
|
||||
local cargo_target="target/debug"
|
||||
else
|
||||
cargo build --release --features ffi
|
||||
local cargo_target="target/release"
|
||||
fi
|
||||
|
||||
# Copy the library
|
||||
if [ -f "$cargo_target/$lib_name" ]; then
|
||||
cp "$cargo_target/$lib_name" "$target_dir/"
|
||||
local lib_size
|
||||
lib_size=$(ls -lh "$target_dir/$lib_name" | awk '{print $5}')
|
||||
echo -e "${GREEN}Native library built! ${NC}"
|
||||
echo -e " Output: ${YELLOW}$target_dir/$lib_name${NC}"
|
||||
echo -e " Size: ${YELLOW}$lib_size${NC}"
|
||||
else
|
||||
echo -e "${RED}Build failed: $lib_name not found${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
echo -e "${GREEN}.NET build complete! (${duration}s)${NC}"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Main Build Process
|
||||
# ============================================
|
||||
@@ -176,6 +278,13 @@ if $BUILD_BROWSER; then
|
||||
distribute_browser
|
||||
fi
|
||||
|
||||
if $BUILD_DOTNET; then
|
||||
build_dotnet
|
||||
# Note: dotnet native libs are built to dist/dotnet/ but not distributed
|
||||
# Blazor WASM uses the WASM module via JS interop instead
|
||||
echo -e "${YELLOW}Note: Native library built to dist/dotnet/ (for server-side .NET use)${NC}"
|
||||
fi
|
||||
|
||||
TOTAL_END=$(date +%s)
|
||||
TOTAL_DURATION=$((TOTAL_END - TOTAL_START))
|
||||
|
||||
|
||||
181
core/rust/src/ffi.rs
Normal file
181
core/rust/src/ffi.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
//! C FFI exports for .NET P/Invoke.
|
||||
//!
|
||||
//! These functions provide a C-compatible interface for calling Rust functions from C#.
|
||||
//! All functions use JSON strings for input/output to simplify marshalling.
|
||||
|
||||
use std::ffi::{c_char, CStr, CString};
|
||||
use std::ptr;
|
||||
|
||||
use crate::credential_matcher::{filter_credentials, CredentialMatcherInput};
|
||||
use crate::merge::{merge_vaults, MergeInput, SYNCABLE_TABLE_NAMES};
|
||||
|
||||
/// Merge two vaults using LWW strategy.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// - `input_json` must be a valid null-terminated C string
|
||||
/// - The returned pointer must be freed by calling `free_string`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A null-terminated C string containing the JSON result (MergeOutput).
|
||||
/// Returns null on error.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn merge_vaults_ffi(input_json: *const c_char) -> *mut c_char {
|
||||
if input_json.is_null() {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let c_str = match CStr::from_ptr(input_json).to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return ptr::null_mut(),
|
||||
};
|
||||
|
||||
let input: MergeInput = match serde_json::from_str(c_str) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
return create_error_response(&format!("Failed to parse input: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let output = match merge_vaults(input) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
return create_error_response(&format!("Merge failed: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => string_to_c_char(json),
|
||||
Err(e) => create_error_response(&format!("Failed to serialize output: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter credentials for autofill.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// - `input_json` must be a valid null-terminated C string
|
||||
/// - The returned pointer must be freed by calling `free_string`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A null-terminated C string containing the JSON result (CredentialMatcherOutput).
|
||||
/// Returns null on error.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn filter_credentials_ffi(input_json: *const c_char) -> *mut c_char {
|
||||
if input_json.is_null() {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let c_str = match CStr::from_ptr(input_json).to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return ptr::null_mut(),
|
||||
};
|
||||
|
||||
let input: CredentialMatcherInput = match serde_json::from_str(c_str) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
return create_error_response(&format!("Failed to parse input: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let output = filter_credentials(input);
|
||||
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => string_to_c_char(json),
|
||||
Err(e) => create_error_response(&format!("Failed to serialize output: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the list of syncable table names as a JSON array.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// - The returned pointer must be freed by calling `free_string`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A null-terminated C string containing a JSON array of table names.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn get_syncable_table_names_ffi() -> *mut c_char {
|
||||
let names: Vec<&str> = SYNCABLE_TABLE_NAMES.iter().map(|s| *s).collect();
|
||||
match serde_json::to_string(&names) {
|
||||
Ok(json) => string_to_c_char(json),
|
||||
Err(_) => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a string that was allocated by Rust.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// - `s` must be a pointer that was returned by one of the FFI functions
|
||||
/// - This function must only be called once per pointer
|
||||
/// - After calling this function, the pointer is invalid
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn free_string(s: *mut c_char) {
|
||||
if !s.is_null() {
|
||||
drop(CString::from_raw(s));
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Rust string to a C string pointer.
|
||||
fn string_to_c_char(s: String) -> *mut c_char {
|
||||
match CString::new(s) {
|
||||
Ok(c_string) => c_string.into_raw(),
|
||||
Err(_) => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an error response JSON string.
|
||||
fn create_error_response(message: &str) -> *mut c_char {
|
||||
let error_json = format!(r#"{{"success":false,"error":"{}"}}"#, message.replace('"', r#"\""#));
|
||||
string_to_c_char(error_json)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::CString;
|
||||
|
||||
#[test]
|
||||
fn test_get_syncable_table_names() {
|
||||
let result = get_syncable_table_names_ffi();
|
||||
assert!(!result.is_null());
|
||||
|
||||
unsafe {
|
||||
let c_str = CStr::from_ptr(result);
|
||||
let json = c_str.to_str().unwrap();
|
||||
let names: Vec<String> = serde_json::from_str(json).unwrap();
|
||||
assert!(names.contains(&"Items".to_string()));
|
||||
assert!(names.contains(&"FieldValues".to_string()));
|
||||
free_string(result);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_null_input() {
|
||||
unsafe {
|
||||
let result = merge_vaults_ffi(ptr::null());
|
||||
assert!(result.is_null());
|
||||
|
||||
let result = filter_credentials_ffi(ptr::null());
|
||||
assert!(result.is_null());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_json_input() {
|
||||
let invalid_json = CString::new("not valid json").unwrap();
|
||||
unsafe {
|
||||
let result = merge_vaults_ffi(invalid_json.as_ptr());
|
||||
assert!(!result.is_null());
|
||||
|
||||
let c_str = CStr::from_ptr(result);
|
||||
let json = c_str.to_str().unwrap();
|
||||
assert!(json.contains("error"));
|
||||
free_string(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ pub mod wasm;
|
||||
#[cfg(feature = "wasm")]
|
||||
pub use wasm::*;
|
||||
|
||||
// C FFI exports for .NET P/Invoke
|
||||
#[cfg(feature = "ffi")]
|
||||
pub mod ffi;
|
||||
|
||||
// UniFFI scaffolding
|
||||
#[cfg(feature = "uniffi")]
|
||||
uniffi::setup_scaffolding!();
|
||||
|
||||
@@ -231,29 +231,10 @@ fn merge_table_by_composite_key(
|
||||
) -> Vec<SqlStatement> {
|
||||
let mut statements: Vec<SqlStatement> = Vec::new();
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
{
|
||||
use crate::wasm::log;
|
||||
log(&format!(
|
||||
"[merge_composite] Processing {} - local={} records, server={} records, key_cols={:?}",
|
||||
table_name,
|
||||
local_records.len(),
|
||||
server_records.len(),
|
||||
key_columns
|
||||
));
|
||||
}
|
||||
|
||||
// Create map of server records by composite key
|
||||
let mut server_map: HashMap<String, &Record> = HashMap::new();
|
||||
for record in server_records {
|
||||
let key = get_composite_key(record, key_columns);
|
||||
#[cfg(feature = "wasm")]
|
||||
{
|
||||
use crate::wasm::log;
|
||||
let value = record.get("Value").and_then(|v| v.as_str()).unwrap_or("(no value)");
|
||||
let updated = record.get("UpdatedAt").and_then(|v| v.as_str()).unwrap_or("(no ts)");
|
||||
log(&format!("[merge_composite] SERVER record: key={}, value={}, updated={}", key, value, updated));
|
||||
}
|
||||
// Keep the one with latest UpdatedAt if duplicate keys
|
||||
if let Some(existing) = server_map.get(&key) {
|
||||
if get_updated_at(record) > get_updated_at(existing) {
|
||||
@@ -273,14 +254,6 @@ fn merge_table_by_composite_key(
|
||||
None => continue,
|
||||
};
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
{
|
||||
use crate::wasm::log;
|
||||
let value = local_record.get("Value").and_then(|v| v.as_str()).unwrap_or("(no value)");
|
||||
let updated = local_record.get("UpdatedAt").and_then(|v| v.as_str()).unwrap_or("(no ts)");
|
||||
log(&format!("[merge_composite] LOCAL record: key={}, value={}, updated={}", composite_key, value, updated));
|
||||
}
|
||||
|
||||
if let Some(server_record) = server_map.get(&composite_key) {
|
||||
// Record exists in both - compare UpdatedAt
|
||||
let local_ts = get_updated_at(local_record);
|
||||
@@ -292,44 +265,24 @@ fn merge_table_by_composite_key(
|
||||
stats.conflicts += 1;
|
||||
stats.records_from_server += 1;
|
||||
if let Some(stmt) = generate_update_sql(table_name, server_record, &local_id) {
|
||||
#[cfg(feature = "wasm")]
|
||||
{
|
||||
use crate::wasm::log;
|
||||
log(&format!("[merge_composite] SERVER WINS: key={}", composite_key));
|
||||
}
|
||||
statements.push(stmt);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Local wins - no action needed
|
||||
stats.records_from_local += 1;
|
||||
#[cfg(feature = "wasm")]
|
||||
{
|
||||
use crate::wasm::log;
|
||||
log(&format!("[merge_composite] LOCAL WINS: key={}", composite_key));
|
||||
}
|
||||
}
|
||||
}
|
||||
server_map.remove(&composite_key);
|
||||
} else {
|
||||
// Only in local - no action needed
|
||||
stats.records_created_locally += 1;
|
||||
#[cfg(feature = "wasm")]
|
||||
{
|
||||
use crate::wasm::log;
|
||||
log(&format!("[merge_composite] LOCAL ONLY: key={}", composite_key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server-only records (by composite key) - generate INSERTs
|
||||
for (_key, server_record) in &server_map {
|
||||
stats.records_inserted += 1;
|
||||
#[cfg(feature = "wasm")]
|
||||
{
|
||||
use crate::wasm::log;
|
||||
log(&format!("[merge_composite] SERVER ONLY (INSERT): key={}", _key));
|
||||
}
|
||||
if let Some(stmt) = generate_insert_sql(table_name, server_record) {
|
||||
statements.push(stmt);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user