Skip to content

Commit ea88345

Browse files
committed
see revisions.md for list of changes.
1 parent 6b2fdab commit ea88345

5 files changed

Lines changed: 199 additions & 59 deletions

File tree

1-Aquiis.Infrastructure/Data/SqlCipherConnectionInterceptor.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ public override void ConnectionOpened(DbConnection connection, ConnectionEndEven
5858
{
5959
Console.WriteLine("[SqlCipherConnectionInterceptor] No password provided, skipping encryption");
6060
}
61+
62+
// Enable WAL mode for all connections (encrypted or not).
63+
// WAL mode is persistent in the database file — this is a no-op for databases
64+
// already in WAL mode, and upgrades fresh databases from the default DELETE journal.
65+
// NORMAL synchronous is safe with WAL and gives a meaningful performance boost.
66+
using (var cmd = connection.CreateCommand())
67+
{
68+
cmd.CommandText = "PRAGMA journal_mode = WAL;";
69+
cmd.ExecuteNonQuery();
70+
cmd.CommandText = "PRAGMA synchronous = NORMAL;";
71+
cmd.ExecuteNonQuery();
72+
}
73+
6174
base.ConnectionOpened(connection, eventData);
6275
}
6376

@@ -99,6 +112,19 @@ public override async Task ConnectionOpenedAsync(DbConnection connection, Connec
99112
{
100113
Console.WriteLine("[SqlCipherConnectionInterceptor] No password provided, skipping encryption (async)");
101114
}
115+
116+
// Enable WAL mode for all connections (encrypted or not).
117+
// WAL mode is persistent in the database file — this is a no-op for databases
118+
// already in WAL mode, and upgrades fresh databases from the default DELETE journal.
119+
// NORMAL synchronous is safe with WAL and gives a meaningful performance boost.
120+
using (var cmd = connection.CreateCommand())
121+
{
122+
cmd.CommandText = "PRAGMA journal_mode = WAL;";
123+
await cmd.ExecuteNonQueryAsync(cancellationToken);
124+
cmd.CommandText = "PRAGMA synchronous = NORMAL;";
125+
await cmd.ExecuteNonQueryAsync(cancellationToken);
126+
}
127+
102128
await base.ConnectionOpenedAsync(connection, eventData, cancellationToken);
103129
}
104130
}

1-Aquiis.Infrastructure/Services/DatabaseEncryptionService.cs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,33 @@ public DatabaseEncryptionService(
186186
await encryptedConn.OpenAsync();
187187
_logger.LogInformation("✅ Encrypted database opened successfully");
188188

189-
// Set password using PRAGMA
189+
// Set password using PRAGMA — must be first operation on the connection
190190
using (var cmd = encryptedConn.CreateCommand())
191191
{
192192
cmd.CommandText = $"PRAGMA key = '{password}';";
193193
await cmd.ExecuteNonQueryAsync();
194194
_logger.LogInformation("Encryption key set with PRAGMA");
195195
}
196+
197+
// Explicitly set SQLCipher 4 parameters to match how the database was created.
198+
// On Windows the native library does not always auto-detect these from the file
199+
// header, causing decryption to silently fail with wrong defaults.
200+
using (var cmd = encryptedConn.CreateCommand())
201+
{
202+
cmd.CommandText = "PRAGMA cipher_page_size = 4096;";
203+
await cmd.ExecuteNonQueryAsync();
204+
205+
cmd.CommandText = "PRAGMA kdf_iter = 256000;";
206+
await cmd.ExecuteNonQueryAsync();
207+
208+
cmd.CommandText = "PRAGMA cipher_hmac_algorithm = HMAC_SHA512;";
209+
await cmd.ExecuteNonQueryAsync();
210+
211+
cmd.CommandText = "PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA512;";
212+
await cmd.ExecuteNonQueryAsync();
213+
214+
_logger.LogInformation("SQLCipher 4 cipher parameters set explicitly");
215+
}
196216

197217
// Attach unencrypted database
198218
using (var cmd = encryptedConn.CreateCommand())
@@ -261,11 +281,23 @@ private async Task<bool> VerifyEncryptedDatabaseAsync(string dbPath, string pass
261281
{
262282
await conn.OpenAsync();
263283

264-
// Set the password using PRAGMA
284+
// Set password then explicit SQLCipher 4 params — mirrors DecryptDatabaseAsync
265285
using (var cmd = conn.CreateCommand())
266286
{
267287
cmd.CommandText = $"PRAGMA key = '{password}';";
268288
await cmd.ExecuteNonQueryAsync();
289+
290+
cmd.CommandText = "PRAGMA cipher_page_size = 4096;";
291+
await cmd.ExecuteNonQueryAsync();
292+
293+
cmd.CommandText = "PRAGMA kdf_iter = 256000;";
294+
await cmd.ExecuteNonQueryAsync();
295+
296+
cmd.CommandText = "PRAGMA cipher_hmac_algorithm = HMAC_SHA512;";
297+
await cmd.ExecuteNonQueryAsync();
298+
299+
cmd.CommandText = "PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA512;";
300+
await cmd.ExecuteNonQueryAsync();
269301
}
270302

271303
_logger.LogInformation("Encrypted database opened successfully");

4-Aquiis.SimpleStart/Program.cs

Lines changed: 128 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,14 @@
2626

2727
var builder = WebApplication.CreateBuilder(args);
2828

29-
// CRITICAL: Handle .restore_pending BEFORE any DbContext registration
30-
// This ensures encrypted database detection happens on the correct file
31-
HandlePendingRestore(builder.Configuration);
32-
33-
// Configure for Electron
29+
// Configure for Electron FIRST — this sets HybridSupport.IsElectronActive, which
30+
// HandlePendingRestore depends on to compute the correct Electron user-data DB path.
3431
builder.WebHost.UseElectron(args);
3532

33+
// CRITICAL: Handle .restore_pending BEFORE any DbContext registration.
34+
// Must run AFTER UseElectron so HybridSupport.IsElectronActive is true.
35+
HandlePendingRestore(builder.Configuration);
36+
3637
// Configure URLs - use specific port for Electron
3738
if (HybridSupport.IsElectronActive)
3839
{
@@ -777,53 +778,137 @@
777778
// Local function to handle .restore_pending before service registration
778779
static void HandlePendingRestore(IConfiguration configuration)
779780
{
780-
var connectionString = configuration.GetConnectionString("DefaultConnection");
781-
Console.WriteLine($"[Program.HandlePendingRestore] Checking for staged restore on database connection string: {connectionString}");
782-
if (string.IsNullOrEmpty(connectionString))
781+
// CRITICAL: This runs before ANY service registration or DbContext creation.
782+
// It must compute the correct database path independently of the DI container.
783+
//
784+
// BUG FIXED: Previously this read the path from appsettings.json DefaultConnection,
785+
// which is a fallback path (e.g. Infrastructure/Data/app_v0.0.0.db) that is NEVER
786+
// used by the Electron app. The Electron app stores its database in the OS user-data
787+
// directory (e.g. %APPDATA%\Aquiis\app_v1.1.0.db on Windows). This mismatch meant
788+
// the staged restore was never found and never applied, leaving the app in a broken
789+
// state after an encrypt→decrypt cycle (the DPAPI key is deleted on successful
790+
// decryption, so on the next startup the app saw an encrypted DB with no key and
791+
// displayed the unlock screen indefinitely).
792+
793+
string dbPath;
794+
795+
if (HybridSupport.IsElectronActive)
783796
{
784-
// Can't proceed without connection string
785-
return;
797+
// Replicate ElectronPathService.GetDatabasePathSync() without needing DI.
798+
var dbFileName = configuration["ApplicationSettings:DatabaseFileName"] ?? "app.db";
799+
800+
string basePath;
801+
if (OperatingSystem.IsWindows())
802+
basePath = Path.Combine(
803+
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Aquiis");
804+
else if (OperatingSystem.IsMacOS())
805+
basePath = Path.Combine(
806+
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
807+
"Library", "Application Support", "Aquiis");
808+
else // Linux
809+
basePath = Path.Combine(
810+
Environment.GetEnvironmentVariable("XDG_CONFIG_HOME")
811+
?? Path.Combine(
812+
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"),
813+
"Aquiis");
814+
815+
Directory.CreateDirectory(basePath);
816+
dbPath = Path.Combine(basePath, dbFileName);
817+
Console.WriteLine($"[Program.HandlePendingRestore] Electron mode — DB path: {dbPath}");
786818
}
787-
788-
// Extract database path
789-
var builder = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder(connectionString);
790-
var dbPath = builder.DataSource;
791-
792-
if (!Path.IsPathRooted(dbPath))
819+
else
793820
{
794-
dbPath = Path.Combine(Directory.GetCurrentDirectory(), dbPath);
821+
// Web / non-Electron: derive path from appsettings.json connection string.
822+
var connectionString = configuration.GetConnectionString("DefaultConnection");
823+
Console.WriteLine($"[Program.HandlePendingRestore] Web mode — connection string: {connectionString}");
824+
825+
if (string.IsNullOrEmpty(connectionString))
826+
{
827+
Console.WriteLine("[Program.HandlePendingRestore] No connection string found, skipping");
828+
return;
829+
}
830+
831+
var csBuilder = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder(connectionString);
832+
dbPath = csBuilder.DataSource;
833+
834+
if (!Path.IsPathRooted(dbPath))
835+
dbPath = Path.Combine(Directory.GetCurrentDirectory(), dbPath);
836+
837+
Console.WriteLine($"[Program.HandlePendingRestore] Web mode — DB path: {dbPath}");
795838
}
796-
839+
797840
var stagedRestorePath = $"{dbPath}.restore_pending";
798-
799-
// Check if there's a staged restore waiting
800-
if (File.Exists(stagedRestorePath))
841+
842+
if (!File.Exists(stagedRestorePath))
801843
{
802-
Console.WriteLine($"[Program.HandlePendingRestore] Found staged restore file, applying it now: {stagedRestorePath}");
803-
804-
// Clear SQLite connection pool
805-
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
806-
807-
// Wait for connections to close
808-
Thread.Sleep(500);
809-
810-
// Backup current database if it exists
811-
if (File.Exists(dbPath))
844+
Console.WriteLine($"[Program.HandlePendingRestore] No staged restore pending at: {stagedRestorePath}");
845+
return;
846+
}
847+
848+
var pendingSize = new FileInfo(stagedRestorePath).Length;
849+
Console.WriteLine($"[Program.HandlePendingRestore] Staged restore found: {stagedRestorePath} ({pendingSize:N0} bytes)");
850+
851+
// At this point in startup no connections have been opened, but clear pools
852+
// as a safety measure and force a GC pass on Windows to release any lingering
853+
// SQLite native file handles from a previous process that didn't exit cleanly.
854+
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
855+
856+
if (OperatingSystem.IsWindows())
857+
{
858+
GC.Collect();
859+
GC.WaitForPendingFinalizers();
860+
GC.Collect();
861+
Console.WriteLine("[Program.HandlePendingRestore] GC collection complete (Windows handle safety)");
862+
}
863+
864+
Thread.Sleep(300);
865+
866+
if (File.Exists(dbPath))
867+
{
868+
var currentSize = new FileInfo(dbPath).Length;
869+
Console.WriteLine($"[Program.HandlePendingRestore] Current DB: {dbPath} ({currentSize:N0} bytes) — backing up before replace");
870+
871+
// Remove WAL/SHM files — they belong to the outgoing session
872+
var walPath = $"{dbPath}-wal";
873+
var shmPath = $"{dbPath}-shm";
874+
if (File.Exists(walPath)) { File.Delete(walPath); Console.WriteLine("[Program.HandlePendingRestore] Deleted WAL file"); }
875+
if (File.Exists(shmPath)) { File.Delete(shmPath); Console.WriteLine("[Program.HandlePendingRestore] Deleted SHM file"); }
876+
877+
var timestamp = DateTime.Now.ToString("yyyyMMddHHmmssfff");
878+
var beforeRestorePath = $"{dbPath}.beforeRestore.{timestamp}";
879+
880+
try
812881
{
813-
var timestamp = DateTime.Now.ToString("yyyyMMddHHmmssfff");
814-
var beforeRestorePath = $"{dbPath}.beforeRestore.{timestamp}";
815882
File.Move(dbPath, beforeRestorePath);
816-
Console.WriteLine($"Current database backed up to: {beforeRestorePath}");
883+
Console.WriteLine($"[Program.HandlePendingRestore] Current DB backed up to: {beforeRestorePath}");
817884
}
818-
819-
// Move staged restore into place
885+
catch (Exception ex)
886+
{
887+
Console.WriteLine($"[Program.HandlePendingRestore] ERROR: Cannot back up current DB — restore aborted.");
888+
Console.WriteLine($"[Program.HandlePendingRestore] {ex.GetType().Name}: {ex.Message}");
889+
if (OperatingSystem.IsWindows())
890+
Console.WriteLine($"[Program.HandlePendingRestore] HResult: 0x{ex.HResult:X8} " +
891+
"(0x80070020 = sharing violation / file locked by another process)");
892+
Console.WriteLine("[Program.HandlePendingRestore] Staged file preserved for next startup attempt.");
893+
return;
894+
}
895+
}
896+
else
897+
{
898+
Console.WriteLine($"[Program.HandlePendingRestore] No existing DB at {dbPath} — placing staged restore directly");
899+
}
900+
901+
try
902+
{
820903
File.Move(stagedRestorePath, dbPath);
821-
Console.WriteLine("Staged restore applied successfully");
822-
823-
// Delete orphaned WAL/SHM files if they exist
824-
var walPath = $"{dbPath}-wal";
825-
var shmPath = $"{dbPath}-shm";
826-
if (File.Exists(walPath)) File.Delete(walPath);
827-
if (File.Exists(shmPath)) File.Delete(shmPath);
904+
Console.WriteLine($"[Program.HandlePendingRestore] ✅ Staged restore applied successfully.");
905+
Console.WriteLine($"[Program.HandlePendingRestore] New DB: {dbPath} ({new FileInfo(dbPath).Length:N0} bytes)");
906+
}
907+
catch (Exception ex)
908+
{
909+
Console.WriteLine($"[Program.HandlePendingRestore] ERROR: Failed to move staged restore into place.");
910+
Console.WriteLine($"[Program.HandlePendingRestore] {ex.GetType().Name}: {ex.Message}");
911+
if (OperatingSystem.IsWindows())
912+
Console.WriteLine($"[Program.HandlePendingRestore] HResult: 0x{ex.HResult:X8}");
828913
}
829914
}

4-Aquiis.SimpleStart/electron.manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"--disable-dev-shm-usage"
99
],
1010
"name": "aquiis",
11-
"description": "Desktop property management application for landlords managing up to 9 residential properties",
11+
"description": "Aquiis Property Management",
1212
"author": "Aquiis",
1313
"singleInstance": false,
1414
"environment": "Production",

Aquiis.code-workspace

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
{
2-
"folders": [
3-
{
4-
"path": ".",
5-
},
6-
{
7-
"path": "../../Documents/Orion/Projects/Aquiis",
8-
},
9-
{
10-
"path": "../appimage.github.io",
11-
},
12-
],
13-
"settings": {},
14-
}
2+
"folders": [
3+
{
4+
"path": "."
5+
},
6+
{
7+
"path": "../../Documents/Orion II/Projects/Aquiis"
8+
}
9+
],
10+
"settings": {}
11+
}

0 commit comments

Comments
 (0)