Simplify durable storage initialization

This commit is contained in:
Haitao Pan 2026-03-23 18:57:29 +08:00
parent 7978695710
commit 1152b4f8fa
13 changed files with 400 additions and 675 deletions

View File

@ -83,45 +83,24 @@ class FileSecureStorageClient implements SecureStorageClient {
}
}
class MemorySecureStorageClient implements SecureStorageClient {
final Map<String, String> _values = <String, String>{};
@override
Future<void> delete({required String key}) async {
_values.remove(key);
}
@override
Future<String?> read({required String key}) async {
return _values[key];
}
@override
Future<void> write({required String key, required String value}) async {
_values[key] = value;
}
}
class SecretStore {
SecretStore({
Future<String?> Function()? fallbackDirectoryPathResolver,
Future<String?> Function()? databasePathResolver,
Future<String?> Function()? defaultSupportDirectoryPathResolver,
bool allowInMemoryFallback = false,
SecureStorageClient? secureStorage,
bool enableSecureStorage = true,
}) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver,
_databasePathResolver = databasePathResolver,
_defaultSupportDirectoryPathResolver =
defaultSupportDirectoryPathResolver,
_allowInMemoryFallback = allowInMemoryFallback,
_secureStorageOverride = secureStorage,
_enableSecureStorage = enableSecureStorage;
static const Duration _secureStorageTimeout = Duration(seconds: 5);
static const String legacyLocalStateKey = 'xworkmate.local_state.key';
static const String _gatewayTokenKey = 'xworkmate.gateway.token';
static const String _gatewayPasswordKey = 'xworkmate.gateway.password';
static const String _legacyGatewayTokenKey = 'xworkmate.gateway.token';
static const String _legacyGatewayPasswordKey = 'xworkmate.gateway.password';
static const String _gatewayDeviceIdKey = 'xworkmate.gateway.device.id';
static const String _gatewayDevicePublicKeyKey =
'xworkmate.gateway.device.public_key';
@ -132,8 +111,8 @@ class SecretStore {
static const String _aiGatewayApiKeyKey = 'xworkmate.ai_gateway.api_key';
static const Map<String, String> _legacyFallbackFileNames = <String, String>{
_gatewayTokenKey: 'gateway-token.txt',
_gatewayPasswordKey: 'gateway-password.txt',
_legacyGatewayTokenKey: 'gateway-token.txt',
_legacyGatewayPasswordKey: 'gateway-password.txt',
_ollamaCloudApiKeyKey: 'ollama-cloud-api-key.txt',
_vaultTokenKey: 'vault-token.txt',
_aiGatewayApiKeyKey: 'ai-gateway-api-key.txt',
@ -143,7 +122,6 @@ class SecretStore {
final Future<String?> Function()? _fallbackDirectoryPathResolver;
final Future<String?> Function()? _databasePathResolver;
final Future<String?> Function()? _defaultSupportDirectoryPathResolver;
final bool _allowInMemoryFallback;
final SecureStorageClient? _secureStorageOverride;
final bool _enableSecureStorage;
SecureStorageClient? _secureStorage;
@ -154,20 +132,18 @@ class SecretStore {
return;
}
await _ensureDurableStorageLayout();
if (_enableSecureStorage) {
if (_secureStorageOverride != null) {
_secureStorage = _secureStorageOverride;
} else if (_useDebugSecureStorageFallback()) {
_secureStorage = _buildDebugSecureStorageClient();
} else {
try {
_secureStorage = FlutterSecureStorageClient(
const FlutterSecureStorage(),
);
} catch (_) {
_secureStorage = null;
}
if (_secureStorageOverride != null) {
_secureStorage = _secureStorageOverride;
} else if (_enableSecureStorage) {
try {
_secureStorage = FlutterSecureStorageClient(
const FlutterSecureStorage(),
);
} catch (_) {
_secureStorage = FileSecureStorageClient(() => _resolveFallbackDirectory());
}
} else {
_secureStorage = FileSecureStorageClient(() => _resolveFallbackDirectory());
}
_initialized = true;
}
@ -175,9 +151,6 @@ class SecretStore {
Future<void> _ensureDurableStorageLayout() async {
final fallbackDirectory = await _resolveFallbackDirectory();
if (fallbackDirectory == null) {
if (_allowInMemoryFallback) {
return;
}
throw StateError(
'Durable secret storage layout unavailable: cannot resolve fallback directory.',
);
@ -190,19 +163,59 @@ class SecretStore {
}
}
Future<String?> loadGatewayToken() => _readSecure(_gatewayTokenKey);
Future<String?> loadGatewayToken({int? profileIndex}) async {
if (profileIndex != null) {
final scopedValue = await _readSecure(
_gatewayTokenKeyForProfile(profileIndex),
);
if ((scopedValue ?? '').trim().isNotEmpty) {
return scopedValue;
}
}
return _readSecure(_legacyGatewayTokenKey);
}
Future<void> saveGatewayToken(String value) =>
_writeSecure(_gatewayTokenKey, value);
Future<void> saveGatewayToken(String value, {int? profileIndex}) =>
_writeSecure(
profileIndex == null
? _legacyGatewayTokenKey
: _gatewayTokenKeyForProfile(profileIndex),
value,
);
Future<void> clearGatewayToken() => _deleteSecure(_gatewayTokenKey);
Future<void> clearGatewayToken({int? profileIndex}) =>
_deleteSecure(
profileIndex == null
? _legacyGatewayTokenKey
: _gatewayTokenKeyForProfile(profileIndex),
);
Future<String?> loadGatewayPassword() => _readSecure(_gatewayPasswordKey);
Future<String?> loadGatewayPassword({int? profileIndex}) async {
if (profileIndex != null) {
final scopedValue = await _readSecure(
_gatewayPasswordKeyForProfile(profileIndex),
);
if ((scopedValue ?? '').trim().isNotEmpty) {
return scopedValue;
}
}
return _readSecure(_legacyGatewayPasswordKey);
}
Future<void> saveGatewayPassword(String value) =>
_writeSecure(_gatewayPasswordKey, value);
Future<void> saveGatewayPassword(String value, {int? profileIndex}) =>
_writeSecure(
profileIndex == null
? _legacyGatewayPasswordKey
: _gatewayPasswordKeyForProfile(profileIndex),
value,
);
Future<void> clearGatewayPassword() => _deleteSecure(_gatewayPasswordKey);
Future<void> clearGatewayPassword({int? profileIndex}) =>
_deleteSecure(
profileIndex == null
? _legacyGatewayPasswordKey
: _gatewayPasswordKeyForProfile(profileIndex),
);
Future<String?> loadOllamaCloudApiKey() => _readSecure(_ollamaCloudApiKeyKey);
@ -223,8 +236,8 @@ class SecretStore {
Future<Map<String, String>> loadSecureRefs() async {
await initialize();
final gatewayToken = await loadGatewayToken();
final gatewayPassword = await loadGatewayPassword();
final legacyGatewayToken = await _readSecure(_legacyGatewayTokenKey);
final legacyGatewayPassword = await _readSecure(_legacyGatewayPasswordKey);
final deviceIdentity = await loadDeviceIdentity();
final deviceToken = deviceIdentity == null
? null
@ -236,12 +249,24 @@ class SecretStore {
final vaultToken = await loadVaultToken();
final aiGatewayApiKey = await loadAiGatewayApiKey();
final secureRefs = <String, String>{};
if (gatewayToken case final value?) {
if (legacyGatewayToken case final value?) {
secureRefs['gateway_token'] = value;
}
if (gatewayPassword case final value?) {
if (legacyGatewayPassword case final value?) {
secureRefs['gateway_password'] = value;
}
for (var index = 0; index < kGatewayProfileListLength; index += 1) {
final scopedToken = await _readSecure(_gatewayTokenKeyForProfile(index));
final scopedPassword = await _readSecure(
_gatewayPasswordKeyForProfile(index),
);
if (scopedToken case final value?) {
secureRefs[_gatewayTokenRefKey(index)] = value;
}
if (scopedPassword case final value?) {
secureRefs[_gatewayPasswordRefKey(index)] = value;
}
}
if (deviceToken case final value?) {
secureRefs['gateway_device_token_operator'] = value;
}
@ -257,6 +282,12 @@ class SecretStore {
return secureRefs;
}
static String gatewayTokenRefKey(int profileIndex) =>
_gatewayTokenRefKey(profileIndex);
static String gatewayPasswordRefKey(int profileIndex) =>
_gatewayPasswordRefKey(profileIndex);
Future<LocalDeviceIdentity?> loadDeviceIdentity() async {
await initialize();
final deviceId = await _readSecure(_gatewayDeviceIdKey);
@ -334,9 +365,6 @@ class SecretStore {
}
Future<void> dispose() async {
if (_allowInMemoryFallback && _memorySecure.isNotEmpty) {
await _syncMemorySecretsToDurableStore();
}
_secureStorage = null;
_initialized = false;
_memorySecure.clear();
@ -363,32 +391,34 @@ class SecretStore {
if (migrated != null && migrated.trim().isNotEmpty) {
return migrated.trim();
}
return _memorySecure[key];
return null;
}
Future<String?> _readSecureRaw(String key) async {
if (_secureStorage != null) {
try {
final value = await _readSecureValue(_secureStorage!, key);
if (value != null && value.trim().isNotEmpty) {
_memorySecure[key] = value.trim();
return value.trim();
}
} catch (_) {
if (await _promoteToFileSecureStorageFallback()) {
try {
final value = await _readSecureValue(_secureStorage!, key);
if (value != null && value.trim().isNotEmpty) {
_memorySecure[key] = value.trim();
return value.trim();
}
} catch (_) {
// Fall through to in-memory cache.
}
}
final client = await _ensureSecureStorageClient();
try {
final value = await _readSecureValue(client, key);
if (value == null || value.trim().isEmpty) {
return null;
}
final trimmed = value.trim();
_memorySecure[key] = trimmed;
return trimmed;
} catch (_) {
final promoted = await _promoteToFileSecureStorageFallback();
if (!promoted || _secureStorage == null) {
throw StateError(
'Durable secret storage unavailable for $key: failed to read secure value.',
);
}
final value = await _readSecureValue(_secureStorage!, key);
if (value == null || value.trim().isEmpty) {
return null;
}
final trimmed = value.trim();
_memorySecure[key] = trimmed;
return trimmed;
}
return _memorySecure[key];
}
Future<void> _writeSecure(String key, String value) async {
@ -397,22 +427,9 @@ class SecretStore {
if (trimmed.isEmpty) {
return;
}
if (_secureStorage == null &&
!await _promoteToFileSecureStorageFallback()) {
if (_allowInMemoryFallback) {
_memorySecure[key] = trimmed;
unawaited(_syncMemorySecretsToDurableStore());
return;
}
throw StateError(
'Durable secret storage unavailable for $key: secure storage and file fallback both failed.',
);
}
if (_secureStorage == null) {
return;
}
final client = await _ensureSecureStorageClient();
try {
await _writeSecureValue(_secureStorage!, key, trimmed);
await _writeSecureValue(client, key, trimmed);
_memorySecure[key] = trimmed;
final file = await _legacyFallbackFile(key);
if (file != null && await file.exists()) {
@ -421,21 +438,12 @@ class SecretStore {
} catch (_) {
final promoted = await _promoteToFileSecureStorageFallback();
if (promoted && _secureStorage != null) {
try {
await _writeSecureValue(_secureStorage!, key, trimmed);
_memorySecure[key] = trimmed;
final file = await _legacyFallbackFile(key);
if (file != null && await file.exists()) {
await file.delete();
}
return;
} catch (_) {
// Fall through to strict fallback handling below.
}
}
if (_allowInMemoryFallback) {
await _writeSecureValue(_secureStorage!, key, trimmed);
_memorySecure[key] = trimmed;
unawaited(_syncMemorySecretsToDurableStore());
final file = await _legacyFallbackFile(key);
if (file != null && await file.exists()) {
await file.delete();
}
return;
}
throw StateError(
@ -446,12 +454,17 @@ class SecretStore {
Future<void> _deleteSecure(String key) async {
await initialize();
if (_secureStorage != null) {
try {
await _deleteSecureValue(_secureStorage!, key);
} catch (_) {
// Best effort.
final client = await _ensureSecureStorageClient();
try {
await _deleteSecureValue(client, key);
} catch (_) {
final promoted = await _promoteToFileSecureStorageFallback();
if (!promoted || _secureStorage == null) {
throw StateError(
'Durable secret storage unavailable for $key: failed to delete secure value.',
);
}
await _deleteSecureValue(_secureStorage!, key);
}
_memorySecure.remove(key);
final file = await _legacyFallbackFile(key);
@ -502,27 +515,19 @@ class SecretStore {
}
Future<Directory?> _resolveFallbackDirectory() async {
if (_fallbackDirectoryPathResolver != null) {
String? explicit;
try {
explicit = await _fallbackDirectoryPathResolver();
} catch (_) {
// Continue to next fallback candidate.
}
final explicitTrimmed = explicit?.trim() ?? '';
if (explicitTrimmed.isNotEmpty) {
return _requireExistingDirectory(explicitTrimmed);
}
final fallbackRoot = await _resolvePath(_fallbackDirectoryPathResolver);
if (fallbackRoot != null) {
return _ensureDirectory(fallbackRoot);
}
try {
final databasePath = await _databasePathResolver?.call();
final databaseTrimmed = databasePath?.trim() ?? '';
if (databaseTrimmed.isNotEmpty) {
return _requireExistingDirectory(File(databaseTrimmed).parent.path);
}
} catch (_) {
// Continue to next fallback candidate.
final databasePath = await _resolvePath(_databasePathResolver);
if (databasePath != null) {
return _ensureDirectory(File(databasePath).parent.path);
}
final defaultSupportRoot = await _resolvePath(
_defaultSupportDirectoryPathResolver,
);
if (defaultSupportRoot != null) {
return _ensureDirectory('$defaultSupportRoot/gateway-auth');
}
try {
final supportDirectory = await getApplicationSupportDirectory();
@ -530,38 +535,7 @@ class SecretStore {
'${supportDirectory.path}/xworkmate/gateway-auth',
);
} catch (_) {
// Continue below to deterministic fallback.
}
try {
final defaultSupportRoot = await _defaultSupportDirectoryPathResolver
?.call();
final trimmed = defaultSupportRoot?.trim() ?? '';
if (trimmed.isNotEmpty) {
return _ensureDirectory('$trimmed/gateway-auth');
}
} catch (_) {
// Ignore and fall through.
}
return null;
}
Future<void> _syncMemorySecretsToDurableStore() async {
if (_memorySecure.isEmpty) {
return;
}
if (_secureStorage == null || _secureStorage is MemorySecureStorageClient) {
final promoted = await _promoteToFileSecureStorageFallback();
if (!promoted || _secureStorage == null) {
return;
}
}
final snapshot = Map<String, String>.from(_memorySecure);
for (final entry in snapshot.entries) {
try {
await _writeSecureValue(_secureStorage!, entry.key, entry.value);
} catch (_) {
// Best-effort sync for fallback memory mode.
}
return null;
}
}
@ -573,19 +547,8 @@ class SecretStore {
return directory;
}
Future<Directory> _requireExistingDirectory(String path) async {
final directory = Directory(path);
if (!await directory.exists()) {
throw StateError('Durable secret storage path does not exist: $path');
}
return directory;
}
Future<bool> _promoteToFileSecureStorageFallback() async {
if (_secureStorageOverride != null ||
(_databasePathResolver == null &&
_fallbackDirectoryPathResolver == null &&
_defaultSupportDirectoryPathResolver == null)) {
if (_secureStorageOverride != null) {
return false;
}
final directory = await _resolveFallbackDirectory();
@ -624,32 +587,51 @@ class SecretStore {
return future;
}
bool _useDebugSecureStorageFallback() {
var enabled = false;
assert(() {
enabled = true;
return true;
}());
return enabled;
}
SecureStorageClient _buildDebugSecureStorageClient() {
if (_databasePathResolver != null ||
_fallbackDirectoryPathResolver != null ||
_defaultSupportDirectoryPathResolver != null) {
return FileSecureStorageClient(() => _resolveFallbackDirectory());
}
return MemorySecureStorageClient();
}
static String _deviceTokenKey(String deviceId, String role) {
final safeRole = role.trim().isEmpty ? 'operator' : role.trim();
return 'xworkmate.gateway.device_token.$deviceId.$safeRole';
}
static String _gatewayTokenKeyForProfile(int profileIndex) =>
'xworkmate.gateway.profile.$profileIndex.token';
static String _gatewayPasswordKeyForProfile(int profileIndex) =>
'xworkmate.gateway.profile.$profileIndex.password';
static String _gatewayTokenRefKey(int profileIndex) =>
'gateway_token_$profileIndex';
static String _gatewayPasswordRefKey(int profileIndex) =>
'gateway_password_$profileIndex';
static List<int> _base64UrlDecode(String value) {
final normalized = value.replaceAll('-', '+').replaceAll('_', '/');
final padded = normalized + '=' * ((4 - normalized.length % 4) % 4);
return base64.decode(padded);
}
Future<SecureStorageClient> _ensureSecureStorageClient() async {
final client = _secureStorage;
if (client != null) {
return client;
}
final promoted = await _promoteToFileSecureStorageFallback();
if (promoted && _secureStorage != null) {
return _secureStorage!;
}
throw StateError('Durable secret storage unavailable: no persistent secure storage client.');
}
Future<String?> _resolvePath(Future<String?> Function()? resolver) async {
if (resolver == null) {
return null;
}
try {
final resolved = await resolver();
final trimmed = resolved?.trim() ?? '';
return trimmed.isEmpty ? null : trimmed;
} catch (_) {
return null;
}
}
}

View File

@ -14,7 +14,6 @@ class SecureConfigStore {
Future<String?> Function()? fallbackDirectoryPathResolver,
Future<String?> Function()? databasePathResolver,
Future<String?> Function()? defaultSupportDirectoryPathResolver,
bool? allowInMemoryFallback,
SecureConfigDatabaseOpener? databaseOpener,
SecureStorageClient? secureStorage,
bool enableSecureStorage = true,
@ -22,13 +21,11 @@ class SecureConfigStore {
final resolvedDefaultSupportDirectoryPathResolver =
defaultSupportDirectoryPathResolver ??
_resolveDefaultSupportDirectoryPath;
final resolvedAllowInMemoryFallback = allowInMemoryFallback ?? false;
_secretStore = SecretStore(
fallbackDirectoryPathResolver: fallbackDirectoryPathResolver,
databasePathResolver: databasePathResolver,
defaultSupportDirectoryPathResolver:
resolvedDefaultSupportDirectoryPathResolver,
allowInMemoryFallback: resolvedAllowInMemoryFallback,
secureStorage: secureStorage,
enableSecureStorage: enableSecureStorage,
);
@ -37,7 +34,6 @@ class SecureConfigStore {
databasePathResolver: databasePathResolver,
defaultSupportDirectoryPathResolver:
resolvedDefaultSupportDirectoryPathResolver,
allowInMemoryFallback: resolvedAllowInMemoryFallback,
databaseOpener: databaseOpener,
legacyLocalStateKeyLoader: _secretStore.loadLegacyLocalStateKeyBytes,
);
@ -86,19 +82,23 @@ class SecureConfigStore {
return _secretStore.loadSecureRefs();
}
Future<String?> loadGatewayToken() => _secretStore.loadGatewayToken();
Future<String?> loadGatewayToken({int? profileIndex}) =>
_secretStore.loadGatewayToken(profileIndex: profileIndex);
Future<void> saveGatewayToken(String value) =>
_secretStore.saveGatewayToken(value);
Future<void> saveGatewayToken(String value, {int? profileIndex}) =>
_secretStore.saveGatewayToken(value, profileIndex: profileIndex);
Future<void> clearGatewayToken() => _secretStore.clearGatewayToken();
Future<void> clearGatewayToken({int? profileIndex}) =>
_secretStore.clearGatewayToken(profileIndex: profileIndex);
Future<String?> loadGatewayPassword() => _secretStore.loadGatewayPassword();
Future<String?> loadGatewayPassword({int? profileIndex}) =>
_secretStore.loadGatewayPassword(profileIndex: profileIndex);
Future<void> saveGatewayPassword(String value) =>
_secretStore.saveGatewayPassword(value);
Future<void> saveGatewayPassword(String value, {int? profileIndex}) =>
_secretStore.saveGatewayPassword(value, profileIndex: profileIndex);
Future<void> clearGatewayPassword() => _secretStore.clearGatewayPassword();
Future<void> clearGatewayPassword({int? profileIndex}) =>
_secretStore.clearGatewayPassword(profileIndex: profileIndex);
Future<String?> loadOllamaCloudApiKey() =>
_secretStore.loadOllamaCloudApiKey();

View File

@ -13,29 +13,17 @@ import 'runtime_models.dart';
typedef SecureConfigDatabaseOpener =
FutureOr<sqlite.Database?> Function(String resolvedPath);
class _DatabasePathCandidate {
const _DatabasePathCandidate({
required this.path,
required this.createParentDirectory,
});
final String path;
final bool createParentDirectory;
}
class SettingsStore {
SettingsStore({
Future<String?> Function()? fallbackDirectoryPathResolver,
Future<String?> Function()? databasePathResolver,
Future<String?> Function()? defaultSupportDirectoryPathResolver,
bool allowInMemoryFallback = false,
SecureConfigDatabaseOpener? databaseOpener,
Future<List<int>?> Function()? legacyLocalStateKeyLoader,
}) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver,
_databasePathResolver = databasePathResolver,
_defaultSupportDirectoryPathResolver =
defaultSupportDirectoryPathResolver,
_allowInMemoryFallback = allowInMemoryFallback,
_databaseOpener = databaseOpener,
_legacyLocalStateKeyLoader = legacyLocalStateKeyLoader;
@ -55,15 +43,12 @@ class SettingsStore {
final Future<String?> Function()? _fallbackDirectoryPathResolver;
final Future<String?> Function()? _databasePathResolver;
final Future<String?> Function()? _defaultSupportDirectoryPathResolver;
final bool _allowInMemoryFallback;
final SecureConfigDatabaseOpener? _databaseOpener;
final Future<List<int>?> Function()? _legacyLocalStateKeyLoader;
final Cipher _legacyCipher = AesGcm.with256bits();
final Map<String, String> _memoryStore = <String, String>{};
SharedPreferences? _prefs;
sqlite.Database? _database;
String? _resolvedDatabasePath;
bool _usingInMemoryDatabase = false;
bool _initialized = false;
bool _recoveryAttempted = false;
LegacyRecoveryReport _lastRecoveryReport = const LegacyRecoveryReport();
@ -94,7 +79,6 @@ class SettingsStore {
await initialize();
final encoded = snapshot.toJsonString();
await _writeStoredString(settingsKey, encoded);
await _writeDurableStateFile(settingsKey, encoded);
_lastRecoveryReport = const LegacyRecoveryReport();
}
@ -114,7 +98,6 @@ class SettingsStore {
records.map((item) => item.toJson()).toList(growable: false),
);
await _writeStoredString(assistantThreadsKey, encoded);
await _writeDurableStateFile(assistantThreadsKey, encoded);
}
Future<void> clearAssistantLocalState() async {
@ -161,9 +144,6 @@ class SettingsStore {
}
void dispose() {
if (_usingInMemoryDatabase) {
unawaited(_syncInMemoryStoreToDurableStore());
}
final database = _database;
_database = null;
if (database != null) {
@ -176,64 +156,35 @@ class SettingsStore {
_prefs = null;
_initialized = false;
_resolvedDatabasePath = null;
_usingInMemoryDatabase = false;
_memoryStore.clear();
}
Future<void> _initializeDatabase() async {
final candidates = await _resolveDatabasePathCandidates();
for (final candidate in candidates) {
try {
_database = await _openDatabase(candidate);
_resolvedDatabasePath = candidate.path;
_usingInMemoryDatabase = false;
break;
} catch (_) {
_database = null;
}
}
if (_database == null && _allowInMemoryFallback) {
try {
final database = sqlite.sqlite3.openInMemory();
_configureDatabase(database);
_database = database;
_usingInMemoryDatabase = true;
} catch (_) {
_database = null;
_usingInMemoryDatabase = false;
}
}
if (_database == null) {
final candidatePaths = candidates
.map((candidate) => candidate.path)
.toList(growable: false);
final resolvedPath = await _resolveDatabasePath();
try {
_database = await _openDatabase(resolvedPath);
_resolvedDatabasePath = resolvedPath;
} catch (error) {
throw StateError(
'Durable settings storage unavailable: cannot resolve or open $databaseFileName. Candidates: ${candidatePaths.join(', ')}',
'Durable settings storage unavailable: failed to open $resolvedPath. Cause: $error',
);
}
await _migrateLegacyPrefs();
}
Future<sqlite.Database?> _openDatabase(
_DatabasePathCandidate candidate,
) async {
final resolvedPath = candidate.path;
Future<sqlite.Database> _openDatabase(String resolvedPath) async {
if (_databaseOpener != null) {
final database = await _databaseOpener(resolvedPath);
if (database != null) {
_configureDatabase(database);
if (database == null) {
throw StateError(
'Durable settings storage unavailable: database opener returned null for $resolvedPath.',
);
}
_configureDatabase(database);
return database;
}
final file = File(resolvedPath);
if (!await file.parent.exists()) {
if (candidate.createParentDirectory) {
await file.parent.create(recursive: true);
} else {
throw StateError(
'Durable settings database directory does not exist: ${file.parent.path}',
);
}
await file.parent.create(recursive: true);
}
final database = sqlite.sqlite3.open(file.path);
_configureDatabase(database);
@ -273,9 +224,6 @@ class SettingsStore {
);
if (existing.isEmpty) {
await _writeStoredString(key, legacyValue);
if (_durableStateFileNames.containsKey(key)) {
await _writeDurableStateFile(key, legacyValue);
}
}
await _prefs!.remove(key);
}
@ -328,18 +276,6 @@ class SettingsStore {
.toList(growable: false),
),
);
await _writeDurableStateFile(
settingsKey,
recoveredSettings.toJsonString(),
);
await _writeDurableStateFile(
assistantThreadsKey,
jsonEncode(
recoveredThreads
.map((item) => item.toJson())
.toList(growable: false),
),
);
return LegacyRecoveryReport(
status: LegacyRecoveryStatus.migrated,
sourcePath: source.sourcePath,
@ -364,40 +300,8 @@ class SettingsStore {
}
Future<List<String>> _legacyCandidateDirectories() async {
final results = <String>{};
final databasePath = await _resolveDatabasePath();
final fallbackRoot = await _fallbackDirectoryPathResolver?.call();
final hasExplicitPaths =
_databasePathResolver != null || _fallbackDirectoryPathResolver != null;
String? defaultSupportRoot;
String? supportPath;
if (!hasExplicitPaths) {
defaultSupportRoot = await _defaultSupportDirectoryPathResolver?.call();
try {
supportPath = (await getApplicationSupportDirectory()).path;
} catch (_) {
supportPath = null;
}
}
void addPath(String? path) {
final trimmed = path?.trim() ?? '';
if (trimmed.isEmpty) {
return;
}
results.add(trimmed);
}
if (databasePath != null && databasePath.trim().isNotEmpty) {
final directory = File(databasePath).parent.path;
addPath(directory);
}
addPath(fallbackRoot);
addPath(fallbackRoot == null ? null : '$fallbackRoot/xworkmate');
addPath(defaultSupportRoot);
addPath(supportPath);
addPath(supportPath == null ? null : '$supportPath/xworkmate');
return results.toList(growable: false);
return <String>[File(databasePath).parent.path];
}
Future<_LegacySourceResult> _readLegacySource(String directoryPath) async {
@ -609,121 +513,54 @@ class SettingsStore {
}
}
Future<List<_DatabasePathCandidate>> _resolveDatabasePathCandidates() async {
final candidates = <_DatabasePathCandidate>[];
final seen = <String>{};
void addPath(String? path, {required bool createParentDirectory}) {
final trimmed = path?.trim() ?? '';
if (trimmed.isNotEmpty && seen.add(trimmed)) {
candidates.add(
_DatabasePathCandidate(
path: trimmed,
createParentDirectory: createParentDirectory,
),
);
}
}
if (_databasePathResolver != null) {
try {
final resolvedPath = await _databasePathResolver();
final trimmedPath = resolvedPath?.trim() ?? '';
if (trimmedPath.isNotEmpty) {
addPath(trimmedPath, createParentDirectory: false);
return candidates;
}
} catch (_) {
// Fall through to default locations.
}
}
try {
final supportDirectory = await getApplicationSupportDirectory();
addPath(
'${supportDirectory.path}/xworkmate/$databaseFileName',
createParentDirectory: true,
);
} catch (_) {
// Continue below to deterministic fallbacks.
}
try {
final fallbackRoot = await _fallbackDirectoryPathResolver?.call();
final trimmedFallbackRoot = fallbackRoot?.trim() ?? '';
if (trimmedFallbackRoot.isNotEmpty) {
addPath(
'$trimmedFallbackRoot/$databaseFileName',
createParentDirectory: true,
);
}
} catch (_) {
// Continue to default support directory fallback.
}
try {
final defaultSupportRoot = await _defaultSupportDirectoryPathResolver
?.call();
final trimmedDefaultSupportRoot = defaultSupportRoot?.trim() ?? '';
if (trimmedDefaultSupportRoot.isNotEmpty) {
addPath(
'$trimmedDefaultSupportRoot/$databaseFileName',
createParentDirectory: true,
);
}
} catch (_) {
// Ignore and fall through.
}
return candidates;
}
Future<String?> _resolveDatabasePath() async {
Future<String> _resolveDatabasePath() async {
final resolved = _resolvedDatabasePath?.trim() ?? '';
if (resolved.isNotEmpty) {
return resolved;
}
final candidates = await _resolveDatabasePathCandidates();
if (candidates.isEmpty) {
return null;
final explicitDatabasePath = await _resolvePath(_databasePathResolver);
if (explicitDatabasePath != null) {
return explicitDatabasePath;
}
final fallbackRoot = await _resolvePath(_fallbackDirectoryPathResolver);
if (fallbackRoot != null) {
return '$fallbackRoot/$databaseFileName';
}
final defaultSupportRoot = await _resolvePath(
_defaultSupportDirectoryPathResolver,
);
if (defaultSupportRoot != null) {
return '$defaultSupportRoot/$databaseFileName';
}
try {
final supportDirectory = await getApplicationSupportDirectory();
return '${supportDirectory.path}/xworkmate/$databaseFileName';
} catch (_) {
throw StateError(
'Durable settings storage unavailable: cannot resolve $databaseFileName.',
);
}
return candidates.first.path;
}
Future<String?> _readStoredString(String key) async {
final memoryValue = _memoryStore[key];
if (memoryValue != null) {
return memoryValue;
}
if (_database != null) {
try {
final result = _database!.select(
'SELECT value FROM $databaseTableName WHERE storage_key = ? LIMIT 1',
<Object?>[key],
);
if (result.isNotEmpty) {
final value = result.first['value'];
if (value is String && value.trim().isNotEmpty) {
return value;
}
}
} catch (_) {
// Fall through to durable fallback.
}
}
final durable = await _readDurableStateFile(key);
if (durable != null) {
return durable;
if (_database == null) {
throw StateError('Durable settings storage unavailable: database not initialized.');
}
try {
final prefValue = _prefs?.getString(key);
if (prefValue != null && prefValue.trim().isNotEmpty) {
return prefValue;
final result = _database!.select(
'SELECT value FROM $databaseTableName WHERE storage_key = ? LIMIT 1',
<Object?>[key],
);
if (result.isEmpty) {
return null;
}
final value = result.first['value'];
return value is String && value.trim().isNotEmpty ? value : null;
} catch (_) {
// Ignore.
throw StateError(
'Durable settings storage unavailable: failed to read $key from $_resolvedDatabasePath.',
);
}
return null;
}
Future<void> _writeStoredString(String key, String value) async {
@ -731,43 +568,40 @@ class SettingsStore {
if (trimmed.isEmpty) {
return;
}
_memoryStore[key] = trimmed;
if (_database != null) {
try {
_database!.execute(
'''
INSERT INTO $databaseTableName (storage_key, value, updated_at_ms)
VALUES (?, ?, ?)
ON CONFLICT(storage_key) DO UPDATE SET
value = excluded.value,
updated_at_ms = excluded.updated_at_ms
''',
<Object?>[key, trimmed, DateTime.now().millisecondsSinceEpoch],
);
if (_usingInMemoryDatabase) {
await _syncInMemoryStoreToDurableStore();
}
return;
} catch (_) {
// Fall through to durable file fallback.
}
if (_database == null) {
throw StateError('Durable settings storage unavailable: database not initialized.');
}
if (_usingInMemoryDatabase) {
await _syncInMemoryStoreToDurableStore();
try {
_database!.execute(
'''
INSERT INTO $databaseTableName (storage_key, value, updated_at_ms)
VALUES (?, ?, ?)
ON CONFLICT(storage_key) DO UPDATE SET
value = excluded.value,
updated_at_ms = excluded.updated_at_ms
''',
<Object?>[key, trimmed, DateTime.now().millisecondsSinceEpoch],
);
} catch (_) {
throw StateError(
'Durable settings storage unavailable: failed to write $key to $_resolvedDatabasePath.',
);
}
}
Future<void> _deleteStoredString(String key) async {
_memoryStore.remove(key);
if (_database != null) {
try {
_database!.execute(
'DELETE FROM $databaseTableName WHERE storage_key = ?',
<Object?>[key],
);
} catch (_) {
// Ignore.
}
if (_database == null) {
throw StateError('Durable settings storage unavailable: database not initialized.');
}
try {
_database!.execute(
'DELETE FROM $databaseTableName WHERE storage_key = ?',
<Object?>[key],
);
} catch (_) {
throw StateError(
'Durable settings storage unavailable: failed to delete $key from $_resolvedDatabasePath.',
);
}
try {
await _prefs?.remove(key);
@ -782,108 +616,10 @@ class SettingsStore {
return null;
}
final databasePath = await _resolveDatabasePath();
if (databasePath == null || databasePath.trim().isEmpty) {
return null;
}
final directory = File(databasePath).parent;
if (!await directory.exists()) {
await directory.create(recursive: true);
}
return File('${directory.path}/$fileName');
}
Future<File?> _durableStateFileForPath(
String key,
String databasePath,
) async {
final fileName = _durableStateFileNames[key];
if (fileName == null) {
return null;
}
final directory = File(databasePath).parent;
if (!await directory.exists()) {
await directory.create(recursive: true);
}
return File('${directory.path}/$fileName');
}
Future<String?> _readDurableStateFile(String key) async {
final file = await _durableStateFile(key);
if (file == null || !await file.exists()) {
return null;
}
final value = await file.readAsString();
return value.trim().isEmpty ? null : value;
}
Future<void> _writeDurableStateFile(String key, String value) async {
final file = await _durableStateFile(key);
if (file == null) {
return;
}
await file.writeAsString(value, flush: true);
}
Future<void> _syncInMemoryStoreToDurableStore() async {
if (!_usingInMemoryDatabase || _memoryStore.isEmpty) {
return;
}
final candidates = await _resolveDatabasePathCandidates();
if (candidates.isEmpty) {
return;
}
for (final candidate in candidates) {
sqlite.Database? durableDatabase;
try {
durableDatabase = await _openDatabase(candidate);
if (durableDatabase == null) {
continue;
}
final updatedAtMs = DateTime.now().millisecondsSinceEpoch;
for (final entry in _memoryStore.entries) {
durableDatabase.execute(
'''
INSERT INTO $databaseTableName (storage_key, value, updated_at_ms)
VALUES (?, ?, ?)
ON CONFLICT(storage_key) DO UPDATE SET
value = excluded.value,
updated_at_ms = excluded.updated_at_ms
''',
<Object?>[entry.key, entry.value, updatedAtMs],
);
final durableFile = await _durableStateFileForPath(
entry.key,
candidate.path,
);
if (durableFile != null) {
await durableFile.writeAsString(entry.value, flush: true);
}
}
final previousDatabase = _database;
_database = durableDatabase;
_resolvedDatabasePath = candidate.path;
_usingInMemoryDatabase = false;
if (previousDatabase != null &&
!identical(previousDatabase, _database)) {
try {
previousDatabase.dispose();
} catch (_) {
// Ignore close errors during promotion.
}
}
return;
} catch (_) {
if (durableDatabase != null) {
try {
durableDatabase.dispose();
} catch (_) {
// Ignore close errors while probing candidates.
}
}
}
}
}
Future<void> _deleteDurableStateFile(String key) async {
final file = await _durableStateFile(key);
if (file == null || !await file.exists()) {
@ -894,9 +630,6 @@ class SettingsStore {
Future<void> _deleteLegacyBackupFile() async {
final databasePath = await _resolveDatabasePath();
if (databasePath == null || databasePath.trim().isEmpty) {
return;
}
final file = File('${File(databasePath).parent.path}/$stateBackupFileName');
if (await file.exists()) {
await file.delete();
@ -971,6 +704,19 @@ class SettingsStore {
json.containsKey('accountUsername') ||
json.containsKey('assistantExecutionTarget');
}
Future<String?> _resolvePath(Future<String?> Function()? resolver) async {
if (resolver == null) {
return null;
}
try {
final resolved = await resolver();
final trimmed = resolved?.trim() ?? '';
return trimmed.isEmpty ? null : trimmed;
} catch (_) {
return null;
}
}
}
class _LegacySourceResult {

View File

@ -22,11 +22,13 @@ import 'package:xworkmate/theme/app_theme.dart';
import '../test_support.dart';
class _FakeGatewayRuntime extends GatewayRuntime {
_FakeGatewayRuntime()
: super(
store: SecureConfigStore(),
identityStore: DeviceIdentityStore(SecureConfigStore()),
);
factory _FakeGatewayRuntime() {
final store = createIsolatedTestStore();
return _FakeGatewayRuntime._(store);
}
_FakeGatewayRuntime._(SecureConfigStore store)
: super(store: store, identityStore: DeviceIdentityStore(store));
@override
Future<void> connectProfile(

View File

@ -7,14 +7,17 @@ import 'package:xworkmate/runtime/device_identity_store.dart';
import 'package:xworkmate/runtime/gateway_runtime.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import '../test_support.dart';
// Mock GatewayRuntime for testing
class MockGatewayRuntime extends GatewayRuntime {
MockGatewayRuntime()
: super(
store: SecureConfigStore(),
identityStore: DeviceIdentityStore(SecureConfigStore()),
);
factory MockGatewayRuntime() {
final store = createIsolatedTestStore();
return MockGatewayRuntime._(store);
}
MockGatewayRuntime._(SecureConfigStore store)
: super(store: store, identityStore: DeviceIdentityStore(store));
final Map<String, dynamic> _responses = {};
final List<Map<String, dynamic>> _requests = [];

View File

@ -13,16 +13,19 @@ import 'package:xworkmate/runtime/gateway_runtime.dart';
import 'package:xworkmate/runtime/runtime_coordinator.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import '../test_support.dart';
const String _manualCodexBridgeSkipReason =
'Disabled by default: reserved for manual validation with a dedicated Codex environment only.';
class _FakeGatewayRuntime extends GatewayRuntime {
_FakeGatewayRuntime({required bool connected})
: super(
store: SecureConfigStore(),
identityStore: DeviceIdentityStore(SecureConfigStore()),
) {
factory _FakeGatewayRuntime({required bool connected}) {
final store = createIsolatedTestStore();
return _FakeGatewayRuntime._(store, connected: connected);
}
_FakeGatewayRuntime._(SecureConfigStore store, {required bool connected})
: super(store: store, identityStore: DeviceIdentityStore(store)) {
setConnected(connected);
}
@ -134,7 +137,7 @@ void main() {
'AppController enables external Codex bridge and registers to gateway',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore();
final store = createIsolatedTestStore();
final gateway = _FakeGatewayRuntime(connected: true);
final codex = _FakeCodexRuntime();
final coordinator = RuntimeCoordinator(
@ -201,7 +204,7 @@ void main() {
'AppController keeps bridge running when gateway registration is unavailable',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore();
final store = createIsolatedTestStore();
final gateway = _FakeGatewayRuntime(connected: false);
final codex = _FakeCodexRuntime();
final coordinator = RuntimeCoordinator(
@ -258,7 +261,7 @@ void main() {
'AppController preserves built-in mode and does not require external codex binary',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore();
final store = createIsolatedTestStore();
final gateway = _FakeGatewayRuntime(connected: false);
final codex = _FakeCodexRuntime();
final coordinator = RuntimeCoordinator(

View File

@ -108,12 +108,12 @@ class _ThrowingSecureConfigStore extends SecureConfigStore {
: super(enableSecureStorage: false);
@override
Future<String?> loadGatewayToken() async {
Future<String?> loadGatewayToken({int? profileIndex}) async {
throw StateError('main store gateway token should not be used');
}
@override
Future<String?> loadGatewayPassword() async {
Future<String?> loadGatewayPassword({int? profileIndex}) async {
throw StateError('main store gateway password should not be used');
}

View File

@ -9,13 +9,16 @@ import 'package:xworkmate/runtime/gateway_runtime.dart';
import 'package:xworkmate/runtime/runtime_coordinator.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import '../test_support.dart';
class _FakeGatewayRuntime extends GatewayRuntime {
_FakeGatewayRuntime()
: super(
store: SecureConfigStore(),
identityStore: DeviceIdentityStore(SecureConfigStore()),
);
factory _FakeGatewayRuntime() {
final store = createIsolatedTestStore();
return _FakeGatewayRuntime._(store);
}
_FakeGatewayRuntime._(SecureConfigStore store)
: super(store: store, identityStore: DeviceIdentityStore(store));
@override
Future<void> connectProfile(

View File

@ -10,14 +10,14 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/runtime/device_identity_store.dart';
import 'package:xworkmate/runtime/gateway_runtime.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import '../test_support.dart';
void main() {
test(
'GatewayRuntime uses explicit shared token override for the initial connect handshake',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore();
final store = createIsolatedTestStore();
final runtime = GatewayRuntime(
store: store,
identityStore: DeviceIdentityStore(store),
@ -64,7 +64,7 @@ void main() {
'GatewayRuntime sends stored operator device token using auth.deviceToken',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore();
final store = createIsolatedTestStore();
final identityStore = DeviceIdentityStore(store);
final identity = await identityStore.loadOrCreate();
await store.saveDeviceToken(
@ -109,7 +109,7 @@ void main() {
'GatewayRuntime parses device pairing state and syncs rotated local role tokens',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore();
final store = createIsolatedTestStore();
final identityStore = DeviceIdentityStore(store);
final identity = await identityStore.loadOrCreate();
final runtime = GatewayRuntime(
@ -170,7 +170,7 @@ void main() {
'GatewayRuntime does not auto reconnect after non-retryable pairing errors',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore();
final store = createIsolatedTestStore();
final runtime = GatewayRuntime(
store: store,
identityStore: DeviceIdentityStore(store),
@ -217,7 +217,7 @@ void main() {
'GatewayRuntime clears a stale stored device token after NOT_PAIRED',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore();
final store = createIsolatedTestStore();
final identityStore = DeviceIdentityStore(store);
final identity = await identityStore.loadOrCreate();
await store.saveDeviceToken(

View File

@ -9,11 +9,12 @@ import 'package:xworkmate/runtime/gateway_runtime.dart';
import 'package:xworkmate/runtime/device_identity_store.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import '../test_support.dart';
// Mock GatewayRuntime for testing
class MockGatewayRuntime extends GatewayRuntime {
factory MockGatewayRuntime() {
final store = SecureConfigStore();
final store = createIsolatedTestStore();
return MockGatewayRuntime._(store);
}

View File

@ -11,13 +11,16 @@ import 'package:xworkmate/runtime/mode_switcher.dart';
import 'package:xworkmate/runtime/runtime_coordinator.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import '../test_support.dart';
class _FakeGatewayRuntime extends GatewayRuntime {
_FakeGatewayRuntime()
: super(
store: SecureConfigStore(),
identityStore: DeviceIdentityStore(SecureConfigStore()),
);
factory _FakeGatewayRuntime() {
final store = createIsolatedTestStore();
return _FakeGatewayRuntime._(store);
}
_FakeGatewayRuntime._(SecureConfigStore store)
: super(store: store, identityStore: DeviceIdentityStore(store));
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial();
final StreamController<GatewayPushEvent> _events =

View File

@ -180,7 +180,7 @@ void main() {
);
test(
'SecureConfigStore throws when explicit settings directory does not exist',
'SecureConfigStore auto-creates an explicit settings directory on first install',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
@ -191,33 +191,26 @@ void main() {
await tempDirectory.delete(recursive: true);
}
});
final existingSecretsDirectory = Directory(
'${tempDirectory.path}/secrets',
);
final existingSecretsDirectory = Directory('${tempDirectory.path}/secrets');
await existingSecretsDirectory.create(recursive: true);
final explicitSettingsPath =
'${tempDirectory.path}/settings/${SettingsStore.databaseFileName}';
final store = SecureConfigStore(
databasePathResolver: () async =>
'${tempDirectory.path}/settings/${SettingsStore.databaseFileName}',
fallbackDirectoryPathResolver: () async =>
existingSecretsDirectory.path,
databasePathResolver: () async => explicitSettingsPath,
fallbackDirectoryPathResolver: () async => existingSecretsDirectory.path,
);
await expectLater(
store.loadSettingsSnapshot(),
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
contains('Durable settings storage unavailable'),
),
),
);
final snapshot = await store.loadSettingsSnapshot();
expect(snapshot.accountUsername, SettingsSnapshot.defaults().accountUsername);
expect(await Directory('${tempDirectory.path}/settings').exists(), isTrue);
expect(await File(explicitSettingsPath).exists(), isTrue);
},
);
test(
'SecureConfigStore throws when explicit secrets directory does not exist',
'SecureConfigStore auto-creates an explicit secrets directory on first install',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
@ -240,16 +233,10 @@ void main() {
'${tempDirectory.path}/secrets',
);
await expectLater(
store.saveGatewayToken('token-secret'),
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
contains('Durable secret storage path does not exist'),
),
),
);
await store.saveGatewayToken('token-secret');
expect(await Directory('${tempDirectory.path}/secrets').exists(), isTrue);
expect(await store.loadGatewayToken(), 'token-secret');
},
);
@ -269,7 +256,6 @@ void main() {
'${tempDirectory.path}/plus.svc.xworkmate/xworkmate';
final firstStore = SecureConfigStore(
allowInMemoryFallback: false,
databasePathResolver: () async =>
throw StateError('primary unavailable'),
fallbackDirectoryPathResolver: () async =>
@ -283,7 +269,6 @@ void main() {
await firstStore.saveGatewayToken('fallback-token');
final secondStore = SecureConfigStore(
allowInMemoryFallback: false,
databasePathResolver: () async =>
throw StateError('primary unavailable'),
fallbackDirectoryPathResolver: () async =>
@ -359,7 +344,7 @@ void main() {
);
test(
'SecureConfigStore persists plain local settings and assistant threads when sqlite is unavailable',
'SecureConfigStore fails fast and keeps legacy files untouched when sqlite is unavailable',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
@ -371,63 +356,29 @@ void main() {
}
});
final databasePath = '${tempDirectory.path}/settings.sqlite3';
final snapshot = SettingsSnapshot.defaults().copyWith(
accountUsername: 'local-user',
assistantLastSessionKey: 'draft:local-1',
);
const records = <AssistantThreadRecord>[
AssistantThreadRecord(
sessionKey: 'draft:local-1',
title: '本地线程',
archived: false,
executionTarget: AssistantExecutionTarget.local,
messageViewMode: AssistantMessageViewMode.rendered,
updatedAtMs: 1700000000000,
messages: <GatewayChatMessage>[
GatewayChatMessage(
id: 'assistant-1',
role: 'assistant',
text: 'plain local message',
timestampMs: 1700000001000,
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
],
),
];
final firstStore = SecureConfigStore(
allowInMemoryFallback: true,
databasePathResolver: () async => databasePath,
fallbackDirectoryPathResolver: () async => tempDirectory.path,
databaseOpener: (_) => throw StateError('sqlite unavailable'),
);
await firstStore.saveSettingsSnapshot(snapshot);
await firstStore.saveAssistantThreadRecords(records);
final settingsFile = File('${tempDirectory.path}/settings-snapshot.json');
final threadsFile = File('${tempDirectory.path}/assistant-threads.json');
expect(await settingsFile.exists(), isTrue);
expect(await threadsFile.exists(), isTrue);
expect(await settingsFile.readAsString(), contains('local-user'));
expect(await threadsFile.readAsString(), contains('plain local message'));
await settingsFile.writeAsString('{"accountUsername":"local-user"}');
await threadsFile.writeAsString('[]');
final secondStore = SecureConfigStore(
allowInMemoryFallback: true,
final firstStore = SecureConfigStore(
databasePathResolver: () async => databasePath,
fallbackDirectoryPathResolver: () async => tempDirectory.path,
databaseOpener: (_) => throw StateError('sqlite unavailable'),
);
final loadedSnapshot = await secondStore.loadSettingsSnapshot();
final loadedThreads = await secondStore.loadAssistantThreadRecords();
expect(loadedSnapshot.accountUsername, 'local-user');
expect(loadedSnapshot.assistantLastSessionKey, 'draft:local-1');
expect(loadedThreads, hasLength(1));
expect(loadedThreads.single.messages.single.text, 'plain local message');
await expectLater(
firstStore.loadSettingsSnapshot(),
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
contains('sqlite unavailable'),
),
),
);
expect(await settingsFile.exists(), isTrue);
expect(await threadsFile.exists(), isTrue);
},
);
@ -894,7 +845,7 @@ void main() {
);
test(
'SecureConfigStore restores assistant state from durable files when sqlite entries are missing',
'SecureConfigStore restart keeps database state and legacy session files untouched',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
@ -940,10 +891,10 @@ void main() {
await store.saveSettingsSnapshot(snapshot);
await store.saveAssistantThreadRecords(records);
final database = sqlite.sqlite3.open(databasePath);
addTearDown(database.dispose);
database.execute('DELETE FROM ${SettingsStore.databaseTableName}');
final settingsFile = File('${tempDirectory.path}/settings-snapshot.json');
final threadsFile = File('${tempDirectory.path}/assistant-threads.json');
await settingsFile.writeAsString('legacy-settings-snapshot', flush: true);
await threadsFile.writeAsString('legacy-assistant-threads', flush: true);
final recoveredStore = SecureConfigStore(
databasePathResolver: () async => databasePath,
@ -958,6 +909,8 @@ void main() {
expect(recoveredRecords, hasLength(1));
expect(recoveredRecords.first.sessionKey, 'draft:backup-1');
expect(recoveredRecords.first.messages.single.text, 'backup message');
expect(await settingsFile.readAsString(), 'legacy-settings-snapshot');
expect(await threadsFile.readAsString(), 'legacy-assistant-threads');
},
);
@ -1060,7 +1013,19 @@ void main() {
'SecureConfigStore clears gateway token without touching snapshot',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore();
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-config-store-clear-token-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
await tempDirectory.delete(recursive: true);
}
});
final store = SecureConfigStore(
databasePathResolver: () async =>
'${tempDirectory.path}/${SettingsStore.databaseFileName}',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
);
await store.saveGatewayToken('token-secret');
expect(await store.loadGatewayToken(), 'token-secret');

View File

@ -10,6 +10,23 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
import 'package:xworkmate/theme/app_theme.dart';
import 'package:xworkmate/runtime/desktop_platform_service.dart';
SecureConfigStore createIsolatedTestStore({bool enableSecureStorage = true}) {
final testRoot = Directory.systemTemp.createTempSync(
'xworkmate-store-test-',
);
addTearDown(() async {
if (await testRoot.exists()) {
await testRoot.delete(recursive: true);
}
});
return SecureConfigStore(
enableSecureStorage: enableSecureStorage,
databasePathResolver: () async =>
'${testRoot.path}/${SettingsStore.databaseFileName}',
fallbackDirectoryPathResolver: () async => testRoot.path,
);
}
Future<AppController> createTestController(
WidgetTester tester, {
DesktopPlatformService? desktopPlatformService,