Remove legacy persistence implementation
This commit is contained in:
parent
e1ea5a5142
commit
c7101bfeb8
@ -1,10 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'runtime_models.dart';
|
||||
|
||||
abstract class SecureStorageClient {
|
||||
@ -15,74 +8,6 @@ abstract class SecureStorageClient {
|
||||
Future<void> delete({required String key});
|
||||
}
|
||||
|
||||
class FlutterSecureStorageClient implements SecureStorageClient {
|
||||
const FlutterSecureStorageClient(this._storage);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
@override
|
||||
Future<String?> read({required String key}) {
|
||||
return _storage.read(key: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String value}) {
|
||||
return _storage.write(key: key, value: value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key}) {
|
||||
return _storage.delete(key: key);
|
||||
}
|
||||
}
|
||||
|
||||
class FileSecureStorageClient implements SecureStorageClient {
|
||||
FileSecureStorageClient(this._directoryResolver);
|
||||
|
||||
final Future<Directory?> Function() _directoryResolver;
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key}) async {
|
||||
final file = await _fileForKey(key);
|
||||
if (file == null || !await file.exists()) {
|
||||
return;
|
||||
}
|
||||
await file.delete();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> read({required String key}) async {
|
||||
final file = await _fileForKey(key);
|
||||
if (file == null || !await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
final value = (await file.readAsString()).trim();
|
||||
return value.isEmpty ? null : value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String value}) async {
|
||||
final file = await _fileForKey(key);
|
||||
if (file == null) {
|
||||
throw StateError('Secure storage directory unavailable for $key');
|
||||
}
|
||||
await file.writeAsString(value, flush: true);
|
||||
}
|
||||
|
||||
Future<File?> _fileForKey(String key) async {
|
||||
final directory = await _directoryResolver();
|
||||
if (directory == null) {
|
||||
return null;
|
||||
}
|
||||
final secureDirectory = Directory('${directory.path}/secure-storage');
|
||||
if (!await secureDirectory.exists()) {
|
||||
await secureDirectory.create(recursive: true);
|
||||
}
|
||||
final safeKey = base64Url.encode(utf8.encode(key)).replaceAll('=', '');
|
||||
return File('${secureDirectory.path}/$safeKey.txt');
|
||||
}
|
||||
}
|
||||
|
||||
class SecretStore {
|
||||
SecretStore({
|
||||
Future<String?> Function()? fallbackDirectoryPathResolver,
|
||||
@ -90,222 +15,94 @@ class SecretStore {
|
||||
Future<String?> Function()? defaultSupportDirectoryPathResolver,
|
||||
SecureStorageClient? secureStorage,
|
||||
bool enableSecureStorage = true,
|
||||
}) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver,
|
||||
_databasePathResolver = databasePathResolver,
|
||||
_defaultSupportDirectoryPathResolver =
|
||||
defaultSupportDirectoryPathResolver,
|
||||
_secureStorageOverride = secureStorage,
|
||||
_enableSecureStorage = enableSecureStorage;
|
||||
});
|
||||
|
||||
static const Duration _secureStorageTimeout = Duration(seconds: 5);
|
||||
static const String legacyLocalStateKey = 'xworkmate.local_state.key';
|
||||
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';
|
||||
static const String _gatewayDevicePrivateKeyKey =
|
||||
'xworkmate.gateway.device.private_key';
|
||||
static const String _ollamaCloudApiKeyKey = 'xworkmate.ollama.cloud.api_key';
|
||||
static const String _vaultTokenKey = 'xworkmate.vault.token';
|
||||
static const String _aiGatewayApiKeyKey = 'xworkmate.ai_gateway.api_key';
|
||||
|
||||
static const Map<String, String> _legacyFallbackFileNames = <String, String>{
|
||||
_legacyGatewayTokenKey: 'gateway-token.txt',
|
||||
_legacyGatewayPasswordKey: 'gateway-password.txt',
|
||||
_ollamaCloudApiKeyKey: 'ollama-cloud-api-key.txt',
|
||||
_vaultTokenKey: 'vault-token.txt',
|
||||
_aiGatewayApiKeyKey: 'ai-gateway-api-key.txt',
|
||||
};
|
||||
Future<void> initialize() async {}
|
||||
|
||||
final Map<String, String> _memorySecure = <String, String>{};
|
||||
final Future<String?> Function()? _fallbackDirectoryPathResolver;
|
||||
final Future<String?> Function()? _databasePathResolver;
|
||||
final Future<String?> Function()? _defaultSupportDirectoryPathResolver;
|
||||
final SecureStorageClient? _secureStorageOverride;
|
||||
final bool _enableSecureStorage;
|
||||
SecureStorageClient? _secureStorage;
|
||||
bool _initialized = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) {
|
||||
return;
|
||||
}
|
||||
await _ensureDurableStorageLayout();
|
||||
if (_secureStorageOverride != null) {
|
||||
_secureStorage = _secureStorageOverride;
|
||||
} else if (_enableSecureStorage) {
|
||||
try {
|
||||
_secureStorage = FlutterSecureStorageClient(
|
||||
const FlutterSecureStorage(),
|
||||
);
|
||||
} catch (_) {
|
||||
_secureStorage = FileSecureStorageClient(
|
||||
() => _resolveFallbackDirectory(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
_secureStorage = FileSecureStorageClient(
|
||||
() => _resolveFallbackDirectory(),
|
||||
);
|
||||
}
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<void> _ensureDurableStorageLayout() async {
|
||||
final fallbackDirectory = await _resolveFallbackDirectory();
|
||||
if (fallbackDirectory == null) {
|
||||
throw StateError(
|
||||
'Durable secret storage layout unavailable: cannot resolve fallback directory.',
|
||||
);
|
||||
}
|
||||
final secureStorageDirectory = Directory(
|
||||
'${fallbackDirectory.path}/secure-storage',
|
||||
Future<String?> loadGatewayToken({int? profileIndex}) {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
if (!await secureStorageDirectory.exists()) {
|
||||
await secureStorageDirectory.create(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> loadGatewayToken({int? profileIndex}) async {
|
||||
if (profileIndex != null) {
|
||||
final scopedValue = await _readSecure(
|
||||
_gatewayTokenKeyForProfile(profileIndex),
|
||||
);
|
||||
if ((scopedValue ?? '').trim().isNotEmpty) {
|
||||
return scopedValue;
|
||||
}
|
||||
return _readSecure(_legacyGatewayTokenKey);
|
||||
}
|
||||
final legacyValue = await _readSecure(_legacyGatewayTokenKey);
|
||||
if ((legacyValue ?? '').trim().isNotEmpty) {
|
||||
return legacyValue;
|
||||
}
|
||||
for (final index in _gatewayProfileFallbackOrder) {
|
||||
final scopedValue = await _readSecure(_gatewayTokenKeyForProfile(index));
|
||||
if ((scopedValue ?? '').trim().isNotEmpty) {
|
||||
return scopedValue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
Future<void> saveGatewayToken(String value, {int? profileIndex}) {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveGatewayToken(String value, {int? profileIndex}) =>
|
||||
_writeSecure(
|
||||
profileIndex == null
|
||||
? _legacyGatewayTokenKey
|
||||
: _gatewayTokenKeyForProfile(profileIndex),
|
||||
value,
|
||||
);
|
||||
|
||||
Future<void> clearGatewayToken({int? profileIndex}) => _deleteSecure(
|
||||
profileIndex == null
|
||||
? _legacyGatewayTokenKey
|
||||
: _gatewayTokenKeyForProfile(profileIndex),
|
||||
);
|
||||
|
||||
Future<String?> loadGatewayPassword({int? profileIndex}) async {
|
||||
if (profileIndex != null) {
|
||||
final scopedValue = await _readSecure(
|
||||
_gatewayPasswordKeyForProfile(profileIndex),
|
||||
);
|
||||
if ((scopedValue ?? '').trim().isNotEmpty) {
|
||||
return scopedValue;
|
||||
}
|
||||
return _readSecure(_legacyGatewayPasswordKey);
|
||||
}
|
||||
final legacyValue = await _readSecure(_legacyGatewayPasswordKey);
|
||||
if ((legacyValue ?? '').trim().isNotEmpty) {
|
||||
return legacyValue;
|
||||
}
|
||||
for (final index in _gatewayProfileFallbackOrder) {
|
||||
final scopedValue = await _readSecure(
|
||||
_gatewayPasswordKeyForProfile(index),
|
||||
);
|
||||
if ((scopedValue ?? '').trim().isNotEmpty) {
|
||||
return scopedValue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
Future<void> clearGatewayToken({int? profileIndex}) {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveGatewayPassword(String value, {int? profileIndex}) =>
|
||||
_writeSecure(
|
||||
profileIndex == null
|
||||
? _legacyGatewayPasswordKey
|
||||
: _gatewayPasswordKeyForProfile(profileIndex),
|
||||
value,
|
||||
);
|
||||
Future<String?> loadGatewayPassword({int? profileIndex}) {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearGatewayPassword({int? profileIndex}) => _deleteSecure(
|
||||
profileIndex == null
|
||||
? _legacyGatewayPasswordKey
|
||||
: _gatewayPasswordKeyForProfile(profileIndex),
|
||||
);
|
||||
Future<void> saveGatewayPassword(String value, {int? profileIndex}) {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> loadOllamaCloudApiKey() => _readSecure(_ollamaCloudApiKeyKey);
|
||||
Future<void> clearGatewayPassword({int? profileIndex}) {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveOllamaCloudApiKey(String value) =>
|
||||
_writeSecure(_ollamaCloudApiKeyKey, value);
|
||||
Future<String?> loadOllamaCloudApiKey() {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> loadVaultToken() => _readSecure(_vaultTokenKey);
|
||||
Future<void> saveOllamaCloudApiKey(String value) {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveVaultToken(String value) =>
|
||||
_writeSecure(_vaultTokenKey, value);
|
||||
Future<String?> loadVaultToken() {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> loadAiGatewayApiKey() => _readSecure(_aiGatewayApiKeyKey);
|
||||
Future<void> saveVaultToken(String value) {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveAiGatewayApiKey(String value) =>
|
||||
_writeSecure(_aiGatewayApiKeyKey, value);
|
||||
Future<String?> loadAiGatewayApiKey() {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearAiGatewayApiKey() => _deleteSecure(_aiGatewayApiKeyKey);
|
||||
Future<void> saveAiGatewayApiKey(String value) {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, String>> loadSecureRefs() async {
|
||||
await initialize();
|
||||
final legacyGatewayToken = await _readSecure(_legacyGatewayTokenKey);
|
||||
final legacyGatewayPassword = await _readSecure(_legacyGatewayPasswordKey);
|
||||
final deviceIdentity = await loadDeviceIdentity();
|
||||
final deviceToken = deviceIdentity == null
|
||||
? null
|
||||
: await loadDeviceToken(
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
role: 'operator',
|
||||
);
|
||||
final ollamaKey = await loadOllamaCloudApiKey();
|
||||
final vaultToken = await loadVaultToken();
|
||||
final aiGatewayApiKey = await loadAiGatewayApiKey();
|
||||
final secureRefs = <String, String>{};
|
||||
if (legacyGatewayToken case final value?) {
|
||||
secureRefs['gateway_token'] = 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;
|
||||
}
|
||||
if (ollamaKey case final value?) {
|
||||
secureRefs['ollama_cloud_api_key'] = value;
|
||||
}
|
||||
if (vaultToken case final value?) {
|
||||
secureRefs['vault_token'] = value;
|
||||
}
|
||||
if (aiGatewayApiKey case final value?) {
|
||||
secureRefs['ai_gateway_api_key'] = value;
|
||||
}
|
||||
return secureRefs;
|
||||
Future<void> clearAiGatewayApiKey() {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, String>> loadSecureRefs() {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
static String gatewayTokenRefKey(int profileIndex) =>
|
||||
@ -314,87 +111,53 @@ class SecretStore {
|
||||
static String gatewayPasswordRefKey(int profileIndex) =>
|
||||
_gatewayPasswordRefKey(profileIndex);
|
||||
|
||||
Future<LocalDeviceIdentity?> loadDeviceIdentity() async {
|
||||
await initialize();
|
||||
final deviceId = await _readSecure(_gatewayDeviceIdKey);
|
||||
final publicKey = await _readSecure(_gatewayDevicePublicKeyKey);
|
||||
final privateKey = await _readSecure(_gatewayDevicePrivateKeyKey);
|
||||
if (deviceId == null || publicKey == null || privateKey == null) {
|
||||
return null;
|
||||
}
|
||||
return LocalDeviceIdentity(
|
||||
deviceId: deviceId,
|
||||
publicKeyBase64Url: publicKey,
|
||||
privateKeyBase64Url: privateKey,
|
||||
createdAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
Future<LocalDeviceIdentity?> loadDeviceIdentity() {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveDeviceIdentity(LocalDeviceIdentity identity) async {
|
||||
await initialize();
|
||||
await _writeSecure(_gatewayDeviceIdKey, identity.deviceId);
|
||||
await _writeSecure(_gatewayDevicePublicKeyKey, identity.publicKeyBase64Url);
|
||||
await _writeSecure(
|
||||
_gatewayDevicePrivateKeyKey,
|
||||
identity.privateKeyBase64Url,
|
||||
Future<void> saveDeviceIdentity(LocalDeviceIdentity identity) {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> loadDeviceToken({
|
||||
required String deviceId,
|
||||
required String role,
|
||||
}) async {
|
||||
await initialize();
|
||||
return _readSecure(_deviceTokenKey(deviceId, role));
|
||||
}) {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveDeviceToken({
|
||||
required String deviceId,
|
||||
required String role,
|
||||
required String token,
|
||||
}) async {
|
||||
await initialize();
|
||||
await _writeSecure(_deviceTokenKey(deviceId, role), token);
|
||||
}) {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearDeviceToken({
|
||||
required String deviceId,
|
||||
required String role,
|
||||
}) async {
|
||||
await initialize();
|
||||
await _deleteSecure(_deviceTokenKey(deviceId, role));
|
||||
}) {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<int>?> loadLegacyLocalStateKeyBytes() async {
|
||||
await initialize();
|
||||
final current = (await _readSecureRaw(legacyLocalStateKey))?.trim() ?? '';
|
||||
if (current.isNotEmpty) {
|
||||
return _base64UrlDecode(current);
|
||||
}
|
||||
final file = await _legacyLocalStateKeyFile();
|
||||
if (file == null || !await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
final value = (await file.readAsString()).trim();
|
||||
if (value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (_secureStorage != null) {
|
||||
try {
|
||||
await _writeSecureValue(_secureStorage!, legacyLocalStateKey, value);
|
||||
await file.delete();
|
||||
} catch (_) {
|
||||
// Keep the fallback file available for future recovery attempts.
|
||||
}
|
||||
}
|
||||
return _base64UrlDecode(value);
|
||||
Future<List<int>?> loadLegacyLocalStateKeyBytes() {
|
||||
throw StateError(
|
||||
'Legacy secret persistence removed. New secret-path store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_secureStorage = null;
|
||||
_initialized = false;
|
||||
_memorySecure.clear();
|
||||
}
|
||||
Future<void> dispose() async {}
|
||||
|
||||
static String maskValue(String value) {
|
||||
final trimmed = value.trim();
|
||||
@ -407,267 +170,9 @@ class SecretStore {
|
||||
return '${trimmed.substring(0, 3)}••••${trimmed.substring(trimmed.length - 3)}';
|
||||
}
|
||||
|
||||
Future<String?> _readSecure(String key) async {
|
||||
await initialize();
|
||||
final direct = await _readSecureRaw(key);
|
||||
if (direct != null && direct.trim().isNotEmpty) {
|
||||
return direct.trim();
|
||||
}
|
||||
final migrated = await _migrateLegacyFallbackFile(key);
|
||||
if (migrated != null && migrated.trim().isNotEmpty) {
|
||||
return migrated.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String?> _readSecureRaw(String key) async {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeSecure(String key, String value) async {
|
||||
await initialize();
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final client = await _ensureSecureStorageClient();
|
||||
try {
|
||||
await _writeSecureValue(client, key, trimmed);
|
||||
_memorySecure[key] = trimmed;
|
||||
final file = await _legacyFallbackFile(key);
|
||||
if (file != null && await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {
|
||||
final promoted = await _promoteToFileSecureStorageFallback();
|
||||
if (promoted && _secureStorage != null) {
|
||||
await _writeSecureValue(_secureStorage!, key, trimmed);
|
||||
_memorySecure[key] = trimmed;
|
||||
final file = await _legacyFallbackFile(key);
|
||||
if (file != null && await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw StateError(
|
||||
'Durable secret storage unavailable for $key: failed to write secure value.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteSecure(String key) async {
|
||||
await initialize();
|
||||
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);
|
||||
if (file != null && await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _migrateLegacyFallbackFile(String key) async {
|
||||
final file = await _legacyFallbackFile(key);
|
||||
if (file == null || !await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
final value = (await file.readAsString()).trim();
|
||||
if (value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (_secureStorage != null) {
|
||||
try {
|
||||
await _writeSecureValue(_secureStorage!, key, value);
|
||||
await file.delete();
|
||||
} catch (_) {
|
||||
// Leave the fallback file in place if migration fails.
|
||||
}
|
||||
}
|
||||
_memorySecure[key] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<File?> _legacyFallbackFile(String key) async {
|
||||
final fileName = _legacyFallbackFileNames[key];
|
||||
if (fileName == null) {
|
||||
return null;
|
||||
}
|
||||
final directory = await _resolveFallbackDirectory();
|
||||
if (directory == null) {
|
||||
return null;
|
||||
}
|
||||
return File('${directory.path}/$fileName');
|
||||
}
|
||||
|
||||
Future<File?> _legacyLocalStateKeyFile() async {
|
||||
final directory = await _resolveFallbackDirectory();
|
||||
if (directory == null) {
|
||||
return null;
|
||||
}
|
||||
return File('${directory.path}/local-state-key.txt');
|
||||
}
|
||||
|
||||
Future<Directory?> _resolveFallbackDirectory() async {
|
||||
final fallbackRoot = await _resolvePath(_fallbackDirectoryPathResolver);
|
||||
if (fallbackRoot != null) {
|
||||
return _ensureDirectory(fallbackRoot);
|
||||
}
|
||||
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();
|
||||
return _ensureDirectory(
|
||||
'${supportDirectory.path}/xworkmate/gateway-auth',
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Directory> _ensureDirectory(String path) async {
|
||||
final directory = Directory(path);
|
||||
if (!await directory.exists()) {
|
||||
await directory.create(recursive: true);
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
|
||||
Future<bool> _promoteToFileSecureStorageFallback() async {
|
||||
if (_secureStorageOverride != null) {
|
||||
return false;
|
||||
}
|
||||
final directory = await _resolveFallbackDirectory();
|
||||
if (directory == null) {
|
||||
return false;
|
||||
}
|
||||
_secureStorage = FileSecureStorageClient(() async => directory);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<String?> _readSecureValue(SecureStorageClient client, String key) {
|
||||
final future = client.read(key: key);
|
||||
if (client is FlutterSecureStorageClient) {
|
||||
return future.timeout(_secureStorageTimeout);
|
||||
}
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<void> _writeSecureValue(
|
||||
SecureStorageClient client,
|
||||
String key,
|
||||
String value,
|
||||
) {
|
||||
final future = client.write(key: key, value: value);
|
||||
if (client is FlutterSecureStorageClient) {
|
||||
return future.timeout(_secureStorageTimeout);
|
||||
}
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<void> _deleteSecureValue(SecureStorageClient client, String key) {
|
||||
final future = client.delete(key: key);
|
||||
if (client is FlutterSecureStorageClient) {
|
||||
return future.timeout(_secureStorageTimeout);
|
||||
}
|
||||
return future;
|
||||
}
|
||||
|
||||
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 const List<int> _gatewayProfileFallbackOrder = <int>[
|
||||
kGatewayRemoteProfileIndex,
|
||||
kGatewayLocalProfileIndex,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import 'dart:io';
|
||||
|
||||
export 'secret_store.dart';
|
||||
export 'settings_store.dart';
|
||||
|
||||
@ -16,14 +14,11 @@ class SecureConfigStore {
|
||||
SecureStorageClient? secureStorage,
|
||||
bool enableSecureStorage = true,
|
||||
}) {
|
||||
final resolvedDefaultSupportDirectoryPathResolver =
|
||||
defaultSupportDirectoryPathResolver ??
|
||||
_resolveDefaultSupportDirectoryPath;
|
||||
_secretStore = SecretStore(
|
||||
fallbackDirectoryPathResolver: fallbackDirectoryPathResolver,
|
||||
databasePathResolver: databasePathResolver,
|
||||
defaultSupportDirectoryPathResolver:
|
||||
resolvedDefaultSupportDirectoryPathResolver,
|
||||
defaultSupportDirectoryPathResolver,
|
||||
secureStorage: secureStorage,
|
||||
enableSecureStorage: enableSecureStorage,
|
||||
);
|
||||
@ -31,7 +26,7 @@ class SecureConfigStore {
|
||||
fallbackDirectoryPathResolver: fallbackDirectoryPathResolver,
|
||||
databasePathResolver: databasePathResolver,
|
||||
defaultSupportDirectoryPathResolver:
|
||||
resolvedDefaultSupportDirectoryPathResolver,
|
||||
defaultSupportDirectoryPathResolver,
|
||||
databaseOpener: databaseOpener,
|
||||
);
|
||||
}
|
||||
@ -155,34 +150,3 @@ class SecureConfigStore {
|
||||
return SecretStore.maskValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
const String _defaultBundleIdentifier = 'plus.svc.xworkmate';
|
||||
|
||||
Future<String?> _resolveDefaultSupportDirectoryPath() async {
|
||||
final home = Platform.environment['HOME']?.trim() ?? '';
|
||||
if (home.isNotEmpty) {
|
||||
if (Platform.isMacOS) {
|
||||
return '$home/Library/Application Support/$_defaultBundleIdentifier/xworkmate';
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
final xdgStateHome = Platform.environment['XDG_STATE_HOME']?.trim() ?? '';
|
||||
if (xdgStateHome.isNotEmpty) {
|
||||
return '$xdgStateHome/$_defaultBundleIdentifier/xworkmate';
|
||||
}
|
||||
return '$home/.local/state/$_defaultBundleIdentifier/xworkmate';
|
||||
}
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
final appData = Platform.environment['APPDATA']?.trim() ?? '';
|
||||
if (appData.isNotEmpty) {
|
||||
return '$appData\\$_defaultBundleIdentifier\\xworkmate';
|
||||
}
|
||||
final localAppData = Platform.environment['LOCALAPPDATA']?.trim() ?? '';
|
||||
if (localAppData.isNotEmpty) {
|
||||
return '$localAppData\\$_defaultBundleIdentifier\\xworkmate';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,14 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3/sqlite3.dart' as sqlite;
|
||||
|
||||
import 'runtime_models.dart';
|
||||
|
||||
typedef SecureConfigDatabaseOpener =
|
||||
FutureOr<sqlite.Database?> Function(String resolvedPath);
|
||||
typedef SecureConfigDatabaseOpener = FutureOr<Object?> Function(
|
||||
String resolvedPath,
|
||||
);
|
||||
|
||||
class SettingsStore {
|
||||
SettingsStore({
|
||||
@ -16,11 +12,7 @@ class SettingsStore {
|
||||
Future<String?> Function()? databasePathResolver,
|
||||
Future<String?> Function()? defaultSupportDirectoryPathResolver,
|
||||
SecureConfigDatabaseOpener? databaseOpener,
|
||||
}) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver,
|
||||
_databasePathResolver = databasePathResolver,
|
||||
_defaultSupportDirectoryPathResolver =
|
||||
defaultSupportDirectoryPathResolver,
|
||||
_databaseOpener = databaseOpener;
|
||||
});
|
||||
|
||||
static const String settingsKey = 'xworkmate.settings.snapshot';
|
||||
static const String auditKey = 'xworkmate.secrets.audit';
|
||||
@ -28,301 +20,51 @@ class SettingsStore {
|
||||
static const String databaseFileName = 'config-store.sqlite3';
|
||||
static const String databaseTableName = 'config_entries';
|
||||
|
||||
final Future<String?> Function()? _fallbackDirectoryPathResolver;
|
||||
final Future<String?> Function()? _databasePathResolver;
|
||||
final Future<String?> Function()? _defaultSupportDirectoryPathResolver;
|
||||
final SecureConfigDatabaseOpener? _databaseOpener;
|
||||
sqlite.Database? _database;
|
||||
String? _resolvedDatabasePath;
|
||||
bool _initialized = false;
|
||||
Future<void> initialize() async {}
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) {
|
||||
return;
|
||||
}
|
||||
await _initializeDatabase();
|
||||
_initialized = true;
|
||||
Future<SettingsSnapshot> loadSettingsSnapshot() {
|
||||
throw StateError(
|
||||
'Legacy settings persistence removed. New file-based settings store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<SettingsSnapshot> loadSettingsSnapshot() async {
|
||||
await initialize();
|
||||
final raw = await _readStoredString(settingsKey);
|
||||
return _decodeSettingsSnapshot(raw) ?? SettingsSnapshot.defaults();
|
||||
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) {
|
||||
throw StateError(
|
||||
'Legacy settings persistence removed. New file-based settings store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
|
||||
await initialize();
|
||||
final encoded = snapshot.toJsonString();
|
||||
await _writeStoredString(settingsKey, encoded);
|
||||
}
|
||||
|
||||
Future<List<AssistantThreadRecord>> loadAssistantThreadRecords() async {
|
||||
await initialize();
|
||||
final raw = await _readStoredString(assistantThreadsKey);
|
||||
return _decodeAssistantThreadRecords(raw) ??
|
||||
const <AssistantThreadRecord>[];
|
||||
Future<List<AssistantThreadRecord>> loadAssistantThreadRecords() {
|
||||
throw StateError(
|
||||
'Legacy settings persistence removed. New file-based settings store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveAssistantThreadRecords(
|
||||
List<AssistantThreadRecord> records,
|
||||
) async {
|
||||
await initialize();
|
||||
final encoded = jsonEncode(
|
||||
records.map((item) => item.toJson()).toList(growable: false),
|
||||
);
|
||||
await _writeStoredString(assistantThreadsKey, encoded);
|
||||
}
|
||||
|
||||
Future<void> clearAssistantLocalState() async {
|
||||
await initialize();
|
||||
await _deleteStoredString(settingsKey);
|
||||
await _deleteStoredString(assistantThreadsKey);
|
||||
}
|
||||
|
||||
Future<List<SecretAuditEntry>> loadAuditTrail() async {
|
||||
await initialize();
|
||||
final raw = await _readStoredString(auditKey);
|
||||
if (raw == null || raw.trim().isEmpty) {
|
||||
return const <SecretAuditEntry>[];
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(raw) as List<dynamic>;
|
||||
return decoded
|
||||
.map(
|
||||
(item) => SecretAuditEntry.fromJson(
|
||||
(item as Map).cast<String, dynamic>(),
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
} catch (_) {
|
||||
return const <SecretAuditEntry>[];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> appendAudit(SecretAuditEntry entry) async {
|
||||
final items = (await loadAuditTrail()).toList(growable: true);
|
||||
items.insert(0, entry);
|
||||
if (items.length > 40) {
|
||||
items.removeRange(40, items.length);
|
||||
}
|
||||
await _writeStoredString(
|
||||
auditKey,
|
||||
jsonEncode(items.map((item) => item.toJson()).toList(growable: false)),
|
||||
) {
|
||||
throw StateError(
|
||||
'Legacy settings persistence removed. New file-based settings store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
final database = _database;
|
||||
_database = null;
|
||||
if (database != null) {
|
||||
try {
|
||||
database.dispose();
|
||||
} catch (_) {
|
||||
// Ignore close errors during teardown.
|
||||
}
|
||||
}
|
||||
_initialized = false;
|
||||
_resolvedDatabasePath = null;
|
||||
}
|
||||
|
||||
Future<void> _initializeDatabase() async {
|
||||
final resolvedPath = await _resolveDatabasePath();
|
||||
try {
|
||||
_database = await _openDatabase(resolvedPath);
|
||||
_resolvedDatabasePath = resolvedPath;
|
||||
} catch (error) {
|
||||
throw StateError(
|
||||
'Durable settings storage unavailable: failed to open $resolvedPath. Cause: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<sqlite.Database> _openDatabase(String resolvedPath) async {
|
||||
if (_databaseOpener != null) {
|
||||
final database = await _databaseOpener(resolvedPath);
|
||||
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()) {
|
||||
await file.parent.create(recursive: true);
|
||||
}
|
||||
final database = sqlite.sqlite3.open(file.path);
|
||||
_configureDatabase(database);
|
||||
return database;
|
||||
}
|
||||
|
||||
void _configureDatabase(sqlite.Database database) {
|
||||
database.execute('''
|
||||
CREATE TABLE IF NOT EXISTS $databaseTableName (
|
||||
storage_key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<String> _resolveDatabasePath() async {
|
||||
final resolved = _resolvedDatabasePath?.trim() ?? '';
|
||||
if (resolved.isNotEmpty) {
|
||||
return resolved;
|
||||
}
|
||||
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,
|
||||
Future<void> clearAssistantLocalState() {
|
||||
throw StateError(
|
||||
'Legacy settings persistence removed. New file-based settings store is pending implementation.',
|
||||
);
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _readStoredString(String key) async {
|
||||
if (_database == null) {
|
||||
throw StateError(
|
||||
'Durable settings storage unavailable: database not initialized.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
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 (_) {
|
||||
throw StateError(
|
||||
'Durable settings storage unavailable: failed to read $key from $_resolvedDatabasePath.',
|
||||
);
|
||||
}
|
||||
Future<List<SecretAuditEntry>> loadAuditTrail() {
|
||||
throw StateError(
|
||||
'Legacy settings persistence removed. New file-based settings store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _writeStoredString(String key, String value) async {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (_database == null) {
|
||||
throw StateError(
|
||||
'Durable settings storage unavailable: database not initialized.',
|
||||
);
|
||||
}
|
||||
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> appendAudit(SecretAuditEntry entry) {
|
||||
throw StateError(
|
||||
'Legacy settings persistence removed. New file-based settings store is pending implementation.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteStoredString(String key) async {
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSnapshot? _decodeSettingsSnapshot(String? raw) {
|
||||
final trimmed = raw?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final decodedValue = jsonDecode(trimmed);
|
||||
if (decodedValue is! Map) {
|
||||
return null;
|
||||
}
|
||||
final decoded = decodedValue.cast<String, dynamic>();
|
||||
if (!_looksLikeSettingsSnapshot(decoded)) {
|
||||
return null;
|
||||
}
|
||||
return SettingsSnapshot.fromJson(decoded);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<AssistantThreadRecord>? _decodeAssistantThreadRecords(String? raw) {
|
||||
final trimmed = raw?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(trimmed) as List<dynamic>;
|
||||
return decoded
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) =>
|
||||
AssistantThreadRecord.fromJson(item.cast<String, dynamic>()),
|
||||
)
|
||||
.toList(growable: false);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _looksLikeSettingsSnapshot(Map<String, dynamic> json) {
|
||||
return json.containsKey('appLanguage') ||
|
||||
json.containsKey('gateway') ||
|
||||
json.containsKey('gatewayProfiles') ||
|
||||
json.containsKey('aiGateway') ||
|
||||
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;
|
||||
}
|
||||
}
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user