Skip to content

Commit b538285

Browse files
Merge pull request #947 from QueryaHub/issue/916-lazy-secrets-keychain-error-handling
perf(storage): resolve connection secrets lazily and handle Keychain errors gracefully (#916)
2 parents b8b1859 + 2cef8e4 commit b538285

4 files changed

Lines changed: 122 additions & 27 deletions

File tree

‎lib/core/storage/connection_secrets_store.dart‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,14 @@ class ConnectionSecretsStore {
5656
readForConnection(
5757
int connectionId,
5858
) async {
59-
final password = await backend.read(_passwordKey(connectionId));
60-
final connectionString =
61-
await backend.read(_connectionStringKey(connectionId));
62-
return (password: password, connectionString: connectionString);
59+
try {
60+
final password = await backend.read(_passwordKey(connectionId));
61+
final connectionString =
62+
await backend.read(_connectionStringKey(connectionId));
63+
return (password: password, connectionString: connectionString);
64+
} catch (_) {
65+
return (password: null, connectionString: null);
66+
}
6367
}
6468

6569
static Future<void> deleteForConnection(int connectionId) async {

‎lib/core/storage/local_db.dart‎

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'dart:io';
22

3+
import 'package:flutter/foundation.dart';
34
import 'package:path/path.dart' as p;
45
import 'package:querya_desktop/core/storage/app_data_root.dart';
56
import 'package:querya_desktop/core/storage/connection_secrets_store.dart';
@@ -423,34 +424,50 @@ class LocalDb {
423424
return _sqliteInt(rows.first['id']);
424425
}
425426

426-
Future<List<ConnectionRow>> getConnections() async {
427+
/// Returns all saved connections.
428+
///
429+
/// By default, passwords and connection strings are NOT eagerly hydrated from
430+
/// the platform secure store to avoid IPC bottlenecks, Keychain lockups, and
431+
/// D-Bus timeouts during sidebar/startup population. Secrets are resolved
432+
/// on-demand when a connection is initiated.
433+
Future<List<ConnectionRow>> getConnections({bool hydrateSecrets = false}) async {
427434
final db = await _open();
428435
final rows =
429436
await db.query('connections', orderBy: 'sort_order ASC, name ASC');
437+
if (!hydrateSecrets) {
438+
return rows.map((m) => ConnectionRow.fromMap(m)).toList();
439+
}
430440
final futures =
431441
rows.map((m) => _hydrateConnection(ConnectionRow.fromMap(m)));
432442
return Future.wait(futures);
433443
}
434444

435445
static Future<ConnectionRow> _hydrateConnection(ConnectionRow row) async {
436446
if (row.id == null) return row;
437-
final secrets = await ConnectionSecretsStore.readForConnection(row.id!);
438-
return ConnectionRow(
439-
id: row.id,
440-
type: row.type,
441-
name: row.name,
442-
host: row.host,
443-
port: row.port,
444-
username: row.username,
445-
password: secrets.password ?? row.password,
446-
databaseName: row.databaseName,
447-
authSource: row.authSource,
448-
useSSL: row.useSSL,
449-
connectionString: secrets.connectionString ?? row.connectionString,
450-
folderId: row.folderId,
451-
sortOrder: row.sortOrder,
452-
createdAt: row.createdAt,
453-
);
447+
try {
448+
final secrets = await ConnectionSecretsStore.readForConnection(row.id!);
449+
return row.copyWith(
450+
password: secrets.password ?? row.password,
451+
connectionString: secrets.connectionString ?? row.connectionString,
452+
);
453+
} catch (e) {
454+
debugPrint('LocalDb._hydrateConnection failed for connection ${row.id}: $e');
455+
return row;
456+
}
457+
}
458+
459+
/// Hydrates secrets for a single [ConnectionRow] on demand.
460+
Future<ConnectionRow> hydrateConnection(ConnectionRow row) =>
461+
_hydrateConnection(row);
462+
463+
/// Retrieves a single connection by [id], optionally hydrating secrets.
464+
Future<ConnectionRow?> getConnectionById(int id, {bool hydrateSecrets = false}) async {
465+
final db = await _open();
466+
final rows = await db.query('connections', where: 'id = ?', whereArgs: [id]);
467+
if (rows.isEmpty) return null;
468+
final row = ConnectionRow.fromMap(rows.first);
469+
if (!hydrateSecrets) return row;
470+
return _hydrateConnection(row);
454471
}
455472

456473
/// Inserts a row and returns the SQLite row id.

‎test/core/storage/local_db_secrets_test.dart‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,15 @@ void main() {
9898

9999
final list = await LocalDb.instance.getConnections();
100100
final loaded = list.singleWhere((c) => c.id == id);
101-
expect(loaded.password, 'redis-secret');
101+
// Secrets are resolved lazily on demand, not eagerly in getConnections()
102+
expect(loaded.password, isNull);
103+
104+
final hydrated = await LocalDb.instance.hydrateConnection(loaded);
105+
expect(hydrated.password, 'redis-secret');
106+
107+
final eagerlyHydrated = (await LocalDb.instance.getConnections(hydrateSecrets: true))
108+
.singleWhere((c) => c.id == id);
109+
expect(eagerlyHydrated.password, 'redis-secret');
102110
});
103111

104112
test('removeConnection deletes secure-store entries', () async {
@@ -146,7 +154,7 @@ void main() {
146154
);
147155
await LocalDb.instance.updateConnection(updatedRow);
148156

149-
final list = await LocalDb.instance.getConnections();
157+
final list = await LocalDb.instance.getConnections(hydrateSecrets: true);
150158
final loaded = list.singleWhere((c) => c.id == id);
151159
expect(loaded.name, 'PG_Updated');
152160
expect(loaded.host, 'db.example.com');
@@ -234,7 +242,7 @@ void main() {
234242
throwsA(isA<StateError>()),
235243
);
236244

237-
final list = await LocalDb.instance.getConnections();
245+
final list = await LocalDb.instance.getConnections(hydrateSecrets: true);
238246
final loaded = list.singleWhere((c) => c.id == id);
239247
expect(loaded.name, 'PG_Before');
240248
expect(loaded.host, 'localhost');
@@ -273,10 +281,65 @@ void main() {
273281
expect(merged.host, 'db.example.com');
274282

275283
await LocalDb.instance.updateConnection(merged);
276-
final loaded = (await LocalDb.instance.getConnections())
284+
final loaded = (await LocalDb.instance.getConnections(hydrateSecrets: true))
277285
.singleWhere((c) => c.id == id);
278286
expect(loaded.password, 'keep-me');
279287
expect(loaded.name, 'PG Renamed');
280288
});
289+
290+
test('getConnections does not read secure store by default', () async {
291+
const row = ConnectionRow(
292+
type: 'mysql',
293+
name: 'MySQL_Lazy',
294+
host: 'localhost',
295+
port: 3306,
296+
username: 'root',
297+
password: 'super-secret-pw',
298+
createdAt: '2026-01-01T00:00:00Z',
299+
);
300+
final id = await LocalDb.instance.addConnection(row);
301+
302+
// failNextRead should NOT be triggered because getConnections() does not read secrets!
303+
testMemorySecrets.failNextRead = StateError('should not be called');
304+
305+
final list = await LocalDb.instance.getConnections();
306+
final conn = list.singleWhere((c) => c.id == id);
307+
expect(conn.name, 'MySQL_Lazy');
308+
expect(conn.password, isNull);
309+
310+
// failNextRead is still set because read was never called
311+
expect(testMemorySecrets.failNextRead, isNotNull);
312+
testMemorySecrets.failNextRead = null;
313+
});
314+
315+
test(
316+
'readForConnection and _hydrateConnection handle Keychain error gracefully without throwing',
317+
() async {
318+
const row = ConnectionRow(
319+
type: 'mysql',
320+
name: 'MySQL_Faulty',
321+
host: 'localhost',
322+
port: 3306,
323+
username: 'root',
324+
password: 'secret-password',
325+
createdAt: '2026-01-01T00:00:00Z',
326+
);
327+
final id = await LocalDb.instance.addConnection(row);
328+
329+
testMemorySecrets.failNextRead = StateError('org.freedesktop.DBus.Error.NoReply');
330+
331+
// readForConnection should return nulls instead of throwing
332+
final secrets = await ConnectionSecretsStore.readForConnection(id);
333+
expect(secrets.password, isNull);
334+
expect(secrets.connectionString, isNull);
335+
336+
testMemorySecrets.failNextRead = StateError('Keychain locked');
337+
338+
// getConnections(hydrateSecrets: true) should still succeed and return the connection row
339+
final list = await LocalDb.instance.getConnections(hydrateSecrets: true);
340+
final conn = list.singleWhere((c) => c.id == id);
341+
expect(conn.name, 'MySQL_Faulty');
342+
expect(conn.password, isNull);
343+
});
281344
});
282345
}

‎test/memory_secrets_backend.dart‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,24 @@ final MemorySecretsStorageBackend testMemorySecrets =
88
class MemorySecretsStorageBackend implements SecretsStorageBackend {
99
final Map<String, String> _values = {};
1010

11+
/// When non-null, the next [read] throws this error (then clears the flag).
12+
Object? failNextRead;
13+
1114
/// When non-null, the next [write] throws this error (then clears the flag).
1215
Object? failNextWrite;
1316

1417
/// When non-null, the next [delete] throws this error (then clears the flag).
1518
Object? failNextDelete;
1619

1720
@override
18-
Future<String?> read(String key) async => _values[key];
21+
Future<String?> read(String key) async {
22+
final fail = failNextRead;
23+
if (fail != null) {
24+
failNextRead = null;
25+
throw fail;
26+
}
27+
return _values[key];
28+
}
1929

2030
@override
2131
Future<void> write(String key, String? value) async {
@@ -43,6 +53,7 @@ class MemorySecretsStorageBackend implements SecretsStorageBackend {
4353

4454
void clear() {
4555
_values.clear();
56+
failNextRead = null;
4657
failNextWrite = null;
4758
failNextDelete = null;
4859
}

0 commit comments

Comments
 (0)