Simplify durable storage initialization
This commit is contained in:
parent
7978695710
commit
1152b4f8fa
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user